/** * SSE streaming endpoint for the chat agent. * This is a raw Express route (not tRPC) because tRPC doesn't support SSE streaming well. */ import { Request, Response } from "express"; import { runAgentLoop } from "./agent"; import * as db from "../db"; import { buildSystemPrompt } from "./system-prompt"; import { getToolList, getPlanMode, setPlanMode, addPlanStep, updatePlanStep, getEffortLevel, setEffortLevel, getSessionSkills, getSessionTasks } from "../tools/executor"; // Simple auth check — reuse the session cookie, with dev-mode bypass async function getAuthUser(req: Request) { // Dev mode: auto-authenticate as dev user (same as tRPC context) if (process.env.DEV_MODE === "true" || !process.env.OAUTH_SERVER_URL) { const user = await db.getUserByOpenId("dev-user"); if (user) return user; // Seed dev user if missing await db.upsertUser({ openId: "dev-user", name: "Developer", email: "dev@claw.local", role: "admin", }); return await db.getUserByOpenId("dev-user") || null; } // Production mode: use OAuth SDK try { const { sdk } = await import("../_core/sdk"); return await sdk.authenticateRequest(req); } catch { return null; } } /** * POST /api/chat/stream * Body: { sessionId: number, message: string } * Response: SSE stream */ export async function handleChatStream(req: Request, res: Response) { const user = await getAuthUser(req); if (!user) { res.status(401).json({ error: "Unauthorized" }); return; } const { sessionId, message } = req.body; if (!sessionId || !message) { res.status(400).json({ error: "sessionId and message are required" }); return; } // Verify session ownership const session = await db.getSession(sessionId, user.id); if (!session) { res.status(404).json({ error: "Session not found" }); return; } // Get user settings const settings = await db.getUserSettings(user.id); // Set up SSE headers res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", "X-Accel-Buffering": "no", }); // Handle client disconnect // Bug #5 fix: Track disconnect state separately from abort. // On disconnect we still abort the loop, but we also try to save // any messages that were produced before the disconnect. const controller = new AbortController(); let clientDisconnected = false; req.on("close", () => { clientDisconnected = true; controller.abort(); }); try { // Save user message to DB await db.addMessage({ sessionId, role: "user", content: message, }); // Load conversation history const historyMessages = await db.getSessionMessages(sessionId); // Convert DB messages to agent format // IMPORTANT: tool_calls is stored as JSON string in DB, must parse back to array const agentMessages = historyMessages.map((m) => { let toolCalls = m.toolCalls; if (typeof toolCalls === "string") { try { toolCalls = JSON.parse(toolCalls); } catch { toolCalls = undefined; } } return { role: m.role as "user" | "assistant" | "system" | "tool", content: m.content, tool_calls: toolCalls || undefined, tool_call_id: m.toolCallId || undefined, name: m.toolName || undefined, }; }); // Get plan mode and effort level const planMode = getPlanMode(sessionId); const effortLevel = getEffortLevel(sessionId); // Build config from settings // Sanitize settings — treat empty strings, "default", and masked values as null const sanitizedModel = process.env.DEFAULT_MODEL || "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo"; const sanitizedProvider = "deepinfra"; const sanitizedKey = null; // Hardcoded — uses BUILT_IN_FORGE_API_KEY const sanitizedBaseUrl = null; // Hardcoded — uses BUILT_IN_FORGE_API_URL // FIX RC1: Do NOT pre-build system prompt here. // agent.ts builds the system prompt itself using buildSystemPrompt(). // Previously, we built a full prompt here AND agent.ts built another one, // causing the model to see retry instructions TWICE (double reinforcement). // Now we pass only the raw custom prompt text (if any) for agent.ts to use. const config = { model: sanitizedModel, apiProvider: sanitizedProvider, apiKey: sanitizedKey, apiBaseUrl: sanitizedBaseUrl, maxTokens: settings?.maxTokens || 16384, temperature: settings?.temperature ?? 0.7, topP: settings?.topP ?? 1, systemPrompt: session.systemPrompt || settings?.systemPrompt || null, memory: settings?.memoryContent || null, workDir: "/home/ubuntu", effortLevel, }; // Run the agent loop const result = await runAgentLoop( agentMessages, sessionId, config, res, controller.signal ); // Save assistant messages and tool results to DB // Bug #5 fix: Save even if client disconnected (data was still produced) try { for (const msg of result.finalMessages) { if (historyMessages.some((h) => h.content === msg.content && h.role === msg.role)) { continue; } if (msg.role === "assistant") { await db.addMessage({ sessionId, role: "assistant", content: msg.content, toolCalls: msg.tool_calls ? JSON.stringify(msg.tool_calls) : null, promptTokens: result.totalPromptTokens, completionTokens: result.totalCompletionTokens, cost: result.totalCost, model: result.model, }); } else if (msg.role === "tool") { await db.addMessage({ sessionId, role: "tool", content: msg.content, toolCallId: msg.tool_call_id || null, toolName: msg.name || null, }); } } } catch (dbErr: any) { console.error(`[chat-endpoint] Failed to save messages to DB:`, dbErr.message); } // Record cost if (result.totalCost > 0) { await db.addCostRecord({ userId: user.id, sessionId, model: result.model, promptTokens: result.totalPromptTokens, completionTokens: result.totalCompletionTokens, totalCost: result.totalCost, }); } // Auto-generate title if this is the first message if (historyMessages.length <= 1) { try { const titlePrompt = `Generate a very short title (3-6 words) for a conversation that starts with: "${message.substring(0, 200)}". Return ONLY the title, no quotes.`; const { invokeLLM } = await import("../_core/llm"); const titleResult = await invokeLLM({ messages: [ { role: "system", content: "You generate short conversation titles. Return only the title text." }, { role: "user", content: titlePrompt }, ], }); const title = (typeof titleResult.choices[0]?.message?.content === "string" ? titleResult.choices[0].message.content : "New Session" ).trim().replace(/^["']|["']$/g, ""); if (title && title.length < 100) { await db.updateSession(sessionId, user.id, { title }); res.write(`event: title_update\ndata: ${JSON.stringify({ title })}\n\n`); } } catch { // Title generation is non-critical } } } catch (error: any) { if (!clientDisconnected) { try { res.write(`event: error\ndata: ${JSON.stringify({ message: error.message })}\n\n`); } catch { /* connection already closed */ } } } finally { if (!clientDisconnected) { try { res.write("event: done\ndata: {}\n\n"); res.end(); } catch { /* connection already closed */ } } } } /** * POST /api/chat/slash * Handle slash commands — full parity with original claw-code */ export async function handleSlashCommand(req: Request, res: Response) { const user = await getAuthUser(req); if (!user) { res.status(401).json({ error: "Unauthorized" }); return; } const { command, args, sessionId } = req.body; const settings = await db.getUserSettings(user.id); try { let result: string; switch (command) { // ─── /help ────────────────────────────────────────────────────── case "/help": result = `## Available Commands | Command | Description | |---------|-------------| | \`/help\` | Show available slash commands | | \`/status\` | Show current session status | | \`/compact\` | Compact local session history | | \`/model [model]\` | Show or switch the active model | | \`/permissions [mode]\` | Show or switch the active permission mode | | \`/clear\` | Start a fresh local session | | \`/cost\` | Show cumulative token usage for this session | | \`/resume\` | Load a saved session into the REPL | | \`/config\` | Inspect Claw config files or merged sections | | \`/memory\` | Inspect loaded Claw instruction memory files | | \`/init\` | Create a starter CLAW.md for this repo | | \`/diff\` | Show git diff for current workspace changes | | \`/version\` | Show CLI version and build information | | \`/bughunter [scope]\` | Inspect the codebase for likely bugs | | \`/branch\` | List, create, or switch git branches | | \`/worktree\` | List, add, remove, or prune git worktrees | | \`/commit\` | Generate a commit message and create a git commit | | \`/commit-push-pr\` | Commit workspace changes, push the branch, and open a PR | | \`/pr [context]\` | Draft or create a pull request from the conversation | | \`/issue [context]\` | Draft or create a GitHub issue from the conversation | | \`/ultraplan [task]\` | Run a deep planning prompt with multi-step reasoning | | \`/teleport \` | Jump to a file or symbol by searching the workspace | | \`/debug-tool-call\` | Replay the last tool call with debug details | | \`/export [file]\` | Export the current conversation to a file | | \`/session\` | List or switch managed local sessions | | \`/plugin\` | Manage Claw Code plugins | | \`/agents\` | List configured agents | | \`/skills\` | List available skills |`; break; // ─── /clear ───────────────────────────────────────────────────── case "/clear": if (args === "--confirm" || args?.includes("--confirm")) { if (sessionId) { await db.clearSessionMessages(sessionId); } result = "Conversation cleared."; } else { result = "Are you sure you want to clear the conversation? This cannot be undone.\n\nRun `/clear --confirm` to confirm."; } break; // ─── /model ───────────────────────────────────────────────────── case "/model": if (args) { await db.upsertSettings(user.id, { model: args }); if (sessionId) { await db.updateSession(sessionId, user.id, { model: args }); } result = `Model switched to: **${args}**`; } else { result = `Current model: **${settings?.model || "XiaomiMiMo/MiMo-V2-Flash (default)"}** Available models (HuggingFace Inference API): - \`XiaomiMiMo/MiMo-V2-Flash\` — MiMo V2 Flash (default, free) - \`Qwen/Qwen3-Coder-Next\` — Qwen3 Coder Next (free) - \`Qwen/Qwen3-8B\` — Qwen3 8B (free) - \`Qwen/Qwen3-Coder-30B-A3B-Instruct\` — Qwen3 Coder 30B (free) - \`meta-llama/Llama-3.3-70B-Instruct\` — Llama 3.3 70B (free) - \`deepseek-ai/DeepSeek-V3.2\` — DeepSeek V3.2 (free) - \`deepseek-ai/DeepSeek-R1\` — DeepSeek R1 (free) Aliases: \`mimo\`, \`qwen-coder\`, \`llama\`, \`deepseek\` Usage: \`/model \``; } break; // ─── /system ──────────────────────────────────────────────────── case "/system": if (args) { await db.upsertSettings(user.id, { systemPrompt: args }); result = `System prompt updated.`; } else { result = `Current system prompt:\n\`\`\`\n${settings?.systemPrompt || "(default)"}\n\`\`\``; } break; // ─── /cost ────────────────────────────────────────────────────── case "/cost": { // Matches original format_usd() and summary_lines_for_model() const { formatUsd: fmtUsd, summaryLinesForModel: summaryLines, pricingForModel: pricingFor, defaultSonnetTierPricing: defaultPricing } = await import("./usage"); const costSummary = await db.getUserCostSummary(user.id); let sessionCost = null; if (sessionId) { sessionCost = await db.getSessionCostSummary(sessionId); } const currentModel = settings?.model || "XiaomiMiMo/MiMo-V2-Flash"; const pricing = pricingFor(currentModel) ?? defaultPricing(); result = `## Cost Summary | Metric | Value | |--------|-------| | Total Cost | ${fmtUsd(costSummary.totalCost)} | | Prompt Tokens | ${costSummary.totalPromptTokens.toLocaleString()} | | Completion Tokens | ${costSummary.totalCompletionTokens.toLocaleString()} | | Model | ${currentModel} | | Input Price | ${fmtUsd(pricing.input_cost_per_million)}/M tokens | | Output Price | ${fmtUsd(pricing.output_cost_per_million)}/M tokens | | Cache Creation | ${fmtUsd(pricing.cache_creation_cost_per_million)}/M tokens | | Cache Read | ${fmtUsd(pricing.cache_read_cost_per_million)}/M tokens |`; if (sessionCost) { const sessionLines = summaryLines( { input_tokens: sessionCost.totalPromptTokens, output_tokens: sessionCost.totalCompletionTokens, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, "session", currentModel ); result += `\n\n### This Session\n\`\`\`\n${sessionLines.join("\n")}\n\`\`\``; } break; } // ─── /sessions ────────────────────────────────────────────────── case "/sessions": { const sessions = await db.getUserSessions(user.id); if (sessions.length === 0) { result = "No sessions found."; } else { result = "## Sessions\n\n" + sessions.map((s, i) => `${i + 1}. **${s.title}** (${s.model || "default"}) — ${new Date(s.updatedAt).toLocaleDateString()}` ).join("\n"); } break; } // ─── /tools ───────────────────────────────────────────────────── case "/tools": { const tools = getToolList(); const categories = new Map(); for (const t of tools) { if (!categories.has(t.category)) categories.set(t.category, []); categories.get(t.category)!.push(t); } let toolsResult = "## Available Tools\n\n"; categories.forEach((catTools, cat) => { toolsResult += `### ${cat}\n| Tool | Description |\n|------|-------------|\n`; for (const t of catTools) { toolsResult += `| \`${t.name}\` | ${t.description} |\n`; } toolsResult += "\n"; }); result = toolsResult; break; } // ─── /permissions ─────────────────────────────────────────────── case "/permissions": { const perms = await db.getToolPermissions(user.id); if (perms.length === 0) { result = "No custom permissions set. All tools are allowed by default."; } else { result = "## Tool Permissions\n\n| Tool | Status | Confirmation |\n|------|--------|-------------|\n" + perms.map((p) => `| \`${p.toolName}\` | ${p.allowed ? "✓ Allowed" : "✗ Denied"} | ${p.requireConfirmation ? "Required" : "Not required"} |` ).join("\n"); } break; } // ─── /config ──────────────────────────────────────────────────── case "/config": result = `## Current Configuration | Setting | Value | |---------|-------| | Provider | ${settings?.apiProvider || "huggingface"} | | Model | ${settings?.model || "XiaomiMiMo/MiMo-V2-Flash"} | | Max Tokens | ${settings?.maxTokens || 16384} | | Temperature | ${settings?.temperature || 0.7} | | Top P | ${settings?.topP || 1} | | Theme | ${settings?.theme || "dark"} | | Effort Level | ${sessionId ? getEffortLevel(sessionId) : "high"} | | Plan Mode | ${sessionId ? (getPlanMode(sessionId).active ? "Active" : "Inactive") : "N/A"} | | API | Built-in HuggingFace Router |`; break; // ─── /memory ──────────────────────────────────────────────────── case "/memory": if (args) { await db.upsertSettings(user.id, { memoryContent: args }); result = "Memory (CLAW.md) updated."; } else { result = `## CLAW.md Memory\n\n${settings?.memoryContent || "(empty — use /memory to set)"}`; } break; // ─── /mcp ─────────────────────────────────────────────────────── case "/mcp": { const servers = await db.getMcpServers(user.id); if (servers.length === 0) { result = "No MCP servers configured. Use Settings to add one."; } else { result = "## MCP Servers\n\n" + servers.map((s) => `- **${s.name}** (${s.transport}) — ${s.enabled ? "✓ Enabled" : "✗ Disabled"}\n URL: ${s.url}` ).join("\n"); } break; } // ─── /compact ─────────────────────────────────────────────────── case "/compact": { if (!sessionId) { result = "No active session to compact."; break; } const compactMessages = await db.getSessionMessages(sessionId); if (compactMessages.length < 4) { result = "Session too short to compact."; break; } try { const { dbMessagesToSession, shouldCompact, compactSession, DEFAULT_COMPACTION_CONFIG } = await import("./compact"); const session = dbMessagesToSession(compactMessages.map(m => ({ ...m, content: m.content || "" }))); // Use context-aware threshold (70% of model window) instead of fixed 10k const MODEL_CONTEXT = 262144; // MiMo-V2-Flash default const dynamicConfig = { preserveRecentMessages: DEFAULT_COMPACTION_CONFIG.preserveRecentMessages, maxEstimatedTokens: Math.floor(MODEL_CONTEXT * 0.7), }; // For /compact slash command, always allow manual compaction if >8 messages const forceCompact = compactMessages.length >= 8; if (!forceCompact && !shouldCompact(session, dynamicConfig)) { result = "Session does not need compaction yet (below token threshold)."; break; } // Use LLM to generate a rich summary of removed messages const { invokeLLM } = await import("../_core/llm"); const compactionResult = compactSession(session, dynamicConfig); const removedText = compactMessages .slice(0, compactMessages.length - dynamicConfig.preserveRecentMessages) .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => `${m.role}: ${(m.content || "").slice(0, 500)}`) .join("\n"); const summaryResponse = await invokeLLM({ messages: [ { role: "system", content: "Summarize this conversation concisely, preserving key decisions, code changes, file paths, and important context. Use ... tags around the summary. Output in markdown.", }, { role: "user", content: removedText.slice(0, 8000) }, ], }); const rawContent = summaryResponse?.choices?.[0]?.message?.content; const llmSummary = typeof rawContent === "string" ? rawContent : compactionResult.formattedSummary; // Clear and rebuild session with compacted messages await db.clearSessionMessages(sessionId); // Add the continuation message const { getCompactContinuationMessage } = await import("./compact"); const continuation = getCompactContinuationMessage(llmSummary, true, true); await db.addMessage({ sessionId, role: "system", content: continuation, }); // Re-add preserved recent messages const preservedCount = dynamicConfig.preserveRecentMessages; const preserved = compactMessages.slice(-preservedCount); for (const msg of preserved) { await db.addMessage({ sessionId, role: msg.role, content: msg.content, toolCallId: msg.toolCallId || undefined, toolName: msg.toolName || undefined, }); } result = `Context compacted! ${compactionResult.removedMessageCount} messages summarized, ${preservedCount} recent messages preserved.\n\n${compactionResult.formattedSummary}`; } catch (compactErr: any) { result = `Compaction failed: ${compactErr.message}. Use /clear to start fresh.`; } break; } // ─── /plan ────────────────────────────────────────────────────── case "/plan": { if (!sessionId) { result = "No active session."; break; } const plan = getPlanMode(sessionId); if (!args) { // Toggle plan mode setPlanMode(sessionId, !plan.active); result = plan.active ? "Plan mode **deactivated**. Returning to normal mode." : "Plan mode **activated**! Use `/plan add ` to add steps, `/plan done ` to mark complete."; break; } const planSubCmd = args.split(" ")[0]; const planArgs = args.split(" ").slice(1).join(" "); switch (planSubCmd) { case "add": if (!planArgs) { result = "Usage: `/plan add `"; break; } addPlanStep(sessionId, planArgs); result = `Plan step added: "${planArgs}"`; break; case "done": { const stepId = parseInt(planArgs); if (isNaN(stepId)) { result = "Usage: `/plan done `"; break; } updatePlanStep(sessionId, stepId, "done"); result = `Step ${stepId} marked as done.`; break; } case "skip": { const skipId = parseInt(planArgs); if (isNaN(skipId)) { result = "Usage: `/plan skip `"; break; } updatePlanStep(sessionId, skipId, "skipped"); result = `Step ${skipId} skipped.`; break; } case "show": { const currentPlan = getPlanMode(sessionId); if (currentPlan.steps.length === 0) { result = "No plan steps defined. Use `/plan add ` to add steps."; } else { result = "## Current Plan\n\n" + currentPlan.steps.map(s => { const icon = s.status === "done" ? "✓" : s.status === "in_progress" ? "●" : s.status === "skipped" ? "○" : "□"; return `${icon} **Step ${s.id}:** ${s.text} \`[${s.status}]\``; }).join("\n"); } break; } case "clear": setPlanMode(sessionId, false); planModes_clear(sessionId); result = "Plan cleared and plan mode deactivated."; break; default: result = `## Plan Commands\n\n| Command | Description |\n|---------|-------------|\n| \`/plan\` | Toggle plan mode |\n| \`/plan add \` | Add a plan step |\n| \`/plan done \` | Mark step as done |\n| \`/plan skip \` | Skip a step |\n| \`/plan show\` | Show current plan |\n| \`/plan clear\` | Clear all steps |`; } break; } // ─── /status ──────────────────────────────────────────────────── case "/status": { const { execSync: statusExec } = await import("child_process"); const uptime = process.uptime(); const memUsage = process.memoryUsage(); let diskInfo = "N/A"; let gitBranch = "N/A"; try { diskInfo = statusExec("df -h / | tail -1 | awk '{print $3\"/\"$2\" (\"$5\" used)\"}'", { timeout: 3000 }).toString().trim(); } catch {} try { gitBranch = statusExec("git branch --show-current 2>/dev/null || echo 'N/A'", { cwd: "/home/ubuntu", timeout: 3000 }).toString().trim(); } catch {} const sessionCount = (await db.getUserSessions(user.id)).length; const costSummary = await db.getUserCostSummary(user.id); const effort = sessionId ? getEffortLevel(sessionId) : "high"; const planState = sessionId ? getPlanMode(sessionId) : { active: false, steps: [] }; // Match original format_status_report: git branch, config files, memory files let configFiles = 0; let memoryFiles = 0; try { const { readdirSync, existsSync } = await import("fs"); if (existsSync("/home/ubuntu/.claw")) { configFiles = readdirSync("/home/ubuntu/.claw", { recursive: true }).length; } // Count CLAW.md memory files const checkPaths = ["/home/ubuntu/CLAW.md", "/home/ubuntu/.claw/CLAW.md"]; for (const p of checkPaths) { if (existsSync(p)) memoryFiles++; } } catch {} let gitStatus = "N/A"; try { gitStatus = statusExec("git --no-optional-locks status --short 2>/dev/null | head -5", { cwd: "/home/ubuntu", timeout: 3000 }).toString().trim() || "clean"; } catch {} const { formatUsd: statusFmtUsd } = await import("./usage"); result = `## System Status | Metric | Value | |--------|-------| | Model | ${settings?.model || "XiaomiMiMo/MiMo-V2-Flash"} | | Provider | ${settings?.apiProvider || "huggingface"} | | Effort Level | ${effort} | | Plan Mode | ${planState.active ? `Active (${planState.steps.filter(s => s.status === "done").length}/${planState.steps.length} steps)` : "Inactive"} | | Sessions | ${sessionCount} | | Total Cost | ${statusFmtUsd(costSummary.totalCost)} | | Git Branch | ${gitBranch} | | Git Status | ${gitStatus} | | Config Files | ${configFiles} | | Memory Files | ${memoryFiles} | | Server Uptime | ${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s | | Memory (RSS) | ${(memUsage.rss / 1024 / 1024).toFixed(1)} MB | | Heap Used | ${(memUsage.heapUsed / 1024 / 1024).toFixed(1)} MB | | Disk | ${diskInfo} | | Node.js | ${process.version} |`; break; } // ─── /diff ────────────────────────────────────────────────────── case "/diff": { const { execSync: diffExec } = await import("child_process"); let diffCwd = "/home/ubuntu"; try { diffCwd = diffExec("git rev-parse --show-toplevel 2>/dev/null || echo /home/ubuntu", { cwd: "/home/ubuntu", timeout: 3000 }).toString().trim(); } catch {} try { let diffOutput: string; if (args) { diffOutput = diffExec(`git diff -- "${args}"`, { cwd: diffCwd, timeout: 10000, maxBuffer: 1024 * 1024 }).toString(); } else { diffOutput = diffExec("git diff", { cwd: diffCwd, timeout: 10000, maxBuffer: 1024 * 1024 }).toString(); } result = diffOutput ? `## Git Diff${args ? ` (${args})` : ""}\n\n\`\`\`diff\n${diffOutput.slice(0, 5000)}\n\`\`\`` : "No changes detected."; } catch (e: any) { result = `Diff error: ${e.message}`; } break; } // ─── /context ─────────────────────────────────────────────────── case "/context": { if (!sessionId) { result = "No active session."; break; } const ctxMessages = await db.getSessionMessages(sessionId); let totalChars = 0; let totalTokensEstimate = 0; const roleCounts: Record = {}; for (const m of ctxMessages) { const len = (m.content || "").length; totalChars += len; totalTokensEstimate += Math.ceil(len / 4); // rough estimate roleCounts[m.role] = (roleCounts[m.role] || 0) + 1; } const maxContextTokens = 128000; // typical context window const usagePercent = Math.min(100, Math.round((totalTokensEstimate / maxContextTokens) * 100)); const bar = "█".repeat(Math.round(usagePercent / 5)) + "░".repeat(20 - Math.round(usagePercent / 5)); result = `## Context Window Usage \`[${bar}]\` ${usagePercent}% | Metric | Value | |--------|-------| | Messages | ${ctxMessages.length} | | Characters | ${totalChars.toLocaleString()} | | Est. Tokens | ~${totalTokensEstimate.toLocaleString()} | | Context Limit | ~${maxContextTokens.toLocaleString()} | | User Messages | ${roleCounts["user"] || 0} | | Assistant Messages | ${roleCounts["assistant"] || 0} | | Tool Messages | ${roleCounts["tool"] || 0} | | System Messages | ${roleCounts["system"] || 0} | ${usagePercent > 75 ? "⚠ Context is getting full. Consider using `/compact` to summarize." : "✓ Context window has plenty of room."}`; break; } // ─── /summary ─────────────────────────────────────────────────── case "/summary": { if (!sessionId) { result = "No active session."; break; } const summaryMessages = await db.getSessionMessages(sessionId); if (summaryMessages.length < 2) { result = "Not enough messages to summarize."; break; } try { const { invokeLLM } = await import("../_core/llm"); const conversationText = summaryMessages .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => `${m.role}: ${(m.content || "").slice(0, 300)}`) .join("\n"); const summaryResponse = await invokeLLM({ messages: [ { role: "system", content: "Provide a concise summary of this conversation. Include: 1) Main topic/goal, 2) Key decisions made, 3) Files modified, 4) Current status. Format in markdown.", }, { role: "user", content: conversationText.slice(0, 6000) }, ], }); const summary = summaryResponse?.choices?.[0]?.message?.content || "(summary failed)"; result = `## Conversation Summary\n\n${summary}`; } catch (e: any) { result = `Summary failed: ${e.message}`; } break; } // ─── /effort ──────────────────────────────────────────────────── case "/effort": { if (!sessionId) { result = "No active session."; break; } if (args && ["low", "medium", "high"].includes(args.trim())) { setEffortLevel(sessionId, args.trim() as "low" | "medium" | "high"); const descriptions: Record = { low: "Quick, concise responses. Minimal explanation.", medium: "Balanced responses. Key decisions explained.", high: "Thorough responses. Full verification and testing.", }; result = `Effort level set to **${args.trim()}**: ${descriptions[args.trim()]}`; } else { const current = getEffortLevel(sessionId); result = `Current effort level: **${current}**\n\nUsage: \`/effort low|medium|high\`\n\n| Level | Description |\n|-------|-------------|\n| low | Quick, concise. Minimal explanation. |\n| medium | Balanced. Key decisions explained. |\n| high | Thorough. Full verification and testing. |`; } break; } // ─── /rewind ──────────────────────────────────────────────────── case "/rewind": { if (!sessionId) { result = "No active session."; break; } const rewindCount = parseInt(args || "2") || 2; const rewindMessages = await db.getSessionMessages(sessionId); if (rewindMessages.length <= rewindCount) { result = `Cannot rewind ${rewindCount} messages — only ${rewindMessages.length} messages in session. Use /clear instead.`; break; } // Delete the last N messages const toDelete = rewindMessages.slice(-rewindCount); for (const msg of toDelete) { await db.deleteMessage(msg.id); } result = `Rewound ${rewindCount} messages. Conversation rolled back to ${rewindMessages.length - rewindCount} messages.`; break; } // ─── /agents ──────────────────────────────────────────────────── case "/agents": { result = `## Agent Types | Agent | Description | |-------|-------------| | \`general_purpose\` | General coding assistant | | \`explore\` | Codebase exploration and analysis | | \`plan\` | Planning and architecture | | \`verification\` | Testing and verification | | \`claw_code_guide\` | Claw Code usage guide | Use the \`sub_agent\` tool to spawn agents during conversation.`; break; } // ─── /resume ──────────────────────────────────────────────────── case "/resume": { // Matches original: /resume // In web context: /resume with no args lists sessions, /resume loads from exported JSON if (!args || !args.trim()) { // No args: list recent sessions (web equivalent of "Usage: /resume ") const allSessions = await db.getUserSessions(user.id); if (allSessions.length === 0) { result = "No previous sessions to resume."; break; } const recent = allSessions.slice(0, 10); result = "Usage: `/resume ` or paste exported JSON\n\n## Recent Sessions\n\n" + recent.map((s, i) => `${i + 1}. **${s.title}** (id: ${s.id}) — ${new Date(s.updatedAt).toLocaleDateString()} (${s.model || "default"})`).join("\n"); break; } const resumeArg = args.trim(); // Try to parse as session ID (number) const resumeSessionId = parseInt(resumeArg, 10); if (!isNaN(resumeSessionId)) { const targetSession = await db.getSession(resumeSessionId, user.id); if (!targetSession) { result = `Session ${resumeSessionId} not found.`; break; } const sessionMessages = await db.getSessionMessages(resumeSessionId); const messageCount = sessionMessages.length; // Calculate turns (pairs of user+assistant) const turns = sessionMessages.filter(m => m.role === "assistant").length; // format_resume_report — matches original exactly result = `Session resumed\n Session file session:${resumeSessionId}\n Messages ${messageCount}\n Turns ${turns}`; // Signal frontend to switch to this session result = `__RESUME__${JSON.stringify({ sessionId: resumeSessionId, title: targetSession.title, messageCount, turns })}`; break; } // Try to parse as exported JSON (from /export) try { const exportedData = JSON.parse(resumeArg); if (exportedData.messages && Array.isArray(exportedData.messages)) { // Create new session from exported data const newSession = await db.createSession({ userId: user.id, title: exportedData.session?.title || "Resumed Session" }); // Insert all messages for (const m of exportedData.messages) { await db.addMessage({ sessionId: newSession.id, role: m.role, content: m.content || null, toolCalls: m.toolCalls || null, toolName: m.toolName || null, }); } const messageCount = exportedData.messages.length; const turns = exportedData.messages.filter((m: any) => m.role === "assistant").length; result = `__RESUME__${JSON.stringify({ sessionId: newSession.id, title: newSession.title, messageCount, turns })}`; break; } } catch { // Not valid JSON } result = `Could not resume: "${resumeArg.slice(0, 50)}..." is not a valid session ID or exported JSON.`; break; } // ─── /doctor ──────────────────────────────────────────────────── case "/doctor": { const { execSync: docExec } = await import("child_process"); const checks: { name: string; status: string; detail: string }[] = []; // Check Node.js checks.push({ name: "Node.js", status: "✓", detail: process.version }); // Check git try { const gitVer = docExec("git --version", { timeout: 3000 }).toString().trim(); checks.push({ name: "Git", status: "✓", detail: gitVer }); } catch { checks.push({ name: "Git", status: "✗", detail: "Not installed" }); } // Check Python try { const pyVer = docExec("python3 --version 2>&1", { timeout: 3000 }).toString().trim(); checks.push({ name: "Python", status: "✓", detail: pyVer }); } catch { checks.push({ name: "Python", status: "✗", detail: "Not installed" }); } // Check disk space try { const disk = docExec("df -h / | tail -1", { timeout: 3000 }).toString().trim(); const parts = disk.split(/\s+/); checks.push({ name: "Disk Space", status: "✓", detail: `${parts[3]} available of ${parts[1]}` }); } catch { checks.push({ name: "Disk Space", status: "?", detail: "Could not check" }); } // Check memory const mem = process.memoryUsage(); checks.push({ name: "Memory (RSS)", status: "✓", detail: `${(mem.rss / 1024 / 1024).toFixed(1)} MB` }); // Check API key checks.push({ name: "API Key", status: settings?.apiKey ? "✓" : "⚠", detail: settings?.apiKey ? `Set (••••${settings.apiKey.slice(-4)})` : "Not configured — using default" }); // Check database try { const sessionCount = (await db.getUserSessions(user.id)).length; checks.push({ name: "Database", status: "✓", detail: `Connected (${sessionCount} sessions)` }); } catch { checks.push({ name: "Database", status: "✗", detail: "Connection failed" }); } result = "## Diagnostics Report\n\n| Check | Status | Detail |\n|-------|--------|--------|\n" + checks.map(c => `| ${c.name} | ${c.status} | ${c.detail} |`).join("\n"); break; } // ─── /branch ──────────────────────────────────────────────────── case "/branch": { const { execSync: branchExec } = await import("child_process"); let branchCwd = "/home/ubuntu"; try { branchCwd = branchExec("git rev-parse --show-toplevel 2>/dev/null || echo /home/ubuntu", { cwd: "/home/ubuntu", timeout: 3000 }).toString().trim(); } catch {} try { if (args) { // Create or switch branch const branchOutput = branchExec(`git checkout -b "${args}" 2>&1 || git checkout "${args}" 2>&1`, { cwd: branchCwd, timeout: 5000 }).toString(); result = `## Branch\n\n\`\`\`\n${branchOutput}\`\`\``; } else { const branches = branchExec("git branch -a --format='%(HEAD) %(refname:short) %(upstream:short) %(objectname:short)'", { cwd: branchCwd, timeout: 5000 }).toString(); result = `## Git Branches\n\n\`\`\`\n${branches || "(no branches)"}\`\`\``; } } catch (e: any) { result = `Branch error: ${e.message}`; } break; } // ─── /copy ────────────────────────────────────────────────────── case "/copy": { if (!sessionId) { result = "No active session."; break; } const copyMessages = await db.getSessionMessages(sessionId); const lastAssistant = copyMessages.filter(m => m.role === "assistant").pop(); if (lastAssistant) { result = `__COPY__${lastAssistant.content}`; } else { result = "No assistant messages to copy."; } break; } // ─── /export ──────────────────────────────────────────────────── case "/export": { if (!sessionId) { result = "No active session to export."; break; } const exportMessages = await db.getSessionMessages(sessionId); const exportSession = await db.getSession(sessionId, user.id); // render_export_text — matches original Rust claw-code exactly // Format: # Conversation Export, then ## N. role, then content blocks const exportLines: string[] = ["# Conversation Export", ""]; exportMessages.forEach((m, index) => { const role = m.role; exportLines.push(`## ${index + 1}. ${role}`); // Text content if (m.content) { exportLines.push(m.content); } // Tool calls — matches [tool_use id=X name=Y] format from original if (m.toolCalls) { try { const calls = typeof m.toolCalls === "string" ? JSON.parse(m.toolCalls) : m.toolCalls; if (Array.isArray(calls)) { for (const tc of calls) { const tcId = tc.id || "unknown"; const tcName = tc.function?.name || tc.name || "unknown"; const tcInput = tc.function?.arguments || tc.input || "{}"; exportLines.push(`[tool_use id=${tcId} name=${tcName}] ${tcInput}`); } } } catch {} } // Tool results — matches [tool_result id=X name=Y error=Z] format if (m.role === "tool" && m.toolName) { const isErr = (m.content || "").startsWith("Error:") || (m.content || "").startsWith("error:") ? "true" : "false"; exportLines.push(`[tool_result id=${m.toolCallId || "unknown"} name=${m.toolName} error=${isErr}] ${m.content || ""}`); } exportLines.push(""); }); const exportText = exportLines.join("\n"); // default_export_filename — matches original: first user message → slug let exportFilename = "conversation.txt"; const firstUserMsg = exportMessages.find(m => m.role === "user"); if (firstUserMsg?.content) { const firstLine = firstUserMsg.content.split("\n")[0] || "conversation"; const slug = firstLine .replace(/[^a-zA-Z0-9]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .toLowerCase() .split("-") .filter(Boolean) .slice(0, 8) .join("-"); exportFilename = `${slug || "conversation"}.txt`; } // Return both formats: markdown preview + downloadable data const exportData = { session: { title: exportSession?.title, model: exportSession?.model, provider: exportSession?.provider, createdAt: exportSession?.createdAt, }, messages: exportMessages.map(m => ({ role: m.role, content: m.content, toolCalls: m.toolCalls, toolName: m.toolName, createdAt: m.createdAt, })), exportedAt: new Date().toISOString(), filename: exportFilename, renderedText: exportText, }; result = `__EXPORT__${JSON.stringify(exportData)}`; break; } // ─── /theme ───────────────────────────────────────────────────── case "/theme": { const currentTheme = settings?.theme || "dark"; const newTheme = currentTheme === "dark" ? "light" : "dark"; await db.upsertSettings(user.id, { theme: newTheme }); result = `Theme switched to **${newTheme}** mode.`; break; } // ─── /terminal ────────────────────────────────────────────────── case "/terminal": { const termParts = (args || "").match(/^(\S+)\s+(.+)$/); const termCwd = termParts?.[1] || "/home/ubuntu"; const termCmd = termParts?.[2] || args || "echo 'Usage: /terminal [cwd] '"; const { execSync: termExec } = await import("child_process"); try { const output = termExec(termCmd, { cwd: termCwd, timeout: 30000, maxBuffer: 1024 * 1024 * 2, env: { ...process.env, HOME: "/home/ubuntu" }, }).toString(); result = `\`\`\`\n$ ${termCmd}\n${output || "(no output)"}\n\`\`\``; } catch (termErr: any) { const parts: string[] = []; if (termErr.stdout) parts.push(termErr.stdout.toString()); if (termErr.stderr) parts.push("STDERR: " + termErr.stderr.toString()); if (parts.length === 0) parts.push(termErr.message); result = `\`\`\`\n$ ${termCmd}\n${parts.join("\n")}\n\`\`\``; } break; } // ─── /files ───────────────────────────────────────────────────── case "/files": { const fileSubCmd = args?.split(" ")[0] || "list"; const filePath = args?.split(" ").slice(1).join(" ") || "/home/ubuntu"; const fs = await import("fs/promises"); const path = await import("path"); try { switch (fileSubCmd) { case "list": { const entries = await fs.readdir(filePath, { withFileTypes: true }); const files = entries .filter(e => !e.name.startsWith(".") || e.name === ".env") .map(e => ({ name: e.name, path: path.join(filePath, e.name), isDirectory: e.isDirectory(), })) .sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.localeCompare(b.name); }); res.json({ files }); return; } case "read": { const content = await fs.readFile(filePath, "utf-8"); const lines = content.split("\n"); const numbered = lines.map((l: string, i: number) => `${i + 1}: ${l}`).join("\n"); result = numbered; break; } case "touch": { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, "", { flag: "wx" }).catch(() => {}); result = `Created file: ${filePath}`; break; } case "mkdir": { await fs.mkdir(filePath, { recursive: true }); result = `Created directory: ${filePath}`; break; } case "delete": { const stat = await fs.stat(filePath); if (stat.isDirectory()) { await fs.rm(filePath, { recursive: true }); } else { await fs.unlink(filePath); } result = `Deleted: ${filePath}`; break; } case "rename": { const newName = args?.split(" ").slice(2).join(" "); if (!newName) { result = "Usage: /files rename "; break; } await fs.rename(filePath, newName); result = `Renamed: ${filePath} → ${newName}`; break; } default: result = "Usage: /files list|read|touch|mkdir|delete|rename "; } } catch (fileErr: any) { result = `File error: ${fileErr.message}`; } break; } // ─── /git ─────────────────────────────────────────────────────── case "/git": { const subCmd = args?.split(" ")[0] || "status"; const { execSync } = await import("child_process"); let gitCwd = "/home/ubuntu"; try { gitCwd = execSync("git rev-parse --show-toplevel 2>/dev/null || echo /home/ubuntu", { cwd: "/home/ubuntu", timeout: 3000 }).toString().trim(); } catch {} try { let gitOutput: string; switch (subCmd) { case "status": gitOutput = execSync("git status --short", { cwd: gitCwd, timeout: 5000 }).toString(); result = `## Git Status (${gitCwd})\n\n\`\`\`\n${gitOutput || "(clean)"}\`\`\``; break; case "diff": gitOutput = execSync("git diff --stat", { cwd: gitCwd, timeout: 5000 }).toString(); result = `## Git Diff\n\n\`\`\`\n${gitOutput || "(no changes)"}\`\`\``; break; case "log": gitOutput = execSync("git log --oneline -20", { cwd: gitCwd, timeout: 5000 }).toString(); result = `## Git Log (last 20)\n\n\`\`\`\n${gitOutput || "(no commits)"}\`\`\``; break; case "commit": { const commitMsg = args!.split(" ").slice(1).join(" ") || "Auto commit"; execSync("git add -A", { cwd: gitCwd, timeout: 5000 }); gitOutput = execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { cwd: gitCwd, timeout: 10000 }).toString(); result = `## Git Commit\n\n\`\`\`\n${gitOutput}\`\`\``; break; } case "branch": gitOutput = execSync("git branch -a", { cwd: gitCwd, timeout: 5000 }).toString(); result = `## Git Branches\n\n\`\`\`\n${gitOutput}\`\`\``; break; case "stash": gitOutput = execSync("git stash list", { cwd: gitCwd, timeout: 5000 }).toString(); result = `## Git Stash\n\n\`\`\`\n${gitOutput || "(no stashes)"}\`\`\``; break; default: result = `## Git Commands\n\n| Command | Description |\n|---------|-------------|\n| \`/git status\` | Show working tree status |\n| \`/git diff\` | Show diff statistics |\n| \`/git log\` | Show recent commits |\n| \`/git commit \` | Stage all and commit |\n| \`/git branch\` | List branches |\n| \`/git stash\` | List stashes |`; } } catch (gitErr: any) { result = `Git error: ${gitErr.message}`; } break; } // ─── /hooks ────────────────────────────────────────────────── case "/hooks": { const { getHooks, setHook, removeHook } = await import("../tools/executor"); const sessionHooks = getHooks(sessionId); if (args === "clear") { // Clear all hooks by removing each one for (const h of sessionHooks.preToolUse) removeHook(sessionId, "preToolUse", h.toolName); for (const h of sessionHooks.postToolUse) removeHook(sessionId, "postToolUse", h.toolName); result = "All hooks cleared."; } else if (args?.startsWith("add ")) { const parts = args.slice(4).split(" "); const hookType = parts[0] === "pre" ? "preToolUse" : "postToolUse"; const toolName = parts[1] || "*"; const action = parts[2] || "ask"; setHook(sessionId, hookType, toolName, action); result = `Hook added: ${parts[0]} ${toolName} → ${action}`; } else { result = `## Hooks\n\n### PreToolUse\n${sessionHooks.preToolUse.length === 0 ? "(none)" : sessionHooks.preToolUse.map((h: any) => `- \`${h.toolName}\` → ${h.action}`).join("\n")}\n\n### PostToolUse\n${sessionHooks.postToolUse.length === 0 ? "(none)" : sessionHooks.postToolUse.map((h: any) => `- \`${h.toolName}\` → ${h.action}`).join("\n")}\n\nUsage:\n- \`/hooks add pre allow|deny|ask\`\n- \`/hooks add post \`\n- \`/hooks clear\``; } break; } // ─── /skills ──────────────────────────────────────────────────── case "/skills": { if (!sessionId) { result = "No active session."; break; } const skillStore = getSessionSkills(sessionId); const allSkills = Array.from(skillStore.values()); if (args?.startsWith("add ")) { const skillParts = args.slice(4).split(" "); const skillName = skillParts[0]; const skillDesc = skillParts.slice(1).join(" ") || "Custom skill"; skillStore.set(skillName, { name: skillName, description: skillDesc, content: "", enabled: true }); result = `Skill \`${skillName}\` added.`; } else if (args?.startsWith("remove ")) { skillStore.delete(args.slice(7).trim()); result = `Skill removed.`; } else { result = allSkills.length === 0 ? "No skills registered. Use `/skills add ` to add one." : `## Skills\n\n| Name | Description | Status |\n|------|-------------|--------|\n${allSkills.map((s: any) => `| \`${s.name}\` | ${s.description} | ${s.enabled ? "✓ Enabled" : "✗ Disabled"} |`).join("\n")}`; } break; } // ─── /review ──────────────────────────────────────────────────── case "/review": { const { execSync: reviewExec } = await import("child_process"); let reviewCwd = "/home/ubuntu"; try { reviewCwd = reviewExec("git rev-parse --show-toplevel 2>/dev/null || echo /home/ubuntu", { cwd: "/home/ubuntu", timeout: 3000 }).toString().trim(); } catch {} try { const diffOutput = reviewExec(args ? `git diff -- "${args}"` : "git diff --cached", { cwd: reviewCwd, timeout: 10000, maxBuffer: 1024 * 1024 }).toString(); if (!diffOutput.trim()) { result = "No staged changes to review. Stage changes with `git add` first, or specify a file: `/review `"; } else { try { const { invokeLLM } = await import("../_core/llm"); const reviewResponse = await invokeLLM({ messages: [ { role: "system", content: "You are a senior code reviewer. Review the following git diff. Point out bugs, security issues, style problems, and suggest improvements. Be concise and actionable." }, { role: "user", content: diffOutput.slice(0, 8000) }, ], }); result = `## Code Review\n\n${reviewResponse?.choices?.[0]?.message?.content || "(review failed)"}`; } catch (e: any) { result = `## Staged Diff\n\n\`\`\`diff\n${diffOutput.slice(0, 5000)}\n\`\`\`\n\n_AI review unavailable: ${e.message}_`; } } } catch (e: any) { result = `Review error: ${e.message}`; } break; } // ─── /tasks ───────────────────────────────────────────────────── case "/tasks": { if (!sessionId) { result = "No active tasks."; break; } const tasks = getSessionTasks(sessionId); if (tasks.length === 0) { result = "No active tasks. The agent can create tasks using the `task_create` tool."; } else { result = `## Tasks\n\n| ID | Description | Status | Created |\n|----|-------------|--------|---------|\n${tasks.map((t: any) => `| \`${String(t.id).slice(0, 8)}\` | ${t.description} | ${t.status} | ${new Date(t.createdAt).toLocaleTimeString()} |`).join("\n")}`; } break; } // ─── /version ─────────────────────────────────────────────────── case "/version": result = `## Claw Web\n\n| Property | Value |\n|----------|-------|\n| Version | 1.0.0 |\n| Engine | Node.js ${process.version} |\n| Platform | ${process.platform} ${process.arch} |\n| Based on | claw-code (instructkr/claw-code) |\n| Runtime | Web (browser-based) |`; break; // ─── /init ────────────────────────────────────────────────────── case "/init": { const { execSync: initExec } = await import("child_process"); const initDir = args || "/home/ubuntu/project"; try { initExec(`mkdir -p "${initDir}" && cd "${initDir}" && git init 2>/dev/null; echo '{"name":"project","version":"1.0.0"}' > package.json 2>/dev/null || true`, { timeout: 5000 }); result = `Project initialized in \`${initDir}\`. Created git repo and package.json.`; } catch (e: any) { result = `Init error: ${e.message}`; } break; } // ─── /login ───────────────────────────────────────────────────── case "/login": result = "You are already logged in. Use the sidebar profile menu to manage your account."; break; // ─── /logout ──────────────────────────────────────────────────── case "/logout": result = "__LOGOUT__"; break; // ─── /vim ─────────────────────────────────────────────────────── case "/vim": { const vimEnabled = args === "on" || args === "enable"; result = vimEnabled ? "Vim mode **enabled**. Use vim keybindings in the input area." : args === "off" || args === "disable" ? "Vim mode **disabled**." : "Usage: `/vim on|off` — Toggle vim-style keybindings in the input area."; break; } // ─── /brief ───────────────────────────────────────────────────── case "/brief": { if (!sessionId) { result = "No active session."; break; } const currentEffort = getEffortLevel(sessionId); if (currentEffort === "low") { setEffortLevel(sessionId, "high"); result = "Brief mode **off**. Returning to detailed responses."; } else { setEffortLevel(sessionId, "low"); result = "Brief mode **on**. Responses will be concise and minimal."; } break; } // ─── /output-style ────────────────────────────────────────────── case "/output-style": { const validStyles = ["markdown", "plain", "json", "streaming", "minimal"]; if (args && validStyles.includes(args.trim())) { await db.upsertSettings(user.id, { outputStyle: args.trim() as any }); result = `Output style set to **${args.trim()}**.`; } else { result = `Current output style: **${settings?.outputStyle || "markdown"}**\n\nAvailable styles: ${validStyles.map(s => `\`${s}\``).join(", ")}\n\nUsage: \`/output-style