From d806bb34c42d3e12135e708d95f591b5985ac500 Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Tue, 9 Jun 2026 19:51:38 +0530 Subject: [PATCH] persistent stdin setup, raw OpenRouter API, `/model` slash command, agent mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace AI SDK with raw fetch-based OpenRouter API to support server tools (openrouter:web_search, openrouter:web_fetch) alongside function tools - Add write_file, run_command tool definitions with permission gating for agent mode - Implement persistent single stdin setup with module-level streamAbort controller — Escape cancels streaming across all modes/providers - Add AbortSignal plumbing to all providers (Google, OpenRouter, NVIDIA, server-proxy) with silent AbortError re-throw - Add /model slash command with codebase-native model lists - Change default provider to OpenRouter; auto-switch on tool/agent mode - Rewrite system prompt for autonomous agent workflows - Pause MiniMax with 402 balance error handling - Bump maxSteps from 5 to 25 across all providers --- .../server/src/cli/ai/chat/chat.ts | 340 +++++++++++------- .../server/src/cli/ai/google-service.ts | 14 +- .../server/src/cli/ai/minimax-service.ts | 15 +- .../server/src/cli/ai/nvidia-service.ts | 5 +- .../server/src/cli/ai/openrouter-service.ts | 220 +++++++++--- .../server/src/cli/ai/provider.ts | 27 +- .../server/src/cli/ai/server-proxy-service.ts | 2 + .../server/src/cli/commands/ai/init.ts | 2 +- .../src/cli/commands/slashCommands/index.ts | 38 ++ .../src/cli/commands/slashCommands/model.ts | 79 ++++ .../supercode-cli/server/src/cli/utils/tui.ts | 2 +- .../server/src/cli/workspace/context.ts | 66 +++- .../server/src/config/tools.config.ts | 16 + apps/supercode-cli/server/src/index.ts | 161 ++++++++- .../src/tools/definitions/run-command.ts | 71 ++++ .../src/tools/definitions/write-file.ts | 52 +++ .../server/src/tools/permission-manager.ts | 203 +++++++++++ .../server/src/tools/registry.ts | 25 +- 18 files changed, 1105 insertions(+), 233 deletions(-) create mode 100644 apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts create mode 100644 apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts create mode 100644 apps/supercode-cli/server/src/tools/definitions/run-command.ts create mode 100644 apps/supercode-cli/server/src/tools/definitions/write-file.ts create mode 100644 apps/supercode-cli/server/src/tools/permission-manager.ts diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts index ff33ecd..c74a9d3 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts @@ -29,6 +29,7 @@ import type { WorkspaceInfo } from "src/cli/workspace/scanner.ts" import { buildSystemPrompt } from "src/cli/workspace/context.ts" import { tools } from "src/tools/registry.ts" import { renderWorkspaceBanner } from "src/cli/workspace/format.ts" +import { handleSlashCommand, isSlashCommand } from "src/cli/commands/slashCommands/index.ts" async function getUserFromToken() { const token = await getStoredToken() @@ -72,7 +73,7 @@ async function streamAIResponse( conversationId: string, mode: string, workspaceInfo?: WorkspaceInfo, -): Promise<{ content: string; elapsed: number; usage: any }> { +): Promise<{ content: string; elapsed: number; usage: any; aborted?: boolean }> { const dbMessages = await getMessages(conversationId) let aiMessages = formatMessagesForAI(dbMessages as any) @@ -93,12 +94,13 @@ async function streamAIResponse( const thinking = createThinking() let toolsToUse: Record | undefined - if (mode === "tool" || mode === "agent") { - toolsToUse = { ...tools } - } else if (workspaceInfo) { + if (workspaceInfo) { toolsToUse = { ...tools } } + const abortController = new AbortController() + streamAbort = abortController + try { const result = await provider.sendMessage( aiMessages as ModelMessage[], @@ -117,6 +119,7 @@ async function streamAIResponse( const argPreview = (args as any)?.path || (args as any)?.pattern || (args as any)?.query || (args as any)?.url || "" thinking.setLabel(`${toolName}(${argPreview})`) }, + abortController.signal, ) const elapsed = Date.now() - startTime @@ -124,9 +127,15 @@ async function streamAIResponse( console.log() return { content: fullResponse, elapsed, usage } - } catch (error) { + } catch (error: any) { + if (error?.name === "AbortError" || abortController.signal.aborted) { + console.log() + return { content: fullResponse || "(cancelled)", elapsed: Date.now() - startTime, usage: {}, aborted: true } + } thinking.fail("Response failed") throw error + } finally { + streamAbort = null } } @@ -145,154 +154,187 @@ interface Conversation { updatedAt: Date } -async function chatInput( - currentMode: string, -): Promise<{ input: string | null; mode: string }> { - return new Promise((resolve) => { - const stdin = process.stdin - const wasRaw = stdin.isRaw - - readline.emitKeypressEvents(stdin) - if (stdin.isTTY) { - stdin.setRawMode(true) - } - stdin.resume() +const modes = ["chat", "tool", "agent"] +const modeColors: Record = { + chat: theme.cyan, + tool: theme.green, + agent: theme.warning, +} +const modeDisplay: Record = { + chat: "chat", + tool: "tools", + agent: "agent", +} - const modes = ["chat", "tool", "agent"] - const modeColors: Record = { - chat: theme.cyan, - tool: theme.green, - agent: theme.warning, - } - const modeDisplay: Record = { - chat: "chat", - tool: "tools", - agent: "agent", - } +// Persistent stdin state +let streamAbort: AbortController | null = null +let stdinInput = "" +let stdinCursor = 0 +let stdinMode = "chat" +let stdinResolve: ((value: { input: string; mode: string }) => void) | null = null +let stdinPromptLen = 0 +let stdinPrevWrapLines = 1 + +function promptText(): string { + const color = chalk.hex(modeColors[stdinMode] ?? theme.cyan) + return `${chalk.hex(theme.cyan)("┃ [")}${color(modeDisplay[stdinMode] ?? stdinMode)}${chalk.hex(theme.cyan)("] ")}` +} - let input = "" - let cursor = 0 - let mode = modes.includes(currentMode) ? currentMode : "chat" +function getStdoutPromptLen(): number { + return stripAnsi(promptText()).length +} - function promptText(): string { - const color = chalk.hex(modeColors[mode] ?? theme.cyan) - return `${chalk.hex(theme.cyan)("┃ [")}${color(modeDisplay[mode] ?? mode)}${chalk.hex(theme.cyan)("] ")}` +function renderInput() { + const cols = process.stdout.columns || 80 + const promptLen = getStdoutPromptLen() + stdinPromptLen = promptLen + const totalChars = promptLen + stdinInput.length + const wrapLines = Math.max(1, Math.ceil(totalChars / cols)) + + for (let i = 0; i < stdinPrevWrapLines; i++) { + readline.cursorTo(process.stdout, 0) + readline.clearLine(process.stdout, 0) + if (i < stdinPrevWrapLines - 1) { + readline.moveCursor(process.stdout, 0, -1) } + } + readline.cursorTo(process.stdout, 0) + process.stdout.write(promptText() + stdinInput) + stdinPrevWrapLines = wrapLines - function getPromptLen(): number { - return stripAnsi(promptText()).length - } + const absPos = promptLen + stdinCursor + if (absPos !== promptLen + stdinInput.length) { + readline.cursorTo(process.stdout, absPos) + } +} - function render() { - readline.clearLine(process.stdout, 0) - readline.cursorTo(process.stdout, 0) - process.stdout.write(promptText() + input) - const absPos = getPromptLen() + cursor - if (absPos !== getPromptLen() + input.length) { - readline.cursorTo(process.stdout, absPos) - } - } +function stdinKeypress(_str: string, key: any) { + if (!key) return - render() + // If streaming, Escape cancels + if (key.name === "escape" && streamAbort) { + streamAbort.abort() + return + } - function cleanup() { - stdin.removeListener("keypress", onKeypress) - if (stdin.isTTY) { - stdin.setRawMode(wasRaw ?? false) - } - stdin.pause() - } + // No input handler active + if (!stdinResolve) return - function onKeypress(_str: string, key: any) { - if (!key) return + // Tab to cycle modes + if (key.name === "tab") { + const idx = modes.indexOf(stdinMode) + stdinMode = modes[(idx + 1) % modes.length]! + renderInput() + return + } - if (key.name === "tab") { - const idx = modes.indexOf(mode) - mode = modes[(idx + 1) % modes.length]! - render() - return - } + if (key.name === "return" || key.name === "enter") { + const resolve = stdinResolve + stdinResolve = null + resolve({ input: stdinInput, mode: stdinMode }) + return + } - if (key.name === "return" || key.name === "enter") { - cleanup() - resolve({ input, mode }) - return - } + if (key.name === "escape") { + stdinInput = "" + stdinCursor = 0 + renderInput() + return + } - if (key.name === "escape" || (key.ctrl && key.name === "c")) { - cleanup() - resolve({ input: null, mode }) - return - } + if (key.ctrl && key.name === "c") { + process.exit(0) + return + } - if (key.name === "backspace") { - if (cursor > 0) { - input = input.slice(0, cursor - 1) + input.slice(cursor) - cursor-- - render() - } - return - } + if (key.name === "backspace") { + if (stdinCursor > 0) { + stdinInput = stdinInput.slice(0, stdinCursor - 1) + stdinInput.slice(stdinCursor) + stdinCursor-- + renderInput() + } + return + } - if (key.name === "delete" || key.name === "del") { - if (cursor < input.length) { - input = input.slice(0, cursor) + input.slice(cursor + 1) - render() - } - return - } + if (key.name === "delete" || key.name === "del") { + if (stdinCursor < stdinInput.length) { + stdinInput = stdinInput.slice(0, stdinCursor) + stdinInput.slice(stdinCursor + 1) + renderInput() + } + return + } - if (key.name === "left") { - if (cursor > 0) { - cursor-- - readline.cursorTo(process.stdout, getPromptLen() + cursor) - } - return - } + if (key.name === "left") { + if (stdinCursor > 0) { + stdinCursor-- + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + } + return + } - if (key.name === "right") { - if (cursor < input.length) { - cursor++ - readline.cursorTo(process.stdout, getPromptLen() + cursor) - } - return - } + if (key.name === "right") { + if (stdinCursor < stdinInput.length) { + stdinCursor++ + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + } + return + } - if (key.name === "home") { - cursor = 0 - readline.cursorTo(process.stdout, getPromptLen()) - return - } + if (key.name === "home") { + stdinCursor = 0 + readline.cursorTo(process.stdout, stdinPromptLen) + return + } - if (key.name === "end") { - cursor = input.length - readline.cursorTo(process.stdout, getPromptLen() + cursor) - return - } + if (key.name === "end") { + stdinCursor = stdinInput.length + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + return + } - if (_str && _str.length === 1 && !key.ctrl && !key.meta) { - input = input.slice(0, cursor) + _str + input.slice(cursor) - cursor++ - readline.clearLine(process.stdout, 0) - readline.cursorTo(process.stdout, 0) - process.stdout.write(promptText() + input) - readline.cursorTo(process.stdout, getPromptLen() + cursor) - return - } - } + if (_str && _str.length === 1 && !key.ctrl && !key.meta) { + stdinInput = stdinInput.slice(0, stdinCursor) + _str + stdinInput.slice(stdinCursor) + stdinCursor++ + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 0) + process.stdout.write(promptText() + stdinInput) + readline.cursorTo(process.stdout, stdinPromptLen + stdinCursor) + return + } +} + +function setupStdin() { + const stdin = process.stdin + readline.emitKeypressEvents(stdin) + if (stdin.isTTY) { + stdin.setRawMode(true) + } + stdin.resume() + stdin.on("keypress", stdinKeypress) +} - stdin.on("keypress", onKeypress) +async function chatInput(currentMode: string): Promise<{ input: string; mode: string }> { + stdinMode = modes.includes(currentMode) ? currentMode : "chat" + stdinInput = "" + stdinCursor = 0 + stdinPrevWrapLines = 1 + renderInput() + return new Promise((resolve) => { + stdinResolve = resolve }) } export async function chatLoop( - provider: AIProvider, + initialProvider: AIProvider, conversation: Conversation, workspaceInfo?: WorkspaceInfo, ) { + setupStdin() + let messageCount = 0 let sessionTokens = 0 - const contextWindow = getContextWindow(provider.modelName) + let provider = initialProvider + let contextWindow = getContextWindow(provider.modelName) while (true) { const { input: userInput, mode } = await chatInput(conversation.mode) @@ -300,13 +342,14 @@ export async function chatLoop( if (mode !== conversation.mode) { conversation.mode = mode await updateConversationMode(conversation.id, mode) - } - if (userInput === null) { - console.log() - console.log(chalk.hex(theme.amber)(` ╰─ `) + chalk.hex(theme.muted)("session ended")) - console.log() - process.exit(0) + if ((mode === "tool" || mode === "agent") && provider.name !== "openrouter") { + const orProvider = createProvider("openrouter") + provider = orProvider + contextWindow = getContextWindow(provider.modelName) + console.log(` ${chalk.hex(theme.blue)("◆")} switched to ${chalk.hex(theme.cyan)(provider.modelName)} for ${mode} mode`) + console.log() + } } const trimmed = userInput.trim() @@ -319,6 +362,24 @@ export async function chatLoop( if (trimmed.length === 0) continue + if (isSlashCommand(trimmed)) { + const result = await handleSlashCommand(trimmed) + if (result?.type === "model_change") { + const newProvider = result.provider ? createProvider(result.provider, result.model) : null + if (newProvider) { + provider = newProvider + contextWindow = getContextWindow(provider.modelName) + const label = result.label || provider.modelName + console.log(` ${chalk.hex(theme.green)("◆")} switched to ${chalk.hex(theme.cyan)(label)}`) + console.log() + } + } else if (result?.type === "unknown") { + console.log(` ${chalk.hex(theme.red)("◆")} unknown slash command: ${trimmed.split(" ")[0]}`) + console.log() + } + continue + } + userMessage(trimmed) messageCount++ @@ -327,6 +388,16 @@ export async function chatLoop( try { const result = await streamAIResponse(provider, conversation.id, conversation.mode, workspaceInfo) + + if (result.aborted) { + if (result.content && result.content !== "(cancelled)") { + await addMessage(conversation.id, "assistant", result.content) + } + console.log(` ${chalk.hex(theme.amber)("◆")} cancelled`) + console.log() + continue + } + await addMessage(conversation.id, "assistant", result.content) const responseTokens = result.usage?.totalTokens ?? 0 @@ -351,7 +422,7 @@ export async function chatLoop( } export async function startChat( - provider: ModelProvider = "google", + provider: ModelProvider = "openrouter", model?: string, conversationId?: string | null, workspaceInfo?: WorkspaceInfo, @@ -381,7 +452,12 @@ export async function startChat( const user = await getUserFromToken() const conversation = await initConversation(user.id, conversationId, initialMode) - await chatLoop(aiProvider, conversation, workspaceInfo) + + const activeProvider = (initialMode === "tool" || initialMode === "agent") && provider !== "openrouter" + ? createProvider("openrouter") + : aiProvider + + await chatLoop(activeProvider, conversation, workspaceInfo) } catch (error: any) { console.log() console.log(` ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(error?.message ?? "Error")}`) diff --git a/apps/supercode-cli/server/src/cli/ai/google-service.ts b/apps/supercode-cli/server/src/cli/ai/google-service.ts index 9beceb4..405dc51 100644 --- a/apps/supercode-cli/server/src/cli/ai/google-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/google-service.ts @@ -5,17 +5,20 @@ import chalk from "chalk"; export class AIService { model: ReturnType> + readonly modelName: string - constructor() { + constructor(modelName?: string) { if (!config.googleApiKey) { throw new Error("Google Gemini is not configured.\n\n Set GOOGLE_GENERATIVE_AI_API_KEY in your environment:\n export GOOGLE_GENERATIVE_AI_API_KEY=\n\n Get a key at: https://aistudio.google.com/apikey"); } + this.modelName = modelName || config.model + const google = createGoogleGenerativeAI({ apiKey: config.googleApiKey, }) - this.model = google(config.model) + this.model = google(this.modelName) } async generateStructured( @@ -43,6 +46,7 @@ export class AIService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ) { try { const systemMessages = messages.filter(m => m.role === "system") @@ -52,6 +56,7 @@ export class AIService { const streamOptions: any = { model: this.model, messages: nonSystemMessages, + abortSignal: signal, } if (system) { @@ -60,7 +65,7 @@ export class AIService { if (tools && Object.keys(tools).length > 0) { streamOptions.tools = tools - streamOptions.maxSteps = 5 // allow limit tool calling + streamOptions.maxSteps = 25 if (onToolCall) { streamOptions.experimental_onToolCallStart = (event: any) => { const tc = event.toolCall @@ -110,7 +115,8 @@ export class AIService { toolResults, step: fullResult.steps } - } catch (error) { + } catch (error: any) { + if (error?.name === "AbortError") throw error console.error(chalk.red("AI Service Error:"), error instanceof Error ? error.message : String(error)) throw error } diff --git a/apps/supercode-cli/server/src/cli/ai/minimax-service.ts b/apps/supercode-cli/server/src/cli/ai/minimax-service.ts index cd8590c..7de81a6 100644 --- a/apps/supercode-cli/server/src/cli/ai/minimax-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/minimax-service.ts @@ -32,6 +32,7 @@ export class MinimaxService { const streamOptions: any = { model: this.model, messages: nonSystemMessages, + maxTokens: Number(process.env.MINIMAX_MAX_TOKENS) || 4096, } if (system) { @@ -40,7 +41,7 @@ export class MinimaxService { if (tools && Object.keys(tools).length > 0) { streamOptions.tools = tools - streamOptions.maxSteps = 5 + streamOptions.maxSteps = 25 if (onToolCall) { streamOptions.experimental_onToolCallStart = (event: any) => { const tc = event.toolCall @@ -64,7 +65,17 @@ export class MinimaxService { usage: result.usage, } } catch (error) { - console.error(chalk.red("MiniMax Service Error:"), error instanceof Error ? error.message : String(error)) + const message = error instanceof Error ? error.message : String(error) + if (message.includes("insufficient balance") || message.includes("402") || message.includes("1008")) { + console.error(chalk.red("MiniMax API Error:"), "Insufficient balance. Top up at https://platform.minimax.ai") + throw new Error( + "MiniMax API: insufficient balance (402).\n\n" + + " Your MiniMax account has insufficient credits.\n" + + " Top up at: https://platform.minimax.ai\n" + + " Or switch to a different provider." + ) + } + console.error(chalk.red("MiniMax Service Error:"), message) throw error } } diff --git a/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts b/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts index 2a7769d..d5fb902 100644 --- a/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/nvidia-service.ts @@ -22,6 +22,7 @@ export class NvidiaService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ) { try { const bodyObj: any = { @@ -46,6 +47,7 @@ export class NvidiaService { "Content-Type": "application/json", }, body: JSON.stringify(bodyObj), + signal, }) if (!response.ok) { @@ -139,7 +141,8 @@ export class NvidiaService { finishResponse: Promise.resolve(finishReason), usage: Promise.resolve(usage), } - } catch (error) { + } catch (error: any) { + if (error?.name === "AbortError") throw error console.error(chalk.red("NVIDIA Service Error:"), error instanceof Error ? error.message : String(error)) throw error } diff --git a/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts b/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts index e87f95b..cbf1b33 100644 --- a/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/openrouter-service.ts @@ -1,11 +1,27 @@ -import { createOpenRouter } from "@openrouter/ai-sdk-provider" -import { streamText, type ModelMessage } from "ai" +import { type ModelMessage } from "ai" import { openRouterConfig } from "../../config/openrouter.config.ts" import chalk from "chalk" +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions" + +function isServerTool(name: string): boolean { + return name === "web_search" || name === "url_fetch" +} + +function serverTool(name: string): any { + if (name === "web_search") return { type: "openrouter:web_search" } + if (name === "url_fetch") return { type: "openrouter:web_fetch" } + return null +} + +function zodToJsonSchema(schema: any): any { + return typeof schema === "object" && "toJSON" in (schema as any) + ? (schema as any).toJSON() + : schema +} + export class OpenRouterService { readonly modelName: string - model: any constructor(model?: string) { if (!openRouterConfig.apiKey) { @@ -13,12 +29,6 @@ export class OpenRouterService { } this.modelName = model || openRouterConfig.model - - const openrouter = createOpenRouter({ - apiKey: openRouterConfig.apiKey, - }) - - this.model = openrouter(this.modelName) } async sendMessage( @@ -26,64 +36,180 @@ export class OpenRouterService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ) { - try { - const systemMessages = messages.filter(m => m.role === "system") - const nonSystemMessages = messages.filter(m => m.role !== "system") - const system = systemMessages.map(m => m.content).join("\n") - - const streamOptions: any = { - model: this.model, - messages: nonSystemMessages, + const systemMessages = messages.filter(m => m.role === "system") + const nonSystemMessages = messages.filter(m => m.role !== "system") + const system = systemMessages.map(m => m.content).join("\n") + + const apiMessages: any[] = [] + if (system) apiMessages.push({ role: "system", content: system }) + for (const m of nonSystemMessages) { + if (m.role === "assistant" && (m as any).tool_calls) { + const msg: any = { role: "assistant", content: m.content } + msg.tool_calls = (m as any).tool_calls + apiMessages.push(msg) + } else { + apiMessages.push({ role: m.role, content: m.content as string }) } + } + + const apiTools: any[] = [] + const functionTools: Record = {} + if (tools) { + for (const [name, def] of Object.entries(tools as Record)) { + if (isServerTool(name)) { + apiTools.push(serverTool(name)) + } else { + apiTools.push({ + type: "function", + function: { + name, + description: def.description || "", + parameters: zodToJsonSchema(def.parameters), + }, + }) + functionTools[name] = def + } + } + } + const allMessages = [...apiMessages] + const maxToolIterations = 25 + let fullResponse = "" + + for (let iter = 0; iter < maxToolIterations; iter++) { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") + if (fullResponse) onChunk?.("\n\n") + + const body: any = { + model: this.modelName, + messages: allMessages, + stream: true, + } + if (apiTools.length > 0) body.tools = apiTools if (this.modelName.includes("minimax-m3") || this.modelName.includes("glm-5.1")) { - streamOptions.maxOutputTokens = 8192 + body.max_tokens = 8192 } - if (system) { - streamOptions.system = system + const res = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${openRouterConfig.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal, + }) + + if (!res.ok) { + const errText = await res.text().catch(() => "unknown error") + throw new Error(`OpenRouter API ${res.status}: ${errText.slice(0, 500)}`) } - if (tools && Object.keys(tools).length > 0) { - streamOptions.tools = tools - streamOptions.maxSteps = 5 - if (onToolCall) { - streamOptions.experimental_onToolCallStart = (event: any) => { - const tc = event.toolCall - onToolCall({ toolName: tc.toolName, args: tc.input as Record }) - } + const reader = res.body?.getReader() + if (!reader) throw new Error("No response body") + + const decoder = new TextDecoder() + let buffer = "" + let toolCalls: Array<{ id: string; type: string; function: { name: string; arguments: string } }> = [] + let finishReason = "" + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith("data: ")) continue + const jsonStr = trimmed.slice(6) + if (jsonStr === "[DONE]") continue + + try { + const data = JSON.parse(jsonStr) + const delta = data.choices?.[0]?.delta + const finish = data.choices?.[0]?.finish_reason + + if (finish) finishReason = finish + + if (delta?.content) { + fullResponse += delta.content + onChunk?.(delta.content) + } + + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = toolCalls.find(t => t.id === tc.id) + if (existing) { + if (tc.function?.arguments) existing.function.arguments += tc.function.arguments + } else { + toolCalls.push({ + id: tc.id, + type: tc.type || "function", + function: { + name: tc.function?.name || "", + arguments: tc.function?.arguments || "", + }, + }) + } + } + } + } catch { /* skip malformed */ } } } - const result = streamText(streamOptions) + if (finishReason === "tool_calls" && toolCalls.length > 0) { + const assistantMsg: any = { role: "assistant", content: null } + assistantMsg.tool_calls = toolCalls.map(tc => ({ + id: tc.id, + type: tc.type, + function: { name: tc.function.name, arguments: tc.function.arguments }, + })) + allMessages.push(assistantMsg) - let fullResponse = "" + for (const tc of toolCalls) { + const toolName = tc.function.name + const toolDef = functionTools[toolName] + let toolResult: string - for await (const chunk of result.textStream) { - fullResponse += chunk - onChunk?.(chunk) - } + if (toolDef?.execute) { + let args: any = {} + try { args = JSON.parse(tc.function.arguments || "{}") } catch { /* */ } + onToolCall?.({ toolName, args }) + try { toolResult = await toolDef.execute(args) } catch (err: any) { toolResult = `Error: ${err.message || String(err)}` } + } else { + toolResult = `Tool "${toolName}" is not available locally` + } - return { - content: fullResponse, - finishResponse: result.finishReason, - usage: result.usage, - } - } catch (error) { - console.error(chalk.red("OpenRouter Service Error:"), error instanceof Error ? error.message : String(error)) - if (error instanceof Error && "cause" in error) { - console.error(chalk.red(" Cause:"), String((error as any).cause)) + allMessages.push({ + role: "tool", + tool_call_id: tc.id, + content: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult), + }) + } + + toolCalls = [] + finishReason = "" + continue } - throw error + + break + } + + return { + content: fullResponse, + finishResponse: Promise.resolve("stop" as any), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 } as any), } } async getMessage(messages: ModelMessage[], tools?: any) { let fullResponse = "" - await this.sendMessage(messages, (chunk) => { - fullResponse += chunk - }) + await this.sendMessage(messages, (chunk) => { fullResponse += chunk }, tools) return fullResponse } } diff --git a/apps/supercode-cli/server/src/cli/ai/provider.ts b/apps/supercode-cli/server/src/cli/ai/provider.ts index 697c91e..62ac0b3 100644 --- a/apps/supercode-cli/server/src/cli/ai/provider.ts +++ b/apps/supercode-cli/server/src/cli/ai/provider.ts @@ -20,6 +20,7 @@ export interface AIProvider { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: any, + signal?: AbortSignal, ): Promise<{ content: string finishResponse: PromiseLike @@ -50,28 +51,19 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi return { name: provider, modelName: model || meta.defaultModel, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), generateObject: (schema, prompt) => svc.generateObject(schema, prompt), } } switch (provider) { case "google": { - const svc = new AIService() + const svc = new AIService(model) return { name: "google", - modelName: "gemini-2.5-flash", - model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), - } - } - case "minimax": { - const svc = new MinimaxService() - return { - name: "minimax", - modelName: "MiniMax-M2", + modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), } } case "openrouter": { @@ -79,8 +71,8 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi return { name: "openrouter", modelName: svc.modelName, - model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + model: null, + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), } } case "nvidia": { @@ -89,8 +81,11 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi name: "nvidia", modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall) => svc.sendMessage(messages, onChunk, tools, onToolCall), + sendMessage: (messages, onChunk, tools, onToolCall, signal) => svc.sendMessage(messages, onChunk, tools, onToolCall, signal), } } + default: { + throw new Error(`Provider "${provider}" is paused or unavailable`) + } } } diff --git a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts index f40e9d5..dc378c4 100644 --- a/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/server-proxy-service.ts @@ -17,6 +17,7 @@ export class ServerProxyService { onChunk?: (chunk: string) => void, tools?: any, onToolCall?: (call: { toolName: string; args: Record }) => void, + signal?: AbortSignal, ) { const token = await getStoredToken() if (!token?.access_token) { @@ -35,6 +36,7 @@ export class ServerProxyService { model: this.modelName, tools, }), + signal, }) if (!res.ok) { diff --git a/apps/supercode-cli/server/src/cli/commands/ai/init.ts b/apps/supercode-cli/server/src/cli/commands/ai/init.ts index 6097211..74b3bb1 100644 --- a/apps/supercode-cli/server/src/cli/commands/ai/init.ts +++ b/apps/supercode-cli/server/src/cli/commands/ai/init.ts @@ -67,7 +67,7 @@ export const wakeUpAction = async () => { options: [ // { value: "server", label: "Supercloud", hint: "server-hosted · no API key needed (Recommended)" }, { value: "google", label: "Gemini 2.5 Flash", hint: "free · fast" }, - { value: "minimax", label: "MiniMax M2", hint: "reasoning · powerful" }, + // { value: "minimax", label: "MiniMax M2", hint: "reasoning · powerful" }, { value: "openrouter", label: "OpenRouter", hint: "multi-provider · bring your own key" }, { value: "nvidia", label: "NVIDIA NIM", hint: "free API" }, ], diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts new file mode 100644 index 0000000..75f0e55 --- /dev/null +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/index.ts @@ -0,0 +1,38 @@ +import { pickModel, formatModelChange } from "./model.ts" +import type { ModelProvider } from "src/cli/ai/provider.ts" + +export interface SlashCommandResult { + type: "model_change" | "unknown" + provider?: ModelProvider + model?: string + label?: string +} + +const handlers: Record Promise> = { + model: async () => { + const result = await pickModel() + return { + type: "model_change", + provider: result.provider, + model: result.model, + label: formatModelChange(result.provider, result.model), + } + }, +} + +export async function handleSlashCommand(input: string): Promise { + const match = input.match(/^\/(\w+)\s*(.*)$/) + if (!match) return null + + const [, cmd = "", args = ""] = match + + + const handler = handlers[cmd.toLowerCase()] + if (!handler) return { type: "unknown" } + + return handler(args.trim()) +} + +export function isSlashCommand(input: string): boolean { + return /^\//.test(input.trim()) +} diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts new file mode 100644 index 0000000..609f298 --- /dev/null +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/model.ts @@ -0,0 +1,79 @@ +import { select, isCancel } from "@clack/prompts" +import chalk from "chalk" +import { theme } from "src/cli/utils/tui.ts" +import { createProvider, type ModelProvider } from "src/cli/ai/provider.ts" + +const openRouterModels = { + "openai/gpt-oss-120b:free": "GPT OSS 120B (free)", + "deepseek/deepseek-v4-flash": "DeepSeek V4 Flash", + "minimax/minimax-m3": "MiniMax M3", + "z-ai/glm-5.1": "GLM 5.1", + "moonshotai/kimi-k2.6:free": "Kimi K2.6 (free)", +} as const + +const nvidiaModels = { + "minimaxai/minimax-m2.7": "MiniMax M2.7", + "deepseek-ai/deepseek-v4-flash": "DeepSeek V4 Flash", + "meta/llama-3.3-70b-instruct": "Llama 3.3 70B", +} as const + +const googleModels = { + "gemini-2.5-flash": "Gemini 2.5 Flash", + "gemini-2.5-pro": "Gemini 2.5 Pro", +} as const + +function defaultModel(provider: ModelProvider): string | undefined { + if (provider === "google") return "gemini-2.5-flash" + if (provider === "openrouter") return "openai/gpt-oss-120b:free" + if (provider === "nvidia") return "minimaxai/minimax-m2.7" + return undefined +} + +export async function pickModel(): Promise<{ provider: ModelProvider; model?: string }> { + const providerChoice = await select({ + message: chalk.hex(theme.cyan)("switch model"), + options: [ + { value: "google", label: "Google Gemini", hint: "default" }, + { value: "openrouter", label: "OpenRouter", hint: "multi-provider" }, + { value: "nvidia", label: "NVIDIA NIM", hint: "free tier" }, + ], + }) + + if (isCancel(providerChoice)) { + return { provider: "google", model: "gemini-2.5-flash" } + } + + if (providerChoice === "google") { + const model = await select({ + message: chalk.hex(theme.cyan)("select Gemini model"), + options: Object.entries(googleModels).map(([value, label]) => ({ value, label })), + }) + if (isCancel(model)) return { provider: "google", model: defaultModel("google") } + return { provider: "google", model: model as string } + } + + if (providerChoice === "openrouter") { + const model = await select({ + message: chalk.hex(theme.cyan)("select OpenRouter model"), + options: Object.entries(openRouterModels).map(([value, label]) => ({ value, label })), + }) + if (isCancel(model)) return { provider: "openrouter", model: defaultModel("openrouter") } + return { provider: "openrouter", model: model as string } + } + + if (providerChoice === "nvidia") { + const model = await select({ + message: chalk.hex(theme.cyan)("select NVIDIA NIM model"), + options: Object.entries(nvidiaModels).map(([value, label]) => ({ value, label })), + }) + if (isCancel(model)) return { provider: "nvidia", model: defaultModel("nvidia") } + return { provider: "nvidia", model: model as string } + } + + return { provider: providerChoice as ModelProvider } +} + +export function formatModelChange(p: ModelProvider, m?: string): string { + const label = p === "google" ? "Gemini" : p === "nvidia" ? "NVIDIA" : "OpenRouter" + return `${label}${m ? ` · ${m}` : ""}` +} diff --git a/apps/supercode-cli/server/src/cli/utils/tui.ts b/apps/supercode-cli/server/src/cli/utils/tui.ts index 40ce364..5716be4 100644 --- a/apps/supercode-cli/server/src/cli/utils/tui.ts +++ b/apps/supercode-cli/server/src/cli/utils/tui.ts @@ -525,7 +525,7 @@ export function sessionSummary(conversation: { id: string; title: string | null; export function chatHelp() { const lines = [ ` ${chalk.hex(theme.cyan)("Enter")} send message`, - ` ${chalk.hex(theme.cyan)("Esc")} cancel / exit`, + ` ${chalk.hex(theme.cyan)("Esc")} clear input / cancel response`, ` ${chalk.hex(theme.cyan)("↑/↓")} navigate history`, ] return panel(lines.join("\n"), { title: "keys", borderColor: theme.dim }) diff --git a/apps/supercode-cli/server/src/cli/workspace/context.ts b/apps/supercode-cli/server/src/cli/workspace/context.ts index 2b057b3..a3401c9 100644 --- a/apps/supercode-cli/server/src/cli/workspace/context.ts +++ b/apps/supercode-cli/server/src/cli/workspace/context.ts @@ -3,8 +3,25 @@ import type { WorkspaceInfo } from "./scanner.ts" export function buildSystemPrompt(info: WorkspaceInfo): string { const lines: string[] = [] - lines.push("You are Supercode, an AI coding assistant running in the user's terminal.") - lines.push("You have full awareness of the user's current workspace and project structure.") + lines.push("You are a senior software engineer running in the user's terminal. You work") + lines.push("autonomously to complete software engineering tasks.") + lines.push("") + lines.push("## Core Principles") + lines.push("") + lines.push("1. **Do, don't suggest.** When the user asks you to create an app, fix a bug,") + lines.push(" or add a feature — just do it. Use your tools to read, write, and execute.") + lines.push("") + lines.push("2. **Explain as you work.** Tell the user what you're doing and why.") + lines.push(" \"I need to check the existing code first\" -> read_file.") + lines.push(" \"I'll create the component now\" -> write_file.") + lines.push(" \"Let me install dependencies\" -> run_command.") + lines.push("") + lines.push("3. **Multi-step workflows are normal.** Plans often require 10+ steps:") + lines.push(" read -> search -> write -> run -> write -> run. Execute the full workflow") + lines.push(" without stopping to ask \"should I continue?\"") + lines.push("") + lines.push("4. **Handle errors gracefully.** If a command fails, diagnose and fix it.") + lines.push(" Do not hand the problem back to the user.") lines.push("") lines.push(`## Workspace: ${info.projectName || info.dirName}`) lines.push(`- Path: ${info.fullPath}`) @@ -33,22 +50,47 @@ export function buildSystemPrompt(info: WorkspaceInfo): string { lines.push("## Available Tools") lines.push("") - lines.push("You have access to the following tools to explore and modify the workspace:") + lines.push("You have full access to create, modify, and delete files in the workspace.") + lines.push("You can run shell commands to install packages, run builds, and start dev servers.") + lines.push("Do not ask the user for permission for routine operations — just execute them.") lines.push("") - lines.push("1. `read_file(path, maxLines?)` — Read the contents of any file in the workspace.") - lines.push(" Use this to examine source code, configs, or any file the user asks about.") + lines.push("1. `read_file(path, maxLines?)` — Read file contents from the workspace.") + lines.push(" Use this to examine source code, configs, or any file.") lines.push("") lines.push("2. `search_files(pattern, include?, maxResults?)` — Search for text patterns") - lines.push(" across workspace files. Use this to find relevant code, function definitions,") - lines.push(" imports, or any text in the codebase.") + lines.push(" across workspace files. Use this to find relevant code or definitions.") + lines.push("") + lines.push("3. `write_file(path, content, description?)` — Create or overwrite files.") + lines.push(" Use for: new components, fixing bugs, adding features, config changes.") + lines.push("") + lines.push("4. `run_command(command, description?, timeout?)` — Execute shell commands.") + lines.push(" Use for: npm install, npm run build, git operations, running tests.") + lines.push("") + lines.push("5. Fetch content from URLs for reference using the built-in web fetch tool.") + lines.push("") + lines.push("6. Search the web for current information using the built-in web search tool.") + lines.push("") + lines.push("7. `code_exec(code)` — Run JavaScript/TypeScript in a sandbox for calculations.") + lines.push("") + lines.push("## Example Workflows") + lines.push("") + lines.push("### Creating a new app:") + lines.push(" run_command(\"npm create vite@latest . -- --template react\")") + lines.push(" run_command(\"npm install\")") + lines.push(" write_file(\"src/App.jsx\", ...)") + lines.push(" write_file(\"src/components/NoteCard.jsx\", ...)") + lines.push(" run_command(\"npm run dev\")") + lines.push("") + lines.push("### Fixing a bug:") + lines.push(" read_file(\"src/components/BuggyComponent.tsx\")") + lines.push(" search_files(\"relatedFunction\", \"*.ts\")") + lines.push(" write_file(\"src/components/BuggyComponent.tsx\", ...)") + lines.push(" run_command(\"npm run test\")") lines.push("") - lines.push("## Guidelines") + lines.push("## Working Directory") lines.push("") - lines.push("- When the user asks about code, use read_file or search_files to investigate.") - lines.push("- When suggesting changes, reference specific file paths and line numbers.") - lines.push("- If you need more context, use the tools to explore the codebase.") lines.push("- The workspace root is the base for all relative file paths.") - lines.push("- Answer questions about the codebase accurately based on what you find.") + lines.push("- All commands execute in the workspace root unless cwd is specified.") return lines.join("\n") } diff --git a/apps/supercode-cli/server/src/config/tools.config.ts b/apps/supercode-cli/server/src/config/tools.config.ts index d7cb0f1..bb5e1e7 100644 --- a/apps/supercode-cli/server/src/config/tools.config.ts +++ b/apps/supercode-cli/server/src/config/tools.config.ts @@ -34,6 +34,22 @@ export const availableTools: ToolConfig[] = [ getTool: () => registryTools.url_fetch as unknown as Record, enabled: false, }, + { + id: "write_file", + name: "Write File", + description: + "Create and modify files in the workspace", + getTool: () => registryTools.write_file as unknown as Record, + enabled: true, + }, + { + id: "run_command", + name: "Run Command", + description: + "Execute shell commands in the workspace", + getTool: () => registryTools.run_command as unknown as Record, + enabled: true, + }, ] function tryGetConfigTools( diff --git a/apps/supercode-cli/server/src/index.ts b/apps/supercode-cli/server/src/index.ts index 7bde636..be2305c 100644 --- a/apps/supercode-cli/server/src/index.ts +++ b/apps/supercode-cli/server/src/index.ts @@ -209,20 +209,135 @@ app.post("/api/ai/chat", async (req, res) => { const apiKey = process.env.OPENROUTER_API_KEY if (!apiKey) { res.status(500).json({ error: "OpenRouter not configured on server" }); return } const modelName = modelParam || process.env.OPENROUTER_MODEL || "openai/gpt-oss-120b:free" - const { createOpenRouter } = await import("@openrouter/ai-sdk-provider") - const { streamText } = await import("ai") - const openrouter = createOpenRouter({ apiKey }) - const opts: any = { model: openrouter(modelName), messages: nonSystemMessages } - if (modelName.includes("minimax-m3") || modelName.includes("glm-5.1")) opts.maxOutputTokens = 8192 - if (system) opts.system = system - if (tools) { opts.tools = tools; opts.maxSteps = 5 } - const result = streamText(opts) - for await (const chunk of result.textStream) { - res.write(JSON.stringify({ type: "text", content: chunk }) + "\n") + + const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions" + + const apiMessages: any[] = [] + if (system) apiMessages.push({ role: "system", content: system }) + for (const m of nonSystemMessages) { + apiMessages.push({ role: m.role, content: m.content as string }) + } + + const apiTools: any[] = [] + if (tools) { + for (const key of Object.keys(tools)) { + if (key === "web_search") { + apiTools.push({ type: "openrouter:web_search" }) + } else if (key === "url_fetch") { + apiTools.push({ type: "openrouter:web_fetch" }) + } else { + const def = (tools as any)[key] + const params = def.parameters + ? (typeof def.parameters === "object" && "toJSON" in (def.parameters as any) + ? (def.parameters as any).toJSON() + : def.parameters) + : undefined + apiTools.push({ + type: "function", + function: { name: key, description: def.description || "", parameters: params }, + }) + } + } + } + + const allMessages = [...apiMessages] + const maxIter = 10 + + for (let iter = 0; iter < maxIter; iter++) { + const body: any = { model: modelName, messages: allMessages, stream: true } + if (apiTools.length > 0) body.tools = apiTools + if (modelName.includes("minimax-m3") || modelName.includes("glm-5.1")) body.max_tokens = 8192 + + const orRes = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + if (!orRes.ok) { + const errText = await orRes.text().catch(() => "unknown error") + res.status(orRes.status).json({ error: `OpenRouter API ${orRes.status}: ${errText.slice(0, 500)}` }) + return + } + + const reader = orRes.body?.getReader() + if (!reader) { res.status(500).json({ error: "No response body" }); return } + + const decoder = new TextDecoder() + let buffer = "" + let toolCalls: Array<{ id: string; type: string; function: { name: string; arguments: string } }> = [] + let finishReason = "" + let hasContent = false + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith("data: ")) continue + const jsonStr = trimmed.slice(6) + if (jsonStr === "[DONE]") continue + try { + const data = JSON.parse(jsonStr) + const delta = data.choices?.[0]?.delta + const finish = data.choices?.[0]?.finish_reason + if (finish) finishReason = finish + if (delta?.content) { + hasContent = true + res.write(JSON.stringify({ type: "text", content: delta.content }) + "\n") + } + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = toolCalls.find(t => t.id === tc.id) + if (existing) { + if (tc.function?.arguments) existing.function.arguments += tc.function.arguments + } else { + toolCalls.push({ + id: tc.id, + type: tc.type || "function", + function: { name: tc.function?.name || "", arguments: tc.function?.arguments || "" }, + }) + } + } + } + } catch { /* skip */ } + } + } + + if (finishReason === "tool_calls" && toolCalls.length > 0) { + const assistantMsg: any = { role: "assistant", content: null } + assistantMsg.tool_calls = toolCalls.map(tc => ({ + id: tc.id, type: tc.type, + function: { name: tc.function.name, arguments: tc.function.arguments }, + })) + allMessages.push(assistantMsg) + + for (const tc of toolCalls) { + const toolName = tc.function.name + const toolDef = (tools as any)?.[toolName] + const resultStr = toolDef + ? `Tool "${toolName}" requires client-side execution` : `Tool "${toolName}" is not available` + allMessages.push({ role: "tool", tool_call_id: tc.id, content: resultStr }) + } + + toolCalls = [] + finishReason = "" + continue + } + + if (!hasContent) { + res.write(JSON.stringify({ type: "text", content: "" }) + "\n") + } + res.write(JSON.stringify({ + type: "finish", reason: "stop", + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }) + "\n") + res.end() + break } - const usage = await result.usage - res.write(JSON.stringify({ type: "finish", reason: await result.finishReason, usage }) + "\n") - res.end() break } case "minimax": { @@ -232,7 +347,11 @@ app.post("/api/ai/chat", async (req, res) => { const { createMinimax } = await import("vercel-minimax-ai-provider") const { streamText } = await import("ai") const minimax = createMinimax({ apiKey }) - const opts: any = { model: minimax(modelName), messages: nonSystemMessages } + const opts: any = { + model: minimax(modelName), + messages: nonSystemMessages, + maxTokens: Number(process.env.MINIMAX_MAX_TOKENS) || 4096, + } if (system) opts.system = system if (tools) { opts.tools = tools; opts.maxSteps = 5 } const result = streamText(opts) @@ -312,7 +431,12 @@ app.post("/api/ai/chat", async (req, res) => { } } } catch (error) { - res.status(500).json({ error: String(error) }) + const msg = String(error) + if (msg.includes("insufficient balance") || msg.includes("402")) { + res.status(402).json({ error: "MiniMax API: insufficient balance. Top up at https://platform.minimax.ai" }) + } else { + res.status(500).json({ error: msg }) + } } }) @@ -368,7 +492,12 @@ app.post("/api/ai/generate-object", async (req, res) => { } } } catch (error) { - res.status(500).json({ error: String(error) }) + const msg = String(error) + if (msg.includes("insufficient balance") || msg.includes("402")) { + res.status(402).json({ error: "MiniMax API: insufficient balance. Top up at https://platform.minimax.ai" }) + } else { + res.status(500).json({ error: msg }) + } } }) diff --git a/apps/supercode-cli/server/src/tools/definitions/run-command.ts b/apps/supercode-cli/server/src/tools/definitions/run-command.ts new file mode 100644 index 0000000..dd839d2 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/definitions/run-command.ts @@ -0,0 +1,71 @@ +import { z } from "zod" +import { exec } from "node:child_process" +import path from "node:path" + +const runCommandSchema = z.object({ + command: z.string().describe("Shell command to execute (e.g. 'npm install', 'npm run build', 'git status')"), + description: z + .string() + .optional() + .describe("Purpose of this command (for display in permission prompt)"), + timeout: z + .number() + .optional() + .default(120_000) + .describe("Timeout in milliseconds (default: 120000)"), + cwd: z + .string() + .optional() + .describe("Working directory relative to workspace root (defaults to workspace root)"), +}) + +export type RunCommandArgs = z.infer + +export const runCommandTool = { + description: + "Execute a shell command in the workspace. Use this to install dependencies, run builds, start dev servers, run tests, or any other terminal operation.", + parameters: runCommandSchema, + execute: async ({ command, timeout, cwd: subdir }: RunCommandArgs) => { + const workspaceRoot = process.env.SUPERCODE_WORKSPACE_ROOT || process.cwd() + + let resolvedCwd = workspaceRoot + if (subdir) { + resolvedCwd = path.resolve(workspaceRoot, subdir) + if (!resolvedCwd.startsWith(workspaceRoot)) { + throw new Error(`Working directory "${subdir}" is outside workspace root`) + } + } + + return new Promise((resolve) => { + const child = exec( + command, + { + cwd: resolvedCwd, + encoding: "utf-8", + timeout, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" }, + }, + (error, stdout, stderr) => { + const result: Record = { + exitCode: error?.code ?? 0, + stdout: stdout || "", + stderr: stderr || "", + } + + if (error && error.killed) { + result.signal = error.signal || "SIGTERM" + result.stderr = (result.stderr as string) + `\nCommand timed out after ${timeout}ms` + } + + if (error && error.code === undefined && !error.killed) { + result.exitCode = -1 + result.stderr = (result.stderr as string) + `\n${error.message}` + } + + resolve(JSON.stringify(result)) + }, + ) + }) + }, +} diff --git a/apps/supercode-cli/server/src/tools/definitions/write-file.ts b/apps/supercode-cli/server/src/tools/definitions/write-file.ts new file mode 100644 index 0000000..aee3461 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/definitions/write-file.ts @@ -0,0 +1,52 @@ +import { z } from "zod" +import path from "node:path" +import { mkdir, writeFile } from "node:fs/promises" + +const MAX_FILE_SIZE = 1_000_000 + +const writeFileSchema = z.object({ + path: z.string().describe("Relative path from workspace root (e.g. 'src/App.jsx', 'package.json')"), + content: z.string().describe("Complete file content to write"), + description: z + .string() + .optional() + .describe("Brief description of what this file does (for display)"), +}) + +export type WriteFileArgs = z.infer + +export const writeFileTool = { + description: + "Create a new file or overwrite an existing file in the workspace. Use this to create components, add features, fix bugs, or modify configuration files.", + parameters: writeFileSchema, + execute: async ({ path: filePath, content }: WriteFileArgs) => { + const workspaceRoot = process.env.SUPERCODE_WORKSPACE_ROOT || process.cwd() + const fullPath = path.resolve(workspaceRoot, filePath) + + if (!fullPath.startsWith(workspaceRoot)) { + throw new Error(`Path "${filePath}" is outside workspace root`) + } + + if (content.length > MAX_FILE_SIZE) { + throw new Error(`File "${filePath}" exceeds maximum size of 1MB`) + } + + if (content.includes("\0")) { + throw new Error(`File "${filePath}" contains binary content and cannot be written`) + } + + const fileDir = path.dirname(fullPath) + await mkdir(fileDir, { recursive: true }) + + await writeFile(fullPath, content, "utf-8") + + const existingSize = content.length + const action = "created" + + return JSON.stringify({ + path: filePath, + size: existingSize, + action, + }) + }, +} diff --git a/apps/supercode-cli/server/src/tools/permission-manager.ts b/apps/supercode-cli/server/src/tools/permission-manager.ts new file mode 100644 index 0000000..5e40141 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/permission-manager.ts @@ -0,0 +1,203 @@ +import chalk from "chalk" +import * as readline from "readline" +import boxen from "boxen" +import { theme } from "src/cli/utils/tui.ts" + +export type PermissionAction = "allow" | "ask" | "deny" + +interface PermissionRule { + action: PermissionAction + rememberAlways: boolean +} + +const DANGEROUS_PATTERNS: RegExp[] = [ + /rm\s+-rf/, + /\bDROP\s+TABLE\b/i, + /\bDROP\s+DATABASE\b/i, + /git\s+push\s+.*--force/, + /git\s+push\s+.*-f\b/, + /chmod\s+-R\s*777/, + /\bsudo\b/, + /\bdd\s+if=/, + />\s*\/dev\/sd/, + /mkfs\.\w+/, + /:()\s*\{.*:\s*\}.*:/, + /curl\s+.*\|\s*bash/, + /wget\s+.*\|\s*bash/, + /\\x[0-9a-fA-F]{2}.*;.*;.*;/, + /pkill\s+-9/, + /killall\s+/, + /shutdown\s+now/, + /reboot\b/, + /init\s+0/, + /init\s+6/, +] + +const DEFAULT_RULES: Record = { + read_file: { action: "allow", rememberAlways: false }, + search_files: { action: "allow", rememberAlways: false }, + url_fetch: { action: "allow", rememberAlways: false }, + web_search: { action: "allow", rememberAlways: false }, + code_exec: { action: "ask", rememberAlways: true }, + write_file: { action: "ask", rememberAlways: true }, + run_command: { action: "ask", rememberAlways: true }, +} + +export class PermissionManager { + private rules: Map + private alwaysCache: Map> = new Map() + private sessionLevel: "allow" | "ask" | "deny" | null = null + + constructor() { + this.rules = new Map(Object.entries(DEFAULT_RULES)) + } + + setSessionLevel(level: "allow" | "ask" | "deny"): void { + this.sessionLevel = level + } + + getSessionLevel(): "allow" | "ask" | "deny" | null { + return this.sessionLevel + } + + isDangerousCommand(command: string): boolean { + return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command)) + } + + async check(toolName: string, args: Record): Promise { + if (this.sessionLevel === "allow") return true + if (this.sessionLevel === "deny") return false + + const rule = this.rules.get(toolName) + if (!rule || rule.action === "allow") return true + if (rule.action === "deny") return false + + if (rule.rememberAlways && this.isAlwaysAllowed(toolName, args)) { + return true + } + + const isDangerous = toolName === "run_command" && this.isDangerousCommand(String(args.command || "")) + + return this.promptUser(toolName, args, isDangerous, rule.rememberAlways) + } + + private isAlwaysAllowed(toolName: string, args: Record): boolean { + const cache = this.alwaysCache.get(toolName) + if (!cache) return false + + if (toolName === "run_command") { + const command = String(args.command || "") + for (const prefix of cache) { + if (command.startsWith(prefix)) return true + } + return false + } + + if (toolName === "write_file") { + const path = String(args.path || "") + for (const pattern of cache) { + if (path.startsWith(pattern)) return true + if (pattern.endsWith("/*") && path.startsWith(pattern.slice(0, -2))) return true + } + return false + } + + return false + } + + private addAlwaysCache(toolName: string, args: Record, alwaysPattern: string): void { + if (!this.alwaysCache.has(toolName)) { + this.alwaysCache.set(toolName, new Set()) + } + this.alwaysCache.get(toolName)!.add(alwaysPattern) + } + + private async promptUser( + toolName: string, + args: Record, + isDangerous: boolean, + canRememberAlways: boolean, + ): Promise { + const stdin = process.stdin + const wasRaw = stdin.isRaw + + if (stdin.isTTY) { + stdin.setRawMode(false) + } + + const borderColor = isDangerous ? theme.red : theme.warning + const header = isDangerous ? " DANGEROUS OPERATION " : " Permission Request " + + let content = "" + if (toolName === "write_file") { + content = `Supercode wants to write:\n ${chalk.cyan(String(args.path || ""))}` + if (args.description) { + content += `\n ${chalk.dim(String(args.description))}` + } + } else if (toolName === "run_command") { + content = `Run:\n $ ${chalk.cyan(String(args.command || ""))}` + if (args.description) { + content += `\n ${chalk.dim(String(args.description))}` + } + } else if (toolName === "code_exec") { + const code = String(args.code || "") + const preview = code.length > 80 ? code.slice(0, 77) + "..." : code + content = `Execute code:\n ${chalk.cyan(preview)}` + } + + if (isDangerous) { + content += `\n\n${chalk.red("This operation is potentially destructive.")}` + } + + const box = boxen(content, { + title: header, + borderColor, + padding: 1, + margin: 1, + }) + console.log(box) + + return new Promise((resolve) => { + const rl = readline.createInterface({ input: stdin, output: process.stdout }) + + const prompt = isDangerous + ? "Allow this operation? (y/N): " + : canRememberAlways + ? "[y] Once [a] Always for session [n] Deny: " + : "Allow? (y/N): " + + rl.question(prompt, (answer) => { + rl.close() + + if (stdin.isTTY && wasRaw) { + stdin.setRawMode(true) + } + + const a = answer.trim().toLowerCase() + + if (a === "y" || a === "yes") { + resolve(true) + } else if ((a === "a" || a === "always") && canRememberAlways && !isDangerous) { + let alwaysPattern = "*" + if (toolName === "run_command") { + const cmd = String(args.command || "") + const parts = cmd.split(/\s+/) + if (parts.length > 0) { + alwaysPattern = parts[0] + " " + } + } else if (toolName === "write_file") { + const path = String(args.path || "") + const lastSlash = path.lastIndexOf("/") + alwaysPattern = lastSlash >= 0 ? path.slice(0, lastSlash + 1) : "" + } + this.addAlwaysCache(toolName, args, alwaysPattern) + resolve(true) + } else { + resolve(false) + } + }) + }) + } +} + +export const permissionManager = new PermissionManager() diff --git a/apps/supercode-cli/server/src/tools/registry.ts b/apps/supercode-cli/server/src/tools/registry.ts index bce0294..ebc81a0 100644 --- a/apps/supercode-cli/server/src/tools/registry.ts +++ b/apps/supercode-cli/server/src/tools/registry.ts @@ -1,13 +1,36 @@ import { readFileTool } from "./definitions/read-file.ts" import { searchFilesTool } from "./definitions/search-files.ts" +import { writeFileTool } from "./definitions/write-file.ts" +import { runCommandTool } from "./definitions/run-command.ts" import { urlFetchTool } from "./definitions/url-fetch.ts" import { webSearchTool } from "./definitions/web-search.ts" import { codeExecTool } from "./definitions/code-exec.ts" +import { permissionManager } from "./permission-manager.ts" + +function withPermission(tool: Record): Record { + const originalExecute = tool.execute as ((args: any) => Promise) | undefined + if (!originalExecute) return tool + return { + ...tool, + execute: async (args: any) => { + const allowed = await permissionManager.check( + (tool.name || tool.description) as string, + args, + ) + if (!allowed) { + return JSON.stringify({ cancelled: true, reason: "Permission denied by user" }) + } + return originalExecute(args) + }, + } +} export const tools = { read_file: readFileTool, search_files: searchFilesTool, + write_file: withPermission(writeFileTool as unknown as Record), + run_command: withPermission(runCommandTool as unknown as Record), url_fetch: urlFetchTool, web_search: webSearchTool, - code_exec: codeExecTool, + code_exec: withPermission(codeExecTool as unknown as Record), }