From bdd763de61c04a0ac4116619feee03a34b40feed Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Mon, 2 Mar 2026 14:09:09 -0800 Subject: [PATCH 1/3] Remove tools --- README.md | 28 +- src/core/router.ts | 45 +-- src/data/primary_llm_system_prompt.txt | 26 +- src/providers/anthropic.ts | 85 +---- src/providers/gemini.ts | 58 +--- src/providers/huggingface.ts | 73 +--- src/providers/minimax.ts | 81 +---- src/providers/mistral.ts | 73 +--- src/providers/moonshot.ts | 73 +--- src/providers/openai.ts | 73 +--- src/providers/openrouter.ts | 73 +--- src/providers/xai.ts | 73 +--- src/tools/cron.ts | 372 -------------------- src/tools/env.ts | 267 -------------- src/tools/git.ts | 219 ------------ src/tools/http.ts | 204 ----------- src/tools/network.ts | 281 --------------- src/tools/process-registry.ts | 234 ------------- src/tools/process.ts | 300 ---------------- src/tools/registry.ts | 152 -------- src/tools/search.ts | 323 ----------------- src/tools/sysinfo.ts | 277 --------------- src/tools/terminal.ts | 460 ------------------------- src/tools/types.ts | 41 --- test/unit/agent-commands.test.ts | 20 -- test/unit/router.test.ts | 20 -- 26 files changed, 134 insertions(+), 3797 deletions(-) delete mode 100644 src/tools/cron.ts delete mode 100644 src/tools/env.ts delete mode 100644 src/tools/git.ts delete mode 100644 src/tools/http.ts delete mode 100644 src/tools/network.ts delete mode 100644 src/tools/process-registry.ts delete mode 100644 src/tools/process.ts delete mode 100644 src/tools/registry.ts delete mode 100644 src/tools/search.ts delete mode 100644 src/tools/sysinfo.ts delete mode 100644 src/tools/terminal.ts delete mode 100644 src/tools/types.ts diff --git a/README.md b/README.md index 126dbc8..74178e0 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Code without a keyboard. Send a message from your phone, and **txtcode** dispatc 1. **Text your AI** from WhatsApp, Telegram, Discord, Slack, Teams, or Signal 2. **It writes code** using Claude Code, Cursor, Codex, Gemini CLI, or other adapters -3. **You stay in control** with mode switching, tool calling, and session logs +3. **You stay in control** with mode switching and session logs No port forwarding. No VPN. Just message and code. @@ -66,10 +66,6 @@ Claude Code, Cursor CLI, OpenAI Codex, Gemini CLI, Kiro CLI, OpenCode, and Ollam -### 9 Built-in Tools - -Terminal, process manager, git, file search, HTTP client, environment variables, network diagnostics, cron jobs, and system info all callable by the LLM. - ### Session Logging Per-session logs accessible from the TUI. Follow live, view by index, auto-pruned after 7 days. @@ -159,7 +155,7 @@ txtcode supports **9 LLM providers** for chat mode. Configure one or more during | **HuggingFace** | _Discovered at runtime_ | Inference Providers API | | **OpenRouter** | _Discovered at runtime_ | Unified API for 100+ models | -All providers support tool calling and the LLM can invoke any built-in tool. +All providers are used in chat mode for general conversation and coding questions. --- @@ -179,31 +175,13 @@ Use `/code` mode to route messages directly to a coding adapter with full coding --- -## 🛠️ Built-in Tools - -The primary LLM in chat mode has access to **9 built-in tools** that it can call autonomously: - -| Tool | Capabilities | -| :----------- | :---------------------------------------------------------------------------------------- | -| **Terminal** | Execute shell commands with timeout and output capture | -| **Process** | Manage background processes: list, poll, stream logs, kill, send input | -| **Git** | Full git operations (blocks force-push and credential config for safety) | -| **Search** | File and content search across the project | -| **HTTP** | Make HTTP requests (GET, POST, PUT, DELETE, PATCH, HEAD). Blocks cloud metadata endpoints | -| **Env** | Get, set, list, and delete environment variables. Masks sensitive values | -| **Network** | Ping, DNS lookup, reachability checks, port scanning | -| **Cron** | Create, list, and manage cron jobs | -| **Sysinfo** | CPU, memory, disk, uptime, OS details | - ---- - ## 💬 Chat Commands Send these commands in any messaging app while connected: | Command | Description | | :----------- | :----------------------------------------------------------------------------- | -| `/chat` | Switch to **Chat mode** to send messages to primary LLM with tools _(default)_ | +| `/chat` | Switch to **Chat mode** to send messages to primary LLM _(default)_ | | `/code` | Switch to **Code mode** to send messages to coding adapter (full CLI control) | | `/switch` | Switch primary LLM provider or coding adapter on the fly | | `/cli-model` | Change the model used by the current coding adapter | diff --git a/src/core/router.ts b/src/core/router.ts index b28b29d..8f2ac99 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -16,16 +16,6 @@ import { processWithOpenRouter } from "../providers/openrouter"; import { processWithXAI } from "../providers/xai"; import { logger } from "../shared/logger"; import { IDEAdapter, ModelInfo } from "../shared/types"; -import { CronTool } from "../tools/cron"; -import { EnvTool } from "../tools/env"; -import { GitTool } from "../tools/git"; -import { HttpTool } from "../tools/http"; -import { NetworkTool } from "../tools/network"; -import { ProcessTool } from "../tools/process"; -import { ToolRegistry } from "../tools/registry"; -import { SearchTool } from "../tools/search"; -import { SysinfoTool } from "../tools/sysinfo"; -import { TerminalTool } from "../tools/terminal"; import { ContextManager } from "./context-manager"; export const AVAILABLE_ADAPTERS = [ @@ -43,7 +33,6 @@ export class Router { private provider: string; private apiKey: string; private model: string; - private toolRegistry: ToolRegistry; private contextManager: ContextManager; private pendingHandoff: string | null = null; private currentAbortController: AbortController | null = null; @@ -53,17 +42,6 @@ export class Router { this.apiKey = process.env.AI_API_KEY || ""; this.model = process.env.AI_MODEL || ""; - this.toolRegistry = new ToolRegistry(); - this.toolRegistry.register(new TerminalTool()); - this.toolRegistry.register(new ProcessTool()); - this.toolRegistry.register(new GitTool()); - this.toolRegistry.register(new SearchTool()); - this.toolRegistry.register(new HttpTool()); - this.toolRegistry.register(new EnvTool()); - this.toolRegistry.register(new NetworkTool()); - this.toolRegistry.register(new CronTool()); - this.toolRegistry.register(new SysinfoTool()); - this.contextManager = new ContextManager(); const ideType = process.env.IDE_TYPE || ""; @@ -151,28 +129,23 @@ export class Router { private async _routeToProvider(instruction: string): Promise { switch (this.provider) { case "anthropic": - return await processWithAnthropic(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithAnthropic(instruction, this.apiKey, this.model); case "openai": - return await processWithOpenAI(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithOpenAI(instruction, this.apiKey, this.model); case "gemini": - return await processWithGemini(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithGemini(instruction, this.apiKey, this.model); case "openrouter": - return await processWithOpenRouter(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithOpenRouter(instruction, this.apiKey, this.model); case "moonshot": - return await processWithMoonshot(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithMoonshot(instruction, this.apiKey, this.model); case "minimax": - return await processWithMiniMax(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithMiniMax(instruction, this.apiKey, this.model); case "huggingface": - return await processWithHuggingFace( - instruction, - this.apiKey, - this.model, - this.toolRegistry, - ); + return await processWithHuggingFace(instruction, this.apiKey, this.model); case "mistral": - return await processWithMistral(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithMistral(instruction, this.apiKey, this.model); case "xai": - return await processWithXAI(instruction, this.apiKey, this.model, this.toolRegistry); + return await processWithXAI(instruction, this.apiKey, this.model); default: return `[ERROR] Unsupported AI provider: ${this.provider}. Run: txtcode config`; } diff --git a/src/data/primary_llm_system_prompt.txt b/src/data/primary_llm_system_prompt.txt index c95740b..8930f14 100644 --- a/src/data/primary_llm_system_prompt.txt +++ b/src/data/primary_llm_system_prompt.txt @@ -1,35 +1,11 @@ -You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging platforms (WhatsApp, Telegram, Discord). +You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging platforms (WhatsApp, Telegram, Discord, Slack, Teams, Signal). ROLE: - You are the user's primary AI assistant for general conversation, coding questions, debugging help, and technical discussions -- You have full access to the user's machine via a rich set of tools — terminal, git, file search, HTTP, env vars, networking, scheduling, and system monitoring -- Use tools when the user asks you to execute something, check system state, or perform actions — NOT just to introduce yourself - -TOOLS AVAILABLE (9 total): -1. exec — Run any shell command in any directory. Use workdir param for absolute paths. PowerShell on Windows, bash on macOS/Linux. -2. process — Manage backgrounded shell commands: list, poll, kill, send stdin, clear finished sessions. -3. git — Git operations: status, diff, log, branch, commit, stash, checkout, add, reset, push, pull, merge, rebase, tag, blame, show. Destructive ops (force push, hard reset) blocked unless force=true. -4. search — Find files and code: grep (search content by regex), glob (find files by name pattern), find (by extension/size/date). Auto-skips node_modules/.git/dist. -5. http — Make HTTP requests: GET/POST/PUT/DELETE/PATCH/HEAD. Test APIs, check endpoints, hit webhooks. Supports headers, body, timeout. -6. env — Read environment variables and .env files. Sensitive values (keys, tokens, passwords) are automatically masked. Use unmask=true to reveal. -7. network — Network diagnostics: ping, DNS lookup, reachability check, list listening ports or check if a specific port is in use. -8. cron — Manage scheduled tasks: list/add/remove cron jobs (Unix) or scheduled tasks (Windows). Destructive ops require confirm=true. -9. sysinfo — System info: overview, CPU, memory, disk usage, uptime, top processes by CPU/memory. - -TOOL USAGE GUIDELINES: -- Use the git tool (not exec) for git operations — it has safety guardrails and structured output -- Use the search tool (not exec + grep/find) for searching files — it's cross-platform and skips junk directories -- Use the http tool (not exec + curl) for API testing — it gives structured response data -- Use the env tool for environment variables — it masks secrets automatically -- Use the network tool for connectivity checks — it works identically on all platforms -- Use the sysinfo tool for system monitoring — it uses Node.js built-ins for reliable cross-platform data -- Fall back to exec only for commands not covered by dedicated tools -- Use the process tool to manage long-running commands started via exec BEHAVIOR: - Respond naturally and conversationally to greetings and general questions - Be concise — remember you're communicating via a messaging platform, not a full IDE -- When asked to run commands or check something, use your available tools immediately - Provide clear, well-formatted responses (use markdown where helpful) - If the user needs deep coding work (editing files, writing code across multiple files), suggest switching to /code mode diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 690fe71..04a7380 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -1,18 +1,7 @@ import fs from "fs"; import path from "path"; import Anthropic from "@anthropic-ai/sdk"; -import type { - ContentBlock, - MessageParam, - TextBlock, - ToolResultBlockParam, - ToolUnion, - ToolUseBlock, -} from "@anthropic-ai/sdk/resources/messages/messages"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; - -const MAX_ITERATIONS = 10; function loadSystemPrompt(): string { try { @@ -27,7 +16,6 @@ export async function processWithAnthropic( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[Anthropic] Request → model=${model}, prompt=${instruction.length} chars`); @@ -35,63 +23,24 @@ export async function processWithAnthropic( try { const anthropic = new Anthropic({ apiKey }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("anthropic") as unknown as ToolUnion[]) - : undefined; - - const messages: MessageParam[] = [{ role: "user", content: instruction }]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const response = await anthropic.messages.create({ - model, - max_tokens: 4096, - system: loadSystemPrompt(), - messages, - ...(tools ? { tools } : {}), - }); - - logger.debug( - `[Anthropic] Response ← iteration=${i + 1}, stop=${response.stop_reason}, ` + - `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out, ` + - `time=${Date.now() - iterStart}ms`, - ); - - const textParts = response.content - .filter((block: ContentBlock): block is TextBlock => block.type === "text") - .map((block: TextBlock) => block.text); - - const toolCalls = response.content.filter( - (block: ContentBlock): block is ToolUseBlock => block.type === "tool_use", - ); - - if (toolCalls.length === 0 || !toolRegistry) { - logger.debug(`[Anthropic] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return textParts.join("\n") || "No response from Claude"; - } - - logger.debug(`[Anthropic] Tool calls: ${toolCalls.map((t) => t.name).join(", ")}`); - - messages.push({ role: "assistant", content: response.content }); - - const toolResults: ToolResultBlockParam[] = []; - for (const toolUse of toolCalls) { - const result = await toolRegistry.execute( - toolUse.name, - toolUse.input as Record, - ); - toolResults.push({ - type: "tool_result", - tool_use_id: toolUse.id, - content: result.output, - }); - } - - messages.push({ role: "user", content: toolResults }); - } + const response = await anthropic.messages.create({ + model, + max_tokens: 4096, + system: loadSystemPrompt(), + messages: [{ role: "user", content: instruction }], + }); + + const text = response.content + .filter((block): block is Anthropic.TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); + + logger.debug( + `[Anthropic] Done in ${Date.now() - startTime}ms, ` + + `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out`, + ); - logger.warn(`[Anthropic] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return text || "No response from Claude"; } catch (error: unknown) { logger.error(`[Anthropic] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index 7465e41..d94dbfa 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -1,14 +1,7 @@ import fs from "fs"; import path from "path"; -import { - type FunctionResponsePart, - type Tool as GeminiTool, - GoogleGenerativeAI, -} from "@google/generative-ai"; +import { GoogleGenerativeAI } from "@google/generative-ai"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; - -const MAX_ITERATIONS = 10; function loadSystemPrompt(): string { try { @@ -23,7 +16,6 @@ export async function processWithGemini( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[Gemini] Request → model=${model}, prompt=${instruction.length} chars`); @@ -31,57 +23,17 @@ export async function processWithGemini( try { const genAI = new GoogleGenerativeAI(apiKey); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("gemini") as unknown as GeminiTool[]) - : undefined; - const genModel = genAI.getGenerativeModel({ model, systemInstruction: loadSystemPrompt(), - ...(tools ? { tools } : {}), }); - const chat = genModel.startChat(); - let iterStart = Date.now(); - let result = await chat.sendMessage(instruction); - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const response = result.response; - const calls = response.functionCalls(); - - logger.debug( - `[Gemini] Response ← iteration=${i + 1}, ` + - `toolCalls=${calls?.length ?? 0}, ` + - `time=${Date.now() - iterStart}ms`, - ); - - if (!calls || calls.length === 0 || !toolRegistry) { - logger.debug(`[Gemini] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return response.text(); - } - - logger.debug(`[Gemini] Tool calls: ${calls.map((c) => c.name).join(", ")}`); - - const toolResults: FunctionResponsePart[] = []; - for (const call of calls) { - const execResult = await toolRegistry.execute( - call.name, - (call.args || {}) as Record, - ); - toolResults.push({ - functionResponse: { - name: call.name, - response: { output: execResult.output, isError: execResult.isError }, - }, - }); - } + const result = await genModel.generateContent(instruction); + const text = result.response.text(); - iterStart = Date.now(); - result = await chat.sendMessage(toolResults); - } + logger.debug(`[Gemini] Done in ${Date.now() - startTime}ms`); - logger.warn(`[Gemini] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return text || "No response from Gemini"; } catch (error: unknown) { logger.error(`[Gemini] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/huggingface.ts b/src/providers/huggingface.ts index 43066a2..4ec81b0 100644 --- a/src/providers/huggingface.ts +++ b/src/providers/huggingface.ts @@ -1,14 +1,8 @@ import fs from "fs"; import path from "path"; import OpenAI from "openai"; -import type { - ChatCompletionMessageParam, - ChatCompletionTool, -} from "openai/resources/chat/completions/completions"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; -const MAX_ITERATIONS = 10; const HUGGINGFACE_BASE_URL = "https://router.huggingface.co/v1"; function loadSystemPrompt(): string { @@ -24,72 +18,33 @@ export async function processWithHuggingFace( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[HuggingFace] Request → model=${model}, prompt=${instruction.length} chars`); try { - // HuggingFace Inference Providers use OpenAI-compatible API const client = new OpenAI({ apiKey, baseURL: HUGGINGFACE_BASE_URL, }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[]) - : undefined; - - const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: loadSystemPrompt() }, - { role: "user", content: instruction }, - ]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const completion = await client.chat.completions.create({ - model, - messages, - max_tokens: 4096, - ...(tools ? { tools } : {}), - }); - - const choice = completion.choices[0]; - const assistantMsg = choice.message; - - logger.debug( - `[HuggingFace] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` + - `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` + - `time=${Date.now() - iterStart}ms`, - ); - - if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) { - logger.debug(`[HuggingFace] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return assistantMsg.content || "No response from HuggingFace"; - } - - logger.debug( - `[HuggingFace] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`, - ); + const completion = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: loadSystemPrompt() }, + { role: "user", content: instruction }, + ], + }); - messages.push(assistantMsg); + const choice = completion.choices[0]; - for (const toolCall of assistantMsg.tool_calls) { - if (toolCall.type !== "function") { - continue; - } - const args = JSON.parse(toolCall.function.arguments); - const result = await toolRegistry.execute(toolCall.function.name, args); - messages.push({ - role: "tool", - tool_call_id: toolCall.id, - content: result.output, - }); - } - } + logger.debug( + `[HuggingFace] Done in ${Date.now() - startTime}ms, ` + + `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`, + ); - logger.warn(`[HuggingFace] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return choice.message.content || "No response from HuggingFace"; } catch (error: unknown) { logger.error(`[HuggingFace] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index 83933cd..78ed83b 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -1,18 +1,8 @@ import fs from "fs"; import path from "path"; import Anthropic from "@anthropic-ai/sdk"; -import type { - ContentBlock, - MessageParam, - TextBlock, - ToolResultBlockParam, - ToolUnion, - ToolUseBlock, -} from "@anthropic-ai/sdk/resources/messages/messages"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; -const MAX_ITERATIONS = 10; const MINIMAX_BASE_URL = "https://api.minimax.chat/v1"; function loadSystemPrompt(): string { @@ -28,75 +18,34 @@ export async function processWithMiniMax( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[MiniMax] Request → model=${model}, prompt=${instruction.length} chars`); try { - // MiniMax uses Anthropic-compatible API const client = new Anthropic({ apiKey, baseURL: MINIMAX_BASE_URL, }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("anthropic") as unknown as ToolUnion[]) - : undefined; - - const messages: MessageParam[] = [{ role: "user", content: instruction }]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const response = await client.messages.create({ - model, - max_tokens: 4096, - system: loadSystemPrompt(), - messages, - ...(tools ? { tools } : {}), - }); - - logger.debug( - `[MiniMax] Response ← iteration=${i + 1}, stop=${response.stop_reason}, ` + - `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out, ` + - `time=${Date.now() - iterStart}ms`, - ); - - const textParts = response.content - .filter((block: ContentBlock): block is TextBlock => block.type === "text") - .map((block: TextBlock) => block.text); - - const toolCalls = response.content.filter( - (block: ContentBlock): block is ToolUseBlock => block.type === "tool_use", - ); - - if (toolCalls.length === 0 || !toolRegistry) { - logger.debug(`[MiniMax] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return textParts.join("\n") || "No response from MiniMax"; - } - - logger.debug(`[MiniMax] Tool calls: ${toolCalls.map((t) => t.name).join(", ")}`); - - messages.push({ role: "assistant", content: response.content }); + const response = await client.messages.create({ + model, + max_tokens: 4096, + system: loadSystemPrompt(), + messages: [{ role: "user", content: instruction }], + }); - const toolResults: ToolResultBlockParam[] = []; - for (const toolUse of toolCalls) { - const result = await toolRegistry.execute( - toolUse.name, - toolUse.input as Record, - ); - toolResults.push({ - type: "tool_result", - tool_use_id: toolUse.id, - content: result.output, - }); - } + const text = response.content + .filter((block): block is Anthropic.TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); - messages.push({ role: "user", content: toolResults }); - } + logger.debug( + `[MiniMax] Done in ${Date.now() - startTime}ms, ` + + `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out`, + ); - logger.warn(`[MiniMax] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return text || "No response from MiniMax"; } catch (error: unknown) { logger.error(`[MiniMax] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/mistral.ts b/src/providers/mistral.ts index 7be41a1..f613ec0 100644 --- a/src/providers/mistral.ts +++ b/src/providers/mistral.ts @@ -1,14 +1,7 @@ import fs from "fs"; import path from "path"; import OpenAI from "openai"; -import type { - ChatCompletionMessageParam, - ChatCompletionTool, -} from "openai/resources/chat/completions/completions"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; - -const MAX_ITERATIONS = 10; function loadSystemPrompt(): string { try { @@ -23,7 +16,6 @@ export async function processWithMistral( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[Mistral] Request → model=${model}, prompt=${instruction.length} chars`); @@ -34,60 +26,23 @@ export async function processWithMistral( baseURL: "https://api.mistral.ai/v1", }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[]) - : undefined; - - const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: loadSystemPrompt() }, - { role: "user", content: instruction }, - ]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const completion = await client.chat.completions.create({ - model, - messages, - max_tokens: 4096, - ...(tools ? { tools } : {}), - }); - - const choice = completion.choices[0]; - const assistantMsg = choice.message; - - logger.debug( - `[Mistral] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` + - `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` + - `time=${Date.now() - iterStart}ms`, - ); - - if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) { - logger.debug(`[Mistral] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return assistantMsg.content || "No response from Mistral AI"; - } - - logger.debug( - `[Mistral] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`, - ); + const completion = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: loadSystemPrompt() }, + { role: "user", content: instruction }, + ], + }); - messages.push(assistantMsg); + const choice = completion.choices[0]; - for (const toolCall of assistantMsg.tool_calls) { - if (toolCall.type !== "function") { - continue; - } - const args = JSON.parse(toolCall.function.arguments); - const result = await toolRegistry.execute(toolCall.function.name, args); - messages.push({ - role: "tool", - tool_call_id: toolCall.id, - content: result.output, - }); - } - } + logger.debug( + `[Mistral] Done in ${Date.now() - startTime}ms, ` + + `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`, + ); - logger.warn(`[Mistral] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return choice.message.content || "No response from Mistral AI"; } catch (error: unknown) { logger.error(`[Mistral] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/moonshot.ts b/src/providers/moonshot.ts index 29b77fd..a7d5847 100644 --- a/src/providers/moonshot.ts +++ b/src/providers/moonshot.ts @@ -1,14 +1,7 @@ import fs from "fs"; import path from "path"; import OpenAI from "openai"; -import type { - ChatCompletionMessageParam, - ChatCompletionTool, -} from "openai/resources/chat/completions/completions"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; - -const MAX_ITERATIONS = 10; function loadSystemPrompt(): string { try { @@ -23,7 +16,6 @@ export async function processWithMoonshot( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[Moonshot] Request → model=${model}, prompt=${instruction.length} chars`); @@ -34,60 +26,23 @@ export async function processWithMoonshot( baseURL: "https://api.moonshot.cn/v1", }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[]) - : undefined; - - const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: loadSystemPrompt() }, - { role: "user", content: instruction }, - ]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const completion = await client.chat.completions.create({ - model, - messages, - max_tokens: 4096, - ...(tools ? { tools } : {}), - }); - - const choice = completion.choices[0]; - const assistantMsg = choice.message; - - logger.debug( - `[Moonshot] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` + - `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` + - `time=${Date.now() - iterStart}ms`, - ); - - if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) { - logger.debug(`[Moonshot] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return assistantMsg.content || "No response from Moonshot AI"; - } - - logger.debug( - `[Moonshot] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`, - ); + const completion = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: loadSystemPrompt() }, + { role: "user", content: instruction }, + ], + }); - messages.push(assistantMsg); + const choice = completion.choices[0]; - for (const toolCall of assistantMsg.tool_calls) { - if (toolCall.type !== "function") { - continue; - } - const args = JSON.parse(toolCall.function.arguments); - const result = await toolRegistry.execute(toolCall.function.name, args); - messages.push({ - role: "tool", - tool_call_id: toolCall.id, - content: result.output, - }); - } - } + logger.debug( + `[Moonshot] Done in ${Date.now() - startTime}ms, ` + + `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`, + ); - logger.warn(`[Moonshot] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return choice.message.content || "No response from Moonshot AI"; } catch (error: unknown) { logger.error(`[Moonshot] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/openai.ts b/src/providers/openai.ts index a377e79..2cdffa7 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -1,14 +1,7 @@ import fs from "fs"; import path from "path"; import OpenAI from "openai"; -import type { - ChatCompletionMessageParam, - ChatCompletionTool, -} from "openai/resources/chat/completions/completions"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; - -const MAX_ITERATIONS = 10; function loadSystemPrompt(): string { try { @@ -23,7 +16,6 @@ export async function processWithOpenAI( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[OpenAI] Request → model=${model}, prompt=${instruction.length} chars`); @@ -31,60 +23,23 @@ export async function processWithOpenAI( try { const openai = new OpenAI({ apiKey }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[]) - : undefined; - - const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: loadSystemPrompt() }, - { role: "user", content: instruction }, - ]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const completion = await openai.chat.completions.create({ - model, - messages, - max_tokens: 4096, - ...(tools ? { tools } : {}), - }); - - const choice = completion.choices[0]; - const assistantMsg = choice.message; + const completion = await openai.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: loadSystemPrompt() }, + { role: "user", content: instruction }, + ], + }); - logger.debug( - `[OpenAI] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` + - `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` + - `time=${Date.now() - iterStart}ms`, - ); + const choice = completion.choices[0]; - if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) { - logger.debug(`[OpenAI] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return assistantMsg.content || "No response from GPT"; - } - - logger.debug( - `[OpenAI] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`, - ); - - messages.push(assistantMsg); - - for (const toolCall of assistantMsg.tool_calls) { - if (toolCall.type !== "function") { - continue; - } - const args = JSON.parse(toolCall.function.arguments); - const result = await toolRegistry.execute(toolCall.function.name, args); - messages.push({ - role: "tool", - tool_call_id: toolCall.id, - content: result.output, - }); - } - } + logger.debug( + `[OpenAI] Done in ${Date.now() - startTime}ms, ` + + `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`, + ); - logger.warn(`[OpenAI] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return choice.message.content || "No response from GPT"; } catch (error: unknown) { logger.error(`[OpenAI] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index ec84ea0..62d4c10 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -1,14 +1,7 @@ import fs from "fs"; import path from "path"; import OpenAI from "openai"; -import type { - ChatCompletionMessageParam, - ChatCompletionTool, -} from "openai/resources/chat/completions/completions"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; - -const MAX_ITERATIONS = 10; function loadSystemPrompt(): string { try { @@ -23,7 +16,6 @@ export async function processWithOpenRouter( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[OpenRouter] Request → model=${model}, prompt=${instruction.length} chars`); @@ -38,60 +30,23 @@ export async function processWithOpenRouter( }, }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[]) - : undefined; - - const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: loadSystemPrompt() }, - { role: "user", content: instruction }, - ]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const completion = await client.chat.completions.create({ - model, - messages, - max_tokens: 4096, - ...(tools ? { tools } : {}), - }); - - const choice = completion.choices[0]; - const assistantMsg = choice.message; - - logger.debug( - `[OpenRouter] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` + - `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` + - `time=${Date.now() - iterStart}ms`, - ); - - if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) { - logger.debug(`[OpenRouter] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return assistantMsg.content || "No response from OpenRouter"; - } - - logger.debug( - `[OpenRouter] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`, - ); + const completion = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: loadSystemPrompt() }, + { role: "user", content: instruction }, + ], + }); - messages.push(assistantMsg); + const choice = completion.choices[0]; - for (const toolCall of assistantMsg.tool_calls) { - if (toolCall.type !== "function") { - continue; - } - const args = JSON.parse(toolCall.function.arguments); - const result = await toolRegistry.execute(toolCall.function.name, args); - messages.push({ - role: "tool", - tool_call_id: toolCall.id, - content: result.output, - }); - } - } + logger.debug( + `[OpenRouter] Done in ${Date.now() - startTime}ms, ` + + `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`, + ); - logger.warn(`[OpenRouter] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return choice.message.content || "No response from OpenRouter"; } catch (error: unknown) { logger.error(`[OpenRouter] API error after ${Date.now() - startTime}ms`, error); throw new Error( diff --git a/src/providers/xai.ts b/src/providers/xai.ts index fd8707d..373b91d 100644 --- a/src/providers/xai.ts +++ b/src/providers/xai.ts @@ -1,14 +1,7 @@ import fs from "fs"; import path from "path"; import OpenAI from "openai"; -import type { - ChatCompletionMessageParam, - ChatCompletionTool, -} from "openai/resources/chat/completions/completions"; import { logger } from "../shared/logger"; -import { ToolRegistry } from "../tools/registry"; - -const MAX_ITERATIONS = 10; function loadSystemPrompt(): string { try { @@ -23,7 +16,6 @@ export async function processWithXAI( instruction: string, apiKey: string, model: string, - toolRegistry?: ToolRegistry, ): Promise { const startTime = Date.now(); logger.debug(`[xAI] Request → model=${model}, prompt=${instruction.length} chars`); @@ -34,60 +26,23 @@ export async function processWithXAI( baseURL: "https://api.x.ai/v1", }); - const tools = toolRegistry - ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[]) - : undefined; - - const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: loadSystemPrompt() }, - { role: "user", content: instruction }, - ]; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const iterStart = Date.now(); - const completion = await client.chat.completions.create({ - model, - messages, - max_tokens: 4096, - ...(tools ? { tools } : {}), - }); - - const choice = completion.choices[0]; - const assistantMsg = choice.message; - - logger.debug( - `[xAI] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` + - `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` + - `time=${Date.now() - iterStart}ms`, - ); - - if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) { - logger.debug(`[xAI] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`); - return assistantMsg.content || "No response from xAI"; - } - - logger.debug( - `[xAI] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`, - ); + const completion = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: [ + { role: "system", content: loadSystemPrompt() }, + { role: "user", content: instruction }, + ], + }); - messages.push(assistantMsg); + const choice = completion.choices[0]; - for (const toolCall of assistantMsg.tool_calls) { - if (toolCall.type !== "function") { - continue; - } - const args = JSON.parse(toolCall.function.arguments); - const result = await toolRegistry.execute(toolCall.function.name, args); - messages.push({ - role: "tool", - tool_call_id: toolCall.id, - content: result.output, - }); - } - } + logger.debug( + `[xAI] Done in ${Date.now() - startTime}ms, ` + + `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`, + ); - logger.warn(`[xAI] Reached max ${MAX_ITERATIONS} iterations`); - return "Reached maximum tool iterations."; + return choice.message.content || "No response from xAI"; } catch (error: unknown) { logger.error(`[xAI] API error after ${Date.now() - startTime}ms`, error); throw new Error(`xAI API error: ${error instanceof Error ? error.message : "Unknown error"}`, { diff --git a/src/tools/cron.ts b/src/tools/cron.ts deleted file mode 100644 index e13cd74..0000000 --- a/src/tools/cron.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { execFile, spawn } from "child_process"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const CMD_TIMEOUT = 15_000; - -function runCommand( - cmd: string, - args: string[], - timeout: number = CMD_TIMEOUT, -): Promise<{ stdout: string; stderr: string; code: number | null }> { - return new Promise((resolve) => { - execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => { - resolve({ - stdout: stdout?.toString() ?? "", - stderr: stderr?.toString() ?? "", - code: err ? ((err as { status?: number }).status ?? 1) : 0, - }); - }); - }); -} - -function writeCrontab( - content: string, - timeout: number = CMD_TIMEOUT, -): Promise<{ stderr: string; code: number | null }> { - return new Promise((resolve) => { - const proc = spawn("crontab", ["-"], { stdio: ["pipe", "ignore", "pipe"] }); - let stderr = ""; - const timer = setTimeout(() => { - proc.kill(); - resolve({ stderr: "crontab write timed out", code: 1 }); - }, timeout); - - proc.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { - clearTimeout(timer); - resolve({ stderr, code }); - }); - - proc.on("error", (err) => { - clearTimeout(timer); - resolve({ stderr: err.message, code: 1 }); - }); - - proc.stdin.write(content); - proc.stdin.end(); - }); -} - -export class CronTool implements Tool { - name = "cron"; - description = - "Manage scheduled tasks and cron jobs. Actions: list (show all scheduled tasks), " + - "get (details of a specific task), add (create a scheduled task), remove (delete a task). " + - "Uses crontab on Linux/macOS and schtasks on Windows."; - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - action: { - type: "string", - description: "Action to perform.", - enum: ["list", "get", "add", "remove"], - }, - name: { - type: "string", - description: - "Task name (required for get, add, remove). On Unix, used as a comment identifier in crontab.", - }, - schedule: { - type: "string", - description: - "Schedule expression. Unix: cron expression (e.g. '0 2 * * *'). Windows: schtasks schedule (e.g. '/sc daily /st 02:00').", - }, - command: { - type: "string", - description: "Command to execute (required for add).", - }, - confirm: { - type: "boolean", - description: "Confirm destructive action (required true for add/remove).", - }, - }, - required: ["action"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return { toolCallId: "", output: "Cron operation aborted", isError: true }; - } - - const action = args.action as string; - const isWindows = process.platform === "win32"; - - switch (action) { - case "list": - return isWindows ? this.listWindows() : this.listUnix(); - case "get": - return isWindows ? this.getWindows(args.name as string) : this.getUnix(args.name as string); - case "add": - return this.addTask(args, isWindows); - case "remove": - return this.removeTask(args, isWindows); - default: - return { - toolCallId: "", - output: `Unknown action: ${action}. Use: list, get, add, remove.`, - isError: true, - }; - } - } - - private async listUnix(): Promise { - const result = await runCommand("crontab", ["-l"]); - - if (result.code !== 0) { - if (result.stderr.includes("no crontab")) { - return { - toolCallId: "", - output: "No crontab configured for current user.", - isError: false, - }; - } - return { - toolCallId: "", - output: `Failed to list crontab: ${result.stderr.trim()}`, - isError: true, - }; - } - - const lines = result.stdout.trim().split("\n"); - const jobs: string[] = []; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - if (trimmed.startsWith("#")) { - jobs.push(trimmed); - continue; - } - jobs.push(trimmed); - } - - if (jobs.length === 0) { - return { toolCallId: "", output: "Crontab is empty.", isError: false }; - } - - return { - toolCallId: "", - output: `Crontab entries:\n\n${jobs.join("\n")}`, - isError: false, - metadata: { count: jobs.length }, - }; - } - - private async listWindows(): Promise { - const result = await runCommand("schtasks.exe", ["/query", "/fo", "TABLE", "/nh"]); - - if (result.code !== 0) { - return { - toolCallId: "", - output: `Failed to list scheduled tasks: ${result.stderr.trim()}`, - isError: true, - }; - } - - const output = result.stdout.trim(); - if (!output) { - return { toolCallId: "", output: "No scheduled tasks found.", isError: false }; - } - - return { toolCallId: "", output: `Scheduled tasks:\n\n${output}`, isError: false }; - } - - private async getUnix(name: string | undefined): Promise { - if (!name) { - return { toolCallId: "", output: "Error: name is required for get.", isError: true }; - } - - const result = await runCommand("crontab", ["-l"]); - if (result.code !== 0) { - return { - toolCallId: "", - output: `Failed to read crontab: ${result.stderr.trim()}`, - isError: true, - }; - } - - const lines = result.stdout.split("\n"); - const matching: string[] = []; - - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(name)) { - matching.push(lines[i].trim()); - } - } - - if (matching.length === 0) { - return { toolCallId: "", output: `No crontab entry matching "${name}".`, isError: false }; - } - - return { - toolCallId: "", - output: `Entries matching "${name}":\n\n${matching.join("\n")}`, - isError: false, - }; - } - - private async getWindows(name: string | undefined): Promise { - if (!name) { - return { toolCallId: "", output: "Error: name is required for get.", isError: true }; - } - - const result = await runCommand("schtasks.exe", ["/query", "/tn", name, "/v", "/fo", "LIST"]); - - if (result.code !== 0) { - return { - toolCallId: "", - output: `Task "${name}" not found: ${result.stderr.trim()}`, - isError: true, - }; - } - - return { toolCallId: "", output: result.stdout.trim(), isError: false }; - } - - private async addTask(args: Record, isWindows: boolean): Promise { - const name = (args.name as string)?.trim(); - const schedule = (args.schedule as string)?.trim(); - const command = (args.command as string)?.trim(); - const confirm = args.confirm === true; - - if (!name) { - return { toolCallId: "", output: "Error: name is required for add.", isError: true }; - } - if (!schedule) { - return { toolCallId: "", output: "Error: schedule is required for add.", isError: true }; - } - if (!command) { - return { toolCallId: "", output: "Error: command is required for add.", isError: true }; - } - if (!confirm) { - return { - toolCallId: "", - output: `This will add a scheduled task:\n Name: ${name}\n Schedule: ${schedule}\n Command: ${command}\n\nSet confirm=true to proceed.`, - isError: false, - }; - } - - if (isWindows) { - const ALLOWED_SCHTASKS_FLAGS = new Set([ - "/sc", - "/st", - "/sd", - "/ed", - "/mo", - "/d", - "/m", - "/ri", - ]); - const scheduleParts = schedule.split(/\s+/); - for (const part of scheduleParts) { - if (part.startsWith("/") && !ALLOWED_SCHTASKS_FLAGS.has(part.toLowerCase())) { - return { - toolCallId: "", - output: `Blocked: schedule flag "${part}" is not allowed. Allowed flags: ${[...ALLOWED_SCHTASKS_FLAGS].join(", ")}`, - isError: true, - }; - } - } - const schtasksArgs = ["/create", "/tn", name, "/tr", command]; - schtasksArgs.push(...scheduleParts); - schtasksArgs.push("/f"); - - const result = await runCommand("schtasks.exe", schtasksArgs); - if (result.code !== 0) { - return { - toolCallId: "", - output: `Failed to create task: ${(result.stdout + result.stderr).trim()}`, - isError: true, - }; - } - return { toolCallId: "", output: `Task "${name}" created successfully.`, isError: false }; - } - - // Unix: append to crontab - const cronLine = `${schedule} ${command} # ${name}`; - const existing = await runCommand("crontab", ["-l"]); - const currentCrontab = existing.code === 0 ? existing.stdout : ""; - const newCrontab = currentCrontab.trimEnd() + "\n" + cronLine + "\n"; - - const result = await writeCrontab(newCrontab); - if (result.code !== 0) { - return { - toolCallId: "", - output: `Failed to add cron job: ${result.stderr.trim()}`, - isError: true, - }; - } - - return { toolCallId: "", output: `Cron job "${name}" added:\n ${cronLine}`, isError: false }; - } - - private async removeTask(args: Record, isWindows: boolean): Promise { - const name = (args.name as string)?.trim(); - const confirm = args.confirm === true; - - if (!name) { - return { toolCallId: "", output: "Error: name is required for remove.", isError: true }; - } - if (!confirm) { - return { - toolCallId: "", - output: `This will remove the scheduled task "${name}". Set confirm=true to proceed.`, - isError: false, - }; - } - - if (isWindows) { - const result = await runCommand("schtasks.exe", ["/delete", "/tn", name, "/f"]); - if (result.code !== 0) { - return { - toolCallId: "", - output: `Failed to remove task: ${(result.stdout + result.stderr).trim()}`, - isError: true, - }; - } - return { toolCallId: "", output: `Task "${name}" removed.`, isError: false }; - } - - // Unix: remove matching lines from crontab - const existing = await runCommand("crontab", ["-l"]); - if (existing.code !== 0) { - return { - toolCallId: "", - output: `No crontab to modify: ${existing.stderr.trim()}`, - isError: true, - }; - } - - const lines = existing.stdout.split("\n"); - const commentTag = `# ${name}`; - const filtered = lines.filter((line) => !line.endsWith(commentTag)); - - if (filtered.length === lines.length) { - return { toolCallId: "", output: `No crontab entry matching "${name}".`, isError: false }; - } - - const newCrontab = filtered.join("\n") + "\n"; - const result = await writeCrontab(newCrontab); - if (result.code !== 0) { - return { - toolCallId: "", - output: `Failed to update crontab: ${result.stderr.trim()}`, - isError: true, - }; - } - - return { toolCallId: "", output: `Cron job "${name}" removed.`, isError: false }; - } -} diff --git a/src/tools/env.ts b/src/tools/env.ts deleted file mode 100644 index edc9ab5..0000000 --- a/src/tools/env.ts +++ /dev/null @@ -1,267 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const SECRET_PATTERNS = [ - /secret/i, - /key/i, - /token/i, - /password/i, - /passwd/i, - /credential/i, - /auth/i, - /private/i, - /api_key/i, - /apikey/i, - /access/i, - /jwt/i, - /encrypt/i, - /signing/i, - /certificate/i, -]; - -function isSensitive(name: string): boolean { - return SECRET_PATTERNS.some((p) => p.test(name)); -} - -function maskValue(name: string, value: string, unmask: boolean): string { - if (unmask) { - return value; - } - if (isSensitive(name)) { - if (value.length <= 4) { - return "****"; - } - return value.substring(0, 2) + "****" + value.substring(value.length - 2); - } - return value; -} - -export class EnvTool implements Tool { - name = "env"; - description = - "Read environment variables and .env files. Actions: list (all vars), get (single var), " + - "dotenv (read .env file), check (verify multiple vars exist). " + - "Sensitive values (keys, tokens, passwords) are masked by default — set unmask=true to reveal."; - - private defaultCwd: string; - - constructor(opts?: { cwd?: string }) { - this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd(); - } - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - action: { - type: "string", - description: "Action to perform.", - enum: ["list", "get", "dotenv", "check"], - }, - name: { - type: "string", - description: - "Variable name (for action=get) or comma-separated names (for action=check).", - }, - file: { - type: "string", - description: "Path to .env file (for action=dotenv). Defaults to .env in project root.", - }, - filter: { - type: "string", - description: - "Filter env vars by prefix or substring (for action=list). e.g. 'NODE', 'DATABASE'.", - }, - unmask: { - type: "boolean", - description: "Show actual values of sensitive variables (default false).", - }, - }, - required: ["action"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return { toolCallId: "", output: "Env operation aborted", isError: true }; - } - - const action = args.action as string; - const unmask = args.unmask === true; - - switch (action) { - case "list": - return this.actionList(args.filter as string | undefined, unmask); - case "get": - return this.actionGet(args.name as string | undefined, unmask); - case "dotenv": - return this.actionDotenv(args.file as string | undefined, unmask); - case "check": - return this.actionCheck(args.name as string | undefined); - default: - return { - toolCallId: "", - output: `Unknown action: ${action}. Use: list, get, dotenv, check.`, - isError: true, - }; - } - } - - private actionList(filter: string | undefined, unmask: boolean): ToolResult { - const env = process.env; - let entries = Object.entries(env).filter(([, v]) => v !== undefined) as [string, string][]; - - if (filter) { - const upper = filter.toUpperCase(); - entries = entries.filter(([k]) => k.toUpperCase().includes(upper)); - } - - entries.sort(([a], [b]) => a.localeCompare(b)); - - if (entries.length === 0) { - return { - toolCallId: "", - output: filter ? `No env vars matching "${filter}".` : "No environment variables found.", - isError: false, - }; - } - - const lines = entries.map(([k, v]) => `${k}=${maskValue(k, v, unmask)}`); - - // Cap at 200 entries to protect context - const capped = lines.length > 200; - const output = (capped ? lines.slice(0, 200) : lines).join("\n"); - - return { - toolCallId: "", - output: `${entries.length} variable(s)${filter ? ` matching "${filter}"` : ""}:\n\n${output}${capped ? "\n\n(showing first 200)" : ""}`, - isError: false, - metadata: { count: entries.length }, - }; - } - - private actionGet(name: string | undefined, unmask: boolean): ToolResult { - if (!name) { - return { toolCallId: "", output: "Error: name is required for action=get.", isError: true }; - } - - const value = process.env[name]; - if (value === undefined) { - return { - toolCallId: "", - output: `${name} is not set.`, - isError: false, - metadata: { exists: false }, - }; - } - - return { - toolCallId: "", - output: `${name}=${maskValue(name, value, unmask)}`, - isError: false, - metadata: { exists: true }, - }; - } - - private actionDotenv(file: string | undefined, unmask: boolean): ToolResult { - const envFile = file || path.join(this.defaultCwd, ".env"); - - if (!fs.existsSync(envFile)) { - // Try common alternatives - const alternatives = [".env.local", ".env.development", ".env.example"]; - const found: string[] = []; - for (const alt of alternatives) { - const altPath = path.join(this.defaultCwd, alt); - if (fs.existsSync(altPath)) { - found.push(alt); - } - } - - let msg = `File not found: ${envFile}`; - if (found.length > 0) { - msg += `\n\nAvailable .env files: ${found.join(", ")}`; - } - return { toolCallId: "", output: msg, isError: true }; - } - - let content: string; - try { - content = fs.readFileSync(envFile, "utf-8"); - } catch (err) { - return { - toolCallId: "", - output: `Cannot read ${envFile}: ${err instanceof Error ? err.message : "permission denied"}`, - isError: true, - }; - } - - const lines = content.split("\n"); - const result: string[] = []; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) { - result.push(line); - continue; - } - - const eqIndex = trimmed.indexOf("="); - if (eqIndex === -1) { - result.push(line); - continue; - } - - const key = trimmed.substring(0, eqIndex).trim(); - const val = trimmed - .substring(eqIndex + 1) - .trim() - .replace(/^["']|["']$/g, ""); - result.push(`${key}=${maskValue(key, val, unmask)}`); - } - - return { - toolCallId: "", - output: `${path.basename(envFile)}:\n\n${result.join("\n")}`, - isError: false, - }; - } - - private actionCheck(names: string | undefined): ToolResult { - if (!names) { - return { - toolCallId: "", - output: "Error: name is required for action=check (comma-separated).", - isError: true, - }; - } - - const varNames = names - .split(",") - .map((n) => n.trim()) - .filter(Boolean); - const results: string[] = []; - let allSet = true; - - for (const name of varNames) { - const value = process.env[name]; - if (value !== undefined && value !== "") { - results.push(` ${name}: set`); - } else { - results.push(` ${name}: NOT SET`); - allSet = false; - } - } - - return { - toolCallId: "", - output: `${allSet ? "All variables are set" : "Some variables are missing"}:\n\n${results.join("\n")}`, - isError: false, - metadata: { allSet }, - }; - } -} diff --git a/src/tools/git.ts b/src/tools/git.ts deleted file mode 100644 index 672941e..0000000 --- a/src/tools/git.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { execFile } from "child_process"; -import { killProcessTree } from "../shared/process-kill"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const BLOCKED_PATTERNS = [ - /push\s+.*--force/i, - /push\s+.*-f\b/i, - /config\s+.*credential/i, - /config\s+.*user\.(name|email)/i, -]; - -const MAX_OUTPUT = 50_000; - -function runGit( - args: string[], - cwd: string, - signal?: AbortSignal, -): Promise<{ stdout: string; stderr: string; code: number | null }> { - return new Promise((resolve) => { - const proc = execFile( - "git", - args, - { cwd, maxBuffer: 1024 * 1024, timeout: 30_000 }, - (err, stdout, stderr) => { - resolve({ - stdout: stdout?.toString() ?? "", - stderr: stderr?.toString() ?? "", - code: err ? ((err as { status?: number }).status ?? 1) : 0, - }); - }, - ); - - if (signal) { - const handler = () => { - killProcessTree(proc); - }; - signal.addEventListener("abort", handler, { once: true }); - proc.on("exit", () => signal.removeEventListener("abort", handler)); - } - }); -} - -function truncate(text: string): string { - if (text.length > MAX_OUTPUT) { - return text.slice(0, MAX_OUTPUT) + "\n\n(output truncated)"; - } - return text; -} - -export class GitTool implements Tool { - name = "git"; - description = - "Run git operations on the local repository. Actions: status, diff, log, branch, commit, stash, checkout, add, reset, remote, show, blame. " + - "Destructive operations (push --force, reset --hard) are blocked unless force=true."; - - private defaultCwd: string; - - constructor(opts?: { cwd?: string }) { - this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd(); - } - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - action: { - type: "string", - description: "Git action to perform.", - enum: [ - "status", - "diff", - "log", - "branch", - "commit", - "stash", - "checkout", - "add", - "reset", - "remote", - "show", - "blame", - "tag", - "pull", - "push", - "fetch", - "merge", - "rebase", - "cherry-pick", - ], - }, - args: { - type: "string", - description: - "Additional arguments (e.g. branch name, file path, --oneline, -n 10). Passed directly to git.", - }, - message: { - type: "string", - description: "Commit message (used with action=commit).", - }, - workdir: { - type: "string", - description: "Working directory. Defaults to project root.", - }, - force: { - type: "boolean", - description: - "Allow destructive operations like push --force or reset --hard. Default false.", - }, - }, - required: ["action"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return { toolCallId: "", output: "Git operation aborted", isError: true }; - } - - const action = args.action as string; - const extraArgs = (args.args as string)?.trim() || ""; - const message = (args.message as string)?.trim() || ""; - const workdir = (args.workdir as string)?.trim() || this.defaultCwd; - const force = args.force === true; - - const fullCommand = `${action} ${extraArgs}`.trim(); - if (!force) { - for (const pattern of BLOCKED_PATTERNS) { - if (pattern.test(fullCommand)) { - return { - toolCallId: "", - output: `Blocked: "${fullCommand}" is a destructive operation. Set force=true to proceed.`, - isError: true, - }; - } - } - - if (action === "reset" && extraArgs.includes("--hard")) { - return { - toolCallId: "", - output: `Blocked: "reset --hard" will discard changes. Set force=true to proceed.`, - isError: true, - }; - } - } - - let gitArgs: string[]; - - switch (action) { - case "status": - gitArgs = ["status", "--short", "--branch"]; - if (extraArgs) { - gitArgs.push(...extraArgs.split(/\s+/)); - } - break; - - case "diff": - gitArgs = ["diff"]; - if (extraArgs) { - gitArgs.push(...extraArgs.split(/\s+/)); - } else { - gitArgs.push("--stat"); - } - break; - - case "log": - gitArgs = ["log", "--oneline"]; - if (extraArgs) { - gitArgs.push(...extraArgs.split(/\s+/)); - } else { - gitArgs.push("-20"); - } - break; - - case "commit": - if (!message) { - return { - toolCallId: "", - output: "Error: commit requires a message parameter.", - isError: true, - }; - } - gitArgs = ["commit", "-m", message]; - if (extraArgs) { - gitArgs.push(...extraArgs.split(/\s+/)); - } - break; - - case "branch": - gitArgs = ["branch"]; - if (extraArgs) { - gitArgs.push(...extraArgs.split(/\s+/)); - } else { - gitArgs.push("-a"); - } - break; - - default: - gitArgs = [action]; - if (extraArgs) { - gitArgs.push(...extraArgs.split(/\s+/)); - } - break; - } - - const result = await runGit(gitArgs, workdir, signal); - const output = (result.stdout + (result.stderr ? "\n" + result.stderr : "")).trim(); - - return { - toolCallId: "", - output: truncate(output || "(no output)"), - isError: result.code !== 0 && result.code !== null, - metadata: { exitCode: result.code, action }, - }; - } -} diff --git a/src/tools/http.ts b/src/tools/http.ts deleted file mode 100644 index 94d1bec..0000000 --- a/src/tools/http.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const DEFAULT_TIMEOUT_MS = 30_000; -const MAX_BODY_SIZE = 50_000; - -const BLOCKED_HOSTS = new Set(["169.254.169.254", "metadata.google.internal", "metadata.internal"]); - -function isBlockedHost(hostname: string): boolean { - if (BLOCKED_HOSTS.has(hostname)) { - return true; - } - - const lower = hostname.toLowerCase(); - - // Block localhost variants - if ( - lower === "localhost" || - lower === "127.0.0.1" || - lower === "[::1]" || - lower === "0.0.0.0" || - lower === "[::ffff:127.0.0.1]" - ) { - return true; - } - - // Block link-local / metadata IP ranges - if (lower.startsWith("169.254.") || lower.includes("169.254.169.254")) { - return true; - } - - // Block IPv6-mapped metadata - if (lower.includes("::ffff:169.254.") || lower.includes("::ffff:a9fe")) { - return true; - } - - // Block cloud metadata hostnames via subdomain - if (lower.endsWith(".internal") || lower.includes("metadata")) { - return true; - } - - return false; -} - -export class HttpTool implements Tool { - name = "http"; - description = - "Make HTTP requests to test APIs, check endpoints, or fetch data. " + - "Supports GET, POST, PUT, DELETE, PATCH, HEAD methods. " + - "Response body is truncated to 50KB. Cloud metadata endpoints are blocked for security."; - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - url: { - type: "string", - description: "The URL to request (e.g. http://localhost:3000/api/users).", - }, - method: { - type: "string", - description: "HTTP method.", - enum: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], - }, - headers: { - type: "object", - description: - 'Request headers as key-value pairs (e.g. {"Authorization": "Bearer ...", "Content-Type": "application/json"}).', - }, - body: { - type: "string", - description: - "Request body (for POST/PUT/PATCH). Send as string — use JSON.stringify for JSON payloads.", - }, - timeout: { - type: "number", - description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}).`, - }, - }, - required: ["url"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return { toolCallId: "", output: "HTTP request aborted", isError: true }; - } - - const url = (args.url as string)?.trim(); - if (!url) { - return { toolCallId: "", output: "Error: url is required.", isError: true }; - } - - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch { - return { toolCallId: "", output: `Error: invalid URL: ${url}`, isError: true }; - } - - if (isBlockedHost(parsedUrl.hostname)) { - return { - toolCallId: "", - output: `Blocked: requests to ${parsedUrl.hostname} are not allowed (cloud metadata security).`, - isError: true, - }; - } - - const method = ((args.method as string) || "GET").toUpperCase(); - const headers = (args.headers as Record) || {}; - const body = args.body as string | undefined; - const timeoutMs = typeof args.timeout === "number" ? args.timeout : DEFAULT_TIMEOUT_MS; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - if (signal) { - signal.addEventListener("abort", () => controller.abort(), { once: true }); - } - - try { - const startTime = Date.now(); - - const fetchOpts: RequestInit = { - method, - headers, - signal: controller.signal, - redirect: "manual", - }; - - if (body && !["GET", "HEAD", "OPTIONS"].includes(method)) { - fetchOpts.body = body; - } - - const response = await fetch(url, fetchOpts); - const elapsed = Date.now() - startTime; - - const responseHeaders: Record = {}; - const importantHeaders = [ - "content-type", - "content-length", - "server", - "x-request-id", - "location", - "set-cookie", - "cache-control", - ]; - for (const key of importantHeaders) { - const val = response.headers.get(key); - if (val) { - responseHeaders[key] = val; - } - } - - let responseBody = ""; - if (method !== "HEAD") { - try { - const text = await response.text(); - responseBody = - text.length > MAX_BODY_SIZE - ? text.substring(0, MAX_BODY_SIZE) + "\n\n(body truncated)" - : text; - } catch { - responseBody = "(could not read response body)"; - } - } - - const headerLines = Object.entries(responseHeaders) - .map(([k, v]) => ` ${k}: ${v}`) - .join("\n"); - - const output = [ - `${response.status} ${response.statusText} (${elapsed}ms)`, - headerLines ? `\nHeaders:\n${headerLines}` : "", - responseBody ? `\nBody:\n${responseBody}` : "", - ] - .filter(Boolean) - .join("\n"); - - return { - toolCallId: "", - output, - isError: response.status >= 400, - metadata: { - status: response.status, - statusText: response.statusText, - elapsed, - method, - }, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes("abort")) { - return { toolCallId: "", output: `Request timed out after ${timeoutMs}ms.`, isError: true }; - } - return { toolCallId: "", output: `HTTP request failed: ${message}`, isError: true }; - } finally { - clearTimeout(timeoutId); - } - } -} diff --git a/src/tools/network.ts b/src/tools/network.ts deleted file mode 100644 index a1c9553..0000000 --- a/src/tools/network.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { execFile } from "child_process"; -import dns from "dns"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const PING_TIMEOUT = 10_000; -const REACHABLE_TIMEOUT = 10_000; - -function runCommand( - cmd: string, - args: string[], - timeout: number = PING_TIMEOUT, -): Promise<{ stdout: string; stderr: string; code: number | null }> { - return new Promise((resolve) => { - execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => { - resolve({ - stdout: stdout?.toString() ?? "", - stderr: stderr?.toString() ?? "", - code: err ? ((err as { code?: number }).code ?? 1) : 0, - }); - }); - }); -} - -export class NetworkTool implements Tool { - name = "network"; - description = - "Network diagnostics. Actions: ping (ICMP ping a host), dns (DNS lookup), " + - "reachable (check if URL/host responds), ports (list listening ports or check a specific port). " + - "Works on Windows, macOS, and Linux."; - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - action: { - type: "string", - description: "Network action to perform.", - enum: ["ping", "dns", "reachable", "ports"], - }, - target: { - type: "string", - description: "Hostname, IP, or URL (for ping, dns, reachable).", - }, - port: { - type: "number", - description: "Specific port to check (for action=ports).", - }, - count: { - type: "number", - description: "Number of ping packets (default 4).", - }, - }, - required: ["action"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return { toolCallId: "", output: "Network operation aborted", isError: true }; - } - - const action = args.action as string; - const target = (args.target as string)?.trim() || ""; - const port = args.port as number | undefined; - const count = typeof args.count === "number" ? Math.min(args.count, 20) : 4; - - switch (action) { - case "ping": - return this.actionPing(target, count); - case "dns": - return this.actionDns(target); - case "reachable": - return this.actionReachable(target); - case "ports": - return this.actionPorts(port); - default: - return { - toolCallId: "", - output: `Unknown action: ${action}. Use: ping, dns, reachable, ports.`, - isError: true, - }; - } - } - - private async actionPing(target: string, count: number): Promise { - if (!target) { - return { toolCallId: "", output: "Error: target is required for ping.", isError: true }; - } - - const isWindows = process.platform === "win32"; - const isMac = process.platform === "darwin"; - const timeoutVal = isMac ? "5000" : "5"; - const pingArgs = isWindows - ? ["-n", String(count), target] - : ["-c", String(count), "-W", timeoutVal, target]; - - const result = await runCommand("ping", pingArgs, PING_TIMEOUT + count * 2000); - const output = (result.stdout + result.stderr).trim(); - - return { - toolCallId: "", - output: output || `Ping to ${target} completed (exit code ${result.code}).`, - isError: result.code !== 0, - metadata: { target, exitCode: result.code }, - }; - } - - private async actionDns(target: string): Promise { - if (!target) { - return { toolCallId: "", output: "Error: target is required for dns.", isError: true }; - } - - // Strip protocol if URL was passed - const hostname = target.replace(/^https?:\/\//, "").replace(/[:/].*$/, ""); - - try { - const resolver = new dns.promises.Resolver(); - resolver.setServers(["8.8.8.8", "1.1.1.1"]); - - const results: string[] = [`DNS lookup for ${hostname}:\n`]; - - try { - const a = await resolver.resolve4(hostname); - results.push(`A records: ${a.join(", ")}`); - } catch {} - - try { - const aaaa = await resolver.resolve6(hostname); - results.push(`AAAA records: ${aaaa.join(", ")}`); - } catch {} - - try { - const mx = await resolver.resolveMx(hostname); - const mxStr = mx.map((r) => `${r.exchange} (priority ${r.priority})`).join(", "); - results.push(`MX records: ${mxStr}`); - } catch {} - - try { - const cname = await resolver.resolveCname(hostname); - results.push(`CNAME: ${cname.join(", ")}`); - } catch {} - - try { - const ns = await resolver.resolveNs(hostname); - results.push(`NS records: ${ns.join(", ")}`); - } catch {} - - try { - const txt = await resolver.resolveTxt(hostname); - const txtStr = txt.map((t) => t.join("")).slice(0, 5); - if (txtStr.length > 0) { - results.push(`TXT records: ${txtStr.join("; ")}`); - } - } catch {} - - return { - toolCallId: "", - output: results.length > 1 ? results.join("\n") : `No DNS records found for ${hostname}.`, - isError: results.length <= 1, - }; - } catch (err) { - return { - toolCallId: "", - output: `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - } - - private async actionReachable(target: string): Promise { - if (!target) { - return { toolCallId: "", output: "Error: target is required for reachable.", isError: true }; - } - - let url = target; - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = `https://${url}`; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REACHABLE_TIMEOUT); - - try { - const start = Date.now(); - const response = await fetch(url, { - method: "HEAD", - signal: controller.signal, - redirect: "follow", - }); - const elapsed = Date.now() - start; - - return { - toolCallId: "", - output: `${target} is reachable.\n Status: ${response.status} ${response.statusText}\n Response time: ${elapsed}ms`, - isError: false, - metadata: { reachable: true, status: response.status, elapsed }, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes("abort")) { - return { - toolCallId: "", - output: `${target} is not reachable (timed out after ${REACHABLE_TIMEOUT}ms).`, - isError: false, - metadata: { reachable: false }, - }; - } - return { - toolCallId: "", - output: `${target} is not reachable: ${message}`, - isError: false, - metadata: { reachable: false }, - }; - } finally { - clearTimeout(timeoutId); - } - } - - private async actionPorts(port?: number): Promise { - const isWindows = process.platform === "win32"; - const isMac = process.platform === "darwin"; - - let result: { stdout: string; stderr: string; code: number | null }; - - if (isWindows) { - if (port) { - result = await runCommand("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-Command", - `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ` + - `Select-Object LocalAddress, LocalPort, OwningProcess | Format-Table -AutoSize`, - ]); - } else { - result = await runCommand("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-Command", - `Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ` + - `Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize`, - ]); - } - } else if (isMac) { - if (port) { - result = await runCommand("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-P", "-n"]); - } else { - result = await runCommand("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n"]); - } - } else { - // Linux - if (port) { - result = await runCommand("ss", ["-tlnp", "sport", "=", String(port)]); - } else { - result = await runCommand("ss", ["-tlnp"]); - } - } - - const output = (result.stdout + result.stderr).trim(); - - if (!output || result.code !== 0) { - if (port) { - return { toolCallId: "", output: `No process listening on port ${port}.`, isError: false }; - } - return { - toolCallId: "", - output: output || "Could not retrieve listening ports.", - isError: result.code !== 0, - }; - } - - return { - toolCallId: "", - output: port ? `Port ${port}:\n\n${output}` : `Listening ports:\n\n${output}`, - isError: false, - }; - } -} diff --git a/src/tools/process-registry.ts b/src/tools/process-registry.ts deleted file mode 100644 index 43100e5..0000000 --- a/src/tools/process-registry.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { ChildProcessWithoutNullStreams } from "child_process"; -import crypto from "crypto"; - -const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000; -const DEFAULT_MAX_OUTPUT_CHARS = 200_000; -const DEFAULT_PENDING_MAX_OUTPUT_CHARS = 30_000; -const TAIL_CHARS = 2000; - -export type ProcessStatus = "running" | "completed" | "failed" | "killed"; - -export interface ProcessSession { - id: string; - command: string; - child?: ChildProcessWithoutNullStreams; - pid?: number; - startedAt: number; - cwd?: string; - maxOutputChars: number; - pendingMaxOutputChars: number; - totalOutputChars: number; - pendingStdout: string[]; - pendingStderr: string[]; - pendingStdoutChars: number; - pendingStderrChars: number; - aggregated: string; - tail: string; - exitCode?: number | null; - exitSignal?: string | null; - exited: boolean; - truncated: boolean; - backgrounded: boolean; -} - -export interface FinishedSession { - id: string; - command: string; - startedAt: number; - endedAt: number; - cwd?: string; - status: ProcessStatus; - exitCode?: number | null; - exitSignal?: string | null; - aggregated: string; - tail: string; - truncated: boolean; - totalOutputChars: number; -} - -const runningSessions = new Map(); -const finishedSessions = new Map(); -let sweeper: ReturnType | null = null; - -export function createSessionId(): string { - return crypto.randomUUID().slice(0, 8); -} - -export function createSession(opts: { - command: string; - cwd?: string; - maxOutputChars?: number; - pendingMaxOutputChars?: number; -}): ProcessSession { - const id = createSessionId(); - const session: ProcessSession = { - id, - command: opts.command, - startedAt: Date.now(), - cwd: opts.cwd, - maxOutputChars: opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS, - pendingMaxOutputChars: opts.pendingMaxOutputChars ?? DEFAULT_PENDING_MAX_OUTPUT_CHARS, - totalOutputChars: 0, - pendingStdout: [], - pendingStderr: [], - pendingStdoutChars: 0, - pendingStderrChars: 0, - aggregated: "", - tail: "", - exited: false, - truncated: false, - backgrounded: false, - }; - runningSessions.set(id, session); - startSweeper(); - return session; -} - -export function getSession(id: string): ProcessSession | undefined { - return runningSessions.get(id); -} - -export function getFinishedSession(id: string): FinishedSession | undefined { - return finishedSessions.get(id); -} - -export function deleteSession(id: string): void { - runningSessions.delete(id); - finishedSessions.delete(id); -} - -export function appendOutput( - session: ProcessSession, - stream: "stdout" | "stderr", - chunk: string, -): void { - const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr; - const bufferCharsKey = stream === "stdout" ? "pendingStdoutChars" : "pendingStderrChars"; - const pendingCap = Math.min(session.pendingMaxOutputChars, session.maxOutputChars); - - buffer.push(chunk); - let pendingChars = session[bufferCharsKey] + chunk.length; - - if (pendingChars > pendingCap) { - session.truncated = true; - pendingChars = capBuffer(buffer, pendingChars, pendingCap); - } - session[bufferCharsKey] = pendingChars; - session.totalOutputChars += chunk.length; - - const newAggregated = session.aggregated + chunk; - if (newAggregated.length > session.maxOutputChars) { - session.truncated = true; - session.aggregated = newAggregated.slice(newAggregated.length - session.maxOutputChars); - } else { - session.aggregated = newAggregated; - } - session.tail = tail(session.aggregated, TAIL_CHARS); -} - -export function drainSession(session: ProcessSession): { stdout: string; stderr: string } { - const stdout = session.pendingStdout.join(""); - const stderr = session.pendingStderr.join(""); - session.pendingStdout = []; - session.pendingStderr = []; - session.pendingStdoutChars = 0; - session.pendingStderrChars = 0; - return { stdout, stderr }; -} - -export function markExited( - session: ProcessSession, - exitCode: number | null, - exitSignal: string | null, - status: ProcessStatus, -): void { - session.exited = true; - session.exitCode = exitCode; - session.exitSignal = exitSignal; - session.tail = tail(session.aggregated, TAIL_CHARS); - - cleanupChild(session); - runningSessions.delete(session.id); - - if (session.backgrounded) { - finishedSessions.set(session.id, { - id: session.id, - command: session.command, - startedAt: session.startedAt, - endedAt: Date.now(), - cwd: session.cwd, - status, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - aggregated: session.aggregated, - tail: session.tail, - truncated: session.truncated, - totalOutputChars: session.totalOutputChars, - }); - } -} - -export function markBackgrounded(session: ProcessSession): void { - session.backgrounded = true; -} - -export function listRunningSessions(): ProcessSession[] { - return Array.from(runningSessions.values()).filter((s) => s.backgrounded); -} - -export function listFinishedSessions(): FinishedSession[] { - return Array.from(finishedSessions.values()); -} - -export function clearFinished(): void { - finishedSessions.clear(); -} - -export function tail(text: string, max = TAIL_CHARS): string { - if (text.length <= max) { - return text; - } - return text.slice(text.length - max); -} - -function capBuffer(buffer: string[], pendingChars: number, cap: number): number { - while (buffer.length > 0 && pendingChars - buffer[0].length >= cap) { - pendingChars -= buffer[0].length; - buffer.shift(); - } - if (buffer.length > 0 && pendingChars > cap) { - const overflow = pendingChars - cap; - buffer[0] = buffer[0].slice(overflow); - pendingChars = cap; - } - return pendingChars; -} - -function cleanupChild(session: ProcessSession): void { - if (session.child) { - session.child.stdin?.destroy?.(); - session.child.stdout?.destroy?.(); - session.child.stderr?.destroy?.(); - session.child.removeAllListeners(); - delete session.child; - } -} - -function pruneFinishedSessions(): void { - const cutoff = Date.now() - DEFAULT_SESSION_TTL_MS; - for (const [id, session] of finishedSessions.entries()) { - if (session.endedAt < cutoff) { - finishedSessions.delete(id); - } - } -} - -function startSweeper(): void { - if (sweeper) { - return; - } - sweeper = setInterval(pruneFinishedSessions, 60_000); - if (sweeper.unref) { - sweeper.unref(); - } -} diff --git a/src/tools/process.ts b/src/tools/process.ts deleted file mode 100644 index 9ba96af..0000000 --- a/src/tools/process.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { killProcessTree, forceKillProcess } from "../shared/process-kill"; -import { - getSession, - getFinishedSession, - deleteSession, - drainSession, - listRunningSessions, - listFinishedSessions, - clearFinished, - tail, -} from "./process-registry"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -export class ProcessTool implements Tool { - name = "process"; - description = - "Manage backgrounded shell commands. Actions: list (show sessions), poll (get latest output), " + - "log (get full output), kill (terminate), send (write to stdin), clear (remove finished), " + - "remove (delete a specific session)."; - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - action: { - type: "string", - description: "Action to perform.", - enum: ["list", "poll", "log", "kill", "send", "clear", "remove"], - }, - session_id: { - type: "string", - description: "Session ID (required for poll, log, kill, send, remove).", - }, - data: { - type: "string", - description: "Data to send to stdin (only for action=send).", - }, - }, - required: ["action"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - const action = args.action as string; - const sessionId = args.session_id as string | undefined; - - if (signal?.aborted) { - return { toolCallId: "", output: "Process tool execution aborted", isError: true }; - } - - switch (action) { - case "list": - return this.actionList(); - case "poll": - return this.actionPoll(sessionId); - case "log": - return this.actionLog(sessionId); - case "kill": - return this.actionKill(sessionId); - case "send": - return this.actionSend(sessionId, args.data as string | undefined); - case "clear": - return this.actionClear(); - case "remove": - return this.actionRemove(sessionId); - default: - return { - toolCallId: "", - output: `Unknown action: ${action}. Use: list, poll, log, kill, send, clear, remove.`, - isError: true, - }; - } - } - - private actionList(): ToolResult { - const running = listRunningSessions(); - const finished = listFinishedSessions(); - - if (running.length === 0 && finished.length === 0) { - return { - toolCallId: "", - output: "No active or finished background sessions.", - isError: false, - }; - } - - const lines: string[] = []; - - if (running.length > 0) { - lines.push(`Running (${running.length}):`); - for (const s of running) { - const elapsed = Math.round((Date.now() - s.startedAt) / 1000); - lines.push( - ` [${s.id}] pid=${s.pid ?? "?"} elapsed=${elapsed}s cmd="${truncCmd(s.command)}"`, - ); - } - } - - if (finished.length > 0) { - lines.push(`Finished (${finished.length}):`); - for (const s of finished) { - const dur = Math.round((s.endedAt - s.startedAt) / 1000); - lines.push( - ` [${s.id}] status=${s.status} exit=${s.exitCode ?? "?"} dur=${dur}s cmd="${truncCmd(s.command)}"`, - ); - } - } - - return { toolCallId: "", output: lines.join("\n"), isError: false }; - } - - private actionPoll(sessionId?: string): ToolResult { - if (!sessionId) { - return { toolCallId: "", output: "Error: session_id is required for poll.", isError: true }; - } - - const running = getSession(sessionId); - if (running) { - const drained = drainSession(running); - const output = [drained.stdout, drained.stderr].filter(Boolean).join("\n"); - const elapsed = Math.round((Date.now() - running.startedAt) / 1000); - const header = `[${running.id}] running, elapsed=${elapsed}s, pid=${running.pid ?? "?"}`; - return { - toolCallId: "", - output: output ? `${header}\n${output}` : `${header}\n(no new output since last poll)`, - isError: false, - metadata: { status: "running", sessionId: running.id }, - }; - } - - const finished = getFinishedSession(sessionId); - if (finished) { - const dur = Math.round((finished.endedAt - finished.startedAt) / 1000); - const header = `[${finished.id}] ${finished.status}, exit=${finished.exitCode ?? "?"}, dur=${dur}s`; - return { - toolCallId: "", - output: `${header}\n${tail(finished.aggregated, 4000) || "(no output)"}`, - isError: finished.status === "failed", - metadata: { - status: finished.status, - exitCode: finished.exitCode, - sessionId: finished.id, - }, - }; - } - - return { toolCallId: "", output: `Session ${sessionId} not found.`, isError: true }; - } - - private actionLog(sessionId?: string): ToolResult { - if (!sessionId) { - return { toolCallId: "", output: "Error: session_id is required for log.", isError: true }; - } - - const running = getSession(sessionId); - if (running) { - const truncNote = running.truncated ? "\n(output was truncated)" : ""; - return { - toolCallId: "", - output: (running.aggregated || "(no output yet)") + truncNote, - isError: false, - metadata: { totalChars: running.totalOutputChars, truncated: running.truncated }, - }; - } - - const finished = getFinishedSession(sessionId); - if (finished) { - return { - toolCallId: "", - output: finished.aggregated || "(no output)", - isError: false, - metadata: { totalChars: finished.totalOutputChars, truncated: finished.truncated }, - }; - } - - return { toolCallId: "", output: `Session ${sessionId} not found.`, isError: true }; - } - - private actionKill(sessionId?: string): ToolResult { - if (!sessionId) { - return { toolCallId: "", output: "Error: session_id is required for kill.", isError: true }; - } - - const running = getSession(sessionId); - if (!running) { - return { - toolCallId: "", - output: `Session ${sessionId} not found or already exited.`, - isError: true, - }; - } - - if (running.child) { - const childRef = running.child; - try { - killProcessTree(childRef, 3000); - setTimeout(() => { - try { - if (!running.exited && childRef.pid) { - forceKillProcess(childRef); - } - } catch { - // Process may have already exited - } - }, 3000); - } catch { - // Best-effort kill - } - } - - const killMsg = - process.platform === "win32" - ? `Terminating session ${sessionId} (pid ${running.pid ?? "?"}) via taskkill.` - : `Sent SIGTERM to session ${sessionId} (pid ${running.pid ?? "?"}). Will force-kill in 3s if still alive.`; - - return { - toolCallId: "", - output: killMsg, - isError: false, - }; - } - - private actionSend(sessionId?: string, data?: string): ToolResult { - if (!sessionId) { - return { toolCallId: "", output: "Error: session_id is required for send.", isError: true }; - } - if (!data) { - return { toolCallId: "", output: "Error: data is required for send.", isError: true }; - } - - const running = getSession(sessionId); - if (!running) { - return { - toolCallId: "", - output: `Session ${sessionId} not found or already exited.`, - isError: true, - }; - } - if (!running.child || !running.child.stdin) { - return { - toolCallId: "", - output: `Session ${sessionId} has no stdin available.`, - isError: true, - }; - } - - try { - running.child.stdin.write(data); - return { - toolCallId: "", - output: `Sent ${data.length} bytes to session ${sessionId}.`, - isError: false, - }; - } catch (err) { - return { - toolCallId: "", - output: `Failed to write to stdin: ${err instanceof Error ? err.message : "Unknown error"}`, - isError: true, - }; - } - } - - private actionClear(): ToolResult { - const count = listFinishedSessions().length; - clearFinished(); - return { - toolCallId: "", - output: - count > 0 ? `Cleared ${count} finished session(s).` : "No finished sessions to clear.", - isError: false, - }; - } - - private actionRemove(sessionId?: string): ToolResult { - if (!sessionId) { - return { toolCallId: "", output: "Error: session_id is required for remove.", isError: true }; - } - const running = getSession(sessionId); - if (running?.child) { - try { - killProcessTree(running.child, 3000); - } catch { - // Best-effort kill before removal - } - } - deleteSession(sessionId); - return { toolCallId: "", output: `Session ${sessionId} removed.`, isError: false }; - } -} - -function truncCmd(cmd: string, max = 60): string { - if (cmd.length <= max) { - return cmd; - } - return cmd.slice(0, max - 3) + "..."; -} diff --git a/src/tools/registry.ts b/src/tools/registry.ts deleted file mode 100644 index e659a03..0000000 --- a/src/tools/registry.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Tool, ToolCall, ToolDefinition, ToolResult, ParameterProperty } from "./types"; - -export class ToolRegistry { - private tools: Map = new Map(); - - register(tool: Tool): void { - this.tools.set(tool.name, tool); - } - - getDefinitions(): ToolDefinition[] { - return Array.from(this.tools.values()).map((t) => t.getDefinition()); - } - - getDefinitionsForProvider(provider: string): (Record | ToolDefinition)[] { - const defs = this.getDefinitions(); - - switch (provider) { - case "anthropic": - return defs.map((d) => ({ - name: d.name, - description: d.description, - input_schema: toJsonSchema(d.parameters), - })); - - case "openai": - return defs.map((d) => ({ - type: "function", - function: { - name: d.name, - description: d.description, - parameters: toJsonSchema(d.parameters), - }, - })); - - case "gemini": - return [ - { - functionDeclarations: defs.map((d) => ({ - name: d.name, - description: d.description, - parameters: toGeminiSchema(d.parameters), - })), - }, - ]; - - default: - return defs; - } - } - - async execute( - name: string, - args: Record, - signal?: AbortSignal, - ): Promise { - const tool = this.tools.get(name); - if (!tool) { - return { toolCallId: "", output: `Unknown tool: ${name}`, isError: true }; - } - - if (signal?.aborted) { - return { toolCallId: "", output: "Tool execution aborted", isError: true }; - } - - return tool.execute(args, signal); - } - - async executeAll(calls: ToolCall[], signal?: AbortSignal): Promise { - const promises = calls.map(async (call) => { - if (signal?.aborted) { - return { - toolCallId: call.id, - output: "Tool execution aborted", - isError: true, - } as ToolResult; - } - - const result = await this.execute(call.name, call.arguments, signal); - result.toolCallId = call.id; - return result; - }); - - return Promise.all(promises); - } -} - -function toJsonSchema(params: ToolDefinition["parameters"]): Record { - return { - type: "object", - properties: Object.fromEntries( - Object.entries(params.properties).map(([key, prop]) => [key, propertyToJsonSchema(prop)]), - ), - required: params.required, - }; -} - -function propertyToJsonSchema(prop: ParameterProperty): Record { - const schema: Record = { - type: prop.type, - description: prop.description, - }; - if (prop.enum) { - schema.enum = prop.enum; - } - if (prop.items) { - schema.items = { type: prop.items.type }; - } - if (prop.properties) { - schema.properties = Object.fromEntries( - Object.entries(prop.properties).map(([k, v]) => [k, propertyToJsonSchema(v)]), - ); - if (prop.required) { - schema.required = prop.required; - } - } - if (prop.default !== undefined) { - schema.default = prop.default; - } - return schema; -} - -function toGeminiSchema(params: ToolDefinition["parameters"]): Record { - return { - type: "OBJECT", - properties: Object.fromEntries( - Object.entries(params.properties).map(([key, prop]) => [key, propertyToGeminiSchema(prop)]), - ), - required: params.required, - }; -} - -function propertyToGeminiSchema(prop: ParameterProperty): Record { - const schema: Record = { - type: prop.type.toUpperCase(), - description: prop.description, - }; - if (prop.enum) { - schema.enum = prop.enum; - } - if (prop.items) { - schema.items = { type: prop.items.type.toUpperCase() }; - } - if (prop.properties) { - schema.properties = Object.fromEntries( - Object.entries(prop.properties).map(([k, v]) => [k, propertyToGeminiSchema(v)]), - ); - if (prop.required) { - schema.required = prop.required; - } - } - return schema; -} diff --git a/src/tools/search.ts b/src/tools/search.ts deleted file mode 100644 index e6ea8ce..0000000 --- a/src/tools/search.ts +++ /dev/null @@ -1,323 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const SKIP_DIRS = new Set([ - "node_modules", - ".git", - "dist", - "build", - ".next", - ".nuxt", - "__pycache__", - ".cache", - ".venv", - "venv", - "vendor", - "target", - ".gradle", - ".idea", - ".vs", - "coverage", - ".nyc_output", - ".turbo", - ".parcel-cache", -]); - -const MAX_RESULTS = 200; -const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB -const MAX_MATCH_CONTEXT = 200; - -function walkDir( - dir: string, - callback: (filePath: string, stat: fs.Stats) => boolean, - depth: number = 0, - maxDepth: number = 20, -): void { - if (depth > maxDepth) { - return; - } - - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (SKIP_DIRS.has(entry.name)) { - continue; - } - if (entry.name.startsWith(".") && entry.name !== ".env" && depth > 0) { - continue; - } - - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - walkDir(fullPath, callback, depth + 1, maxDepth); - } else if (entry.isFile()) { - try { - const stat = fs.statSync(fullPath); - const shouldStop = callback(fullPath, stat); - if (shouldStop) { - return; - } - } catch {} - } - } -} - -function matchGlob(filename: string, pattern: string): boolean { - const regex = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - return new RegExp(`^${regex}$`, "i").test(filename); -} - -export class SearchTool implements Tool { - name = "search"; - description = - "Search files and file contents in the project. Actions: grep (search content by regex/text), " + - "glob (find files by name pattern), find (find files by extension/size/date). " + - "Auto-skips node_modules, .git, dist, build. Results capped at 200."; - - private defaultCwd: string; - - constructor(opts?: { cwd?: string }) { - this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd(); - } - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - action: { - type: "string", - description: "Search action to perform.", - enum: ["grep", "glob", "find"], - }, - pattern: { - type: "string", - description: - "Search pattern. For grep: regex or text. For glob: file pattern (e.g. *.ts, *.py). For find: file extension (e.g. ts, py).", - }, - path: { - type: "string", - description: "Directory to search in. Defaults to project root.", - }, - case_sensitive: { - type: "boolean", - description: "Case-sensitive search (default false for grep, true for glob/find).", - }, - max_results: { - type: "number", - description: `Maximum results to return (default ${MAX_RESULTS}).`, - }, - include: { - type: "string", - description: - "Only search files matching this glob pattern (e.g. *.ts, *.py). For grep action.", - }, - }, - required: ["action", "pattern"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return { toolCallId: "", output: "Search aborted", isError: true }; - } - - const action = args.action as string; - const pattern = args.pattern as string; - const searchPath = (args.path as string)?.trim() || this.defaultCwd; - const caseSensitive = args.case_sensitive === true; - const maxResults = - typeof args.max_results === "number" ? Math.min(args.max_results, MAX_RESULTS) : MAX_RESULTS; - const include = (args.include as string)?.trim() || ""; - - if (!pattern) { - return { toolCallId: "", output: "Error: pattern is required.", isError: true }; - } - - if (!fs.existsSync(searchPath)) { - return { toolCallId: "", output: `Error: path does not exist: ${searchPath}`, isError: true }; - } - - switch (action) { - case "grep": - return this.actionGrep(searchPath, pattern, caseSensitive, maxResults, include); - case "glob": - return this.actionGlob(searchPath, pattern, maxResults); - case "find": - return this.actionFind(searchPath, pattern, maxResults); - default: - return { - toolCallId: "", - output: `Unknown action: ${action}. Use: grep, glob, find.`, - isError: true, - }; - } - } - - private actionGrep( - searchPath: string, - pattern: string, - caseSensitive: boolean, - maxResults: number, - include: string, - ): ToolResult { - let regex: RegExp; - const hasNestedQuantifiers = - /(\+|\*|\{)\??[^)]*(\+|\*|\{)/.test(pattern) || /\(([^)]*\|){10,}/.test(pattern); - if (hasNestedQuantifiers) { - regex = new RegExp( - pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - caseSensitive ? "g" : "gi", - ); - } else { - try { - regex = new RegExp(pattern, caseSensitive ? "g" : "gi"); - } catch { - regex = new RegExp( - pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - caseSensitive ? "g" : "gi", - ); - } - } - - const matches: string[] = []; - let fileCount = 0; - - walkDir(searchPath, (filePath, stat) => { - if (stat.size > MAX_FILE_SIZE) { - return false; - } - if (include && !matchGlob(path.basename(filePath), include)) { - return false; - } - - let content: string; - try { - content = fs.readFileSync(filePath, "utf-8"); - } catch { - return false; - } - - // Skip binary files - if (content.includes("\0")) { - return false; - } - - const lines = content.split("\n"); - const relativePath = path.relative(searchPath, filePath); - let fileHasMatch = false; - - for (let i = 0; i < lines.length; i++) { - regex.lastIndex = 0; - if (regex.test(lines[i])) { - if (!fileHasMatch) { - fileHasMatch = true; - fileCount++; - } - const lineContent = - lines[i].length > MAX_MATCH_CONTEXT - ? lines[i].substring(0, MAX_MATCH_CONTEXT) + "..." - : lines[i]; - matches.push(`${relativePath}:${i + 1}: ${lineContent.trim()}`); - - if (matches.length >= maxResults) { - return true; - } - } - } - return false; - }); - - if (matches.length === 0) { - return { toolCallId: "", output: `No matches found for "${pattern}".`, isError: false }; - } - - const header = `Found ${matches.length} match(es) in ${fileCount} file(s):\n\n`; - return { - toolCallId: "", - output: header + matches.join("\n"), - isError: false, - metadata: { matchCount: matches.length, fileCount }, - }; - } - - private actionGlob(searchPath: string, pattern: string, maxResults: number): ToolResult { - const results: string[] = []; - - walkDir(searchPath, (filePath) => { - const filename = path.basename(filePath); - if (matchGlob(filename, pattern)) { - results.push(path.relative(searchPath, filePath)); - if (results.length >= maxResults) { - return true; - } - } - return false; - }); - - if (results.length === 0) { - return { toolCallId: "", output: `No files matching "${pattern}".`, isError: false }; - } - - return { - toolCallId: "", - output: `Found ${results.length} file(s):\n\n${results.join("\n")}`, - isError: false, - metadata: { count: results.length }, - }; - } - - private actionFind(searchPath: string, pattern: string, maxResults: number): ToolResult { - const ext = pattern.startsWith(".") ? pattern : `.${pattern}`; - const results: { path: string; size: string; modified: string }[] = []; - - walkDir(searchPath, (filePath, stat) => { - if (filePath.endsWith(ext)) { - results.push({ - path: path.relative(searchPath, filePath), - size: formatSize(stat.size), - modified: stat.mtime.toISOString().split("T")[0], - }); - if (results.length >= maxResults) { - return true; - } - } - return false; - }); - - if (results.length === 0) { - return { toolCallId: "", output: `No files with extension "${ext}".`, isError: false }; - } - - const lines = results.map((r) => `${r.path} (${r.size}, ${r.modified})`); - return { - toolCallId: "", - output: `Found ${results.length} ${ext} file(s):\n\n${lines.join("\n")}`, - isError: false, - metadata: { count: results.length }, - }; - } -} - -function formatSize(bytes: number): string { - if (bytes < 1024) { - return `${bytes}B`; - } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)}KB`; - } - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; -} diff --git a/src/tools/sysinfo.ts b/src/tools/sysinfo.ts deleted file mode 100644 index 91da3ed..0000000 --- a/src/tools/sysinfo.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { execFile } from "child_process"; -import os from "os"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const CMD_TIMEOUT = 10_000; - -function runCommand(cmd: string, args: string[], timeout: number = CMD_TIMEOUT): Promise { - return new Promise((resolve) => { - execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout) => { - resolve(stdout?.toString().trim() ?? ""); - }); - }); -} - -function formatBytes(bytes: number): string { - const gb = bytes / (1024 * 1024 * 1024); - if (gb >= 1) { - return `${gb.toFixed(1)} GB`; - } - const mb = bytes / (1024 * 1024); - return `${mb.toFixed(0)} MB`; -} - -function formatUptime(seconds: number): string { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - const parts: string[] = []; - if (days > 0) { - parts.push(`${days}d`); - } - if (hours > 0) { - parts.push(`${hours}h`); - } - parts.push(`${minutes}m`); - return parts.join(" "); -} - -export class SysinfoTool implements Tool { - name = "sysinfo"; - description = - "System information and resource monitoring. Actions: overview (full summary), cpu, memory, " + - "disk, uptime, processes (top resource consumers). Works on Windows, macOS, and Linux."; - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - action: { - type: "string", - description: "Info to retrieve.", - enum: ["overview", "cpu", "memory", "disk", "uptime", "processes"], - }, - count: { - type: "number", - description: "Number of top processes to show (default 15, for action=processes).", - }, - }, - required: ["action"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return { toolCallId: "", output: "Sysinfo operation aborted", isError: true }; - } - - const action = args.action as string; - const count = typeof args.count === "number" ? Math.min(args.count, 50) : 15; - - switch (action) { - case "overview": - return this.actionOverview(); - case "cpu": - return this.actionCpu(); - case "memory": - return this.actionMemory(); - case "disk": - return this.actionDisk(); - case "uptime": - return this.actionUptime(); - case "processes": - return this.actionProcesses(count); - default: - return { - toolCallId: "", - output: `Unknown action: ${action}. Use: overview, cpu, memory, disk, uptime, processes.`, - isError: true, - }; - } - } - - private async actionOverview(): Promise { - const cpus = os.cpus(); - const totalMem = os.totalmem(); - const freeMem = os.freemem(); - const usedMem = totalMem - freeMem; - const memPercent = ((usedMem / totalMem) * 100).toFixed(1); - - const platformNames: Record = { - win32: "Windows", - darwin: "macOS", - linux: "Linux", - freebsd: "FreeBSD", - }; - - const lines = [ - `System Overview`, - ``, - `Hostname: ${os.hostname()}`, - `Platform: ${platformNames[os.platform()] || os.platform()} (${os.arch()})`, - `OS: ${os.release()}`, - `Uptime: ${formatUptime(os.uptime())}`, - ``, - `CPU: ${cpus[0]?.model || "Unknown"} (${cpus.length} cores)`, - `Memory: ${formatBytes(usedMem)} / ${formatBytes(totalMem)} (${memPercent}% used)`, - `Free mem: ${formatBytes(freeMem)}`, - ``, - `Home dir: ${os.homedir()}`, - `Temp dir: ${os.tmpdir()}`, - `Node.js: ${process.version}`, - ]; - - // Add load average on Unix - if (os.platform() !== "win32") { - const load = os.loadavg(); - lines.push(`Load avg: ${load.map((l) => l.toFixed(2)).join(", ")} (1m, 5m, 15m)`); - } - - return { toolCallId: "", output: lines.join("\n"), isError: false }; - } - - private actionCpu(): ToolResult { - const cpus = os.cpus(); - const lines = [ - `CPU: ${cpus[0]?.model || "Unknown"}`, - `Cores: ${cpus.length}`, - `Architecture: ${os.arch()}`, - ``, - ]; - - for (let i = 0; i < cpus.length; i++) { - const cpu = cpus[i]; - const total = Object.values(cpu.times).reduce((a, b) => a + b, 0); - const idle = cpu.times.idle; - const usage = (((total - idle) / total) * 100).toFixed(1); - lines.push(` Core ${i}: ${cpu.speed}MHz, ${usage}% used`); - } - - if (os.platform() !== "win32") { - const load = os.loadavg(); - lines.push(``); - lines.push(`Load average: ${load.map((l) => l.toFixed(2)).join(", ")} (1m, 5m, 15m)`); - } - - return { toolCallId: "", output: lines.join("\n"), isError: false }; - } - - private actionMemory(): ToolResult { - const totalMem = os.totalmem(); - const freeMem = os.freemem(); - const usedMem = totalMem - freeMem; - const memPercent = ((usedMem / totalMem) * 100).toFixed(1); - - const lines = [ - `Memory`, - ``, - `Total: ${formatBytes(totalMem)}`, - `Used: ${formatBytes(usedMem)} (${memPercent}%)`, - `Free: ${formatBytes(freeMem)}`, - ]; - - // Process memory - const procMem = process.memoryUsage(); - lines.push(``); - lines.push(`Node.js process memory:`); - lines.push(` RSS: ${formatBytes(procMem.rss)}`); - lines.push(` Heap used: ${formatBytes(procMem.heapUsed)}`); - lines.push(` Heap total: ${formatBytes(procMem.heapTotal)}`); - - return { toolCallId: "", output: lines.join("\n"), isError: false }; - } - - private async actionDisk(): Promise { - const isWindows = process.platform === "win32"; - - if (isWindows) { - const output = await runCommand("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-Command", - `Get-PSDrive -PSProvider FileSystem | Select-Object Name, ` + - `@{N='Used(GB)';E={[math]::Round($_.Used/1GB,1)}}, ` + - `@{N='Free(GB)';E={[math]::Round($_.Free/1GB,1)}}, ` + - `@{N='Total(GB)';E={[math]::Round(($_.Used+$_.Free)/1GB,1)}}, ` + - `Root | Format-Table -AutoSize`, - ]); - return { - toolCallId: "", - output: output || "Could not retrieve disk info.", - isError: !output, - }; - } - - const isMac = process.platform === "darwin"; - const dfArgs = isMac - ? ["-h"] - : ["-h", "--type=ext4", "--type=xfs", "--type=btrfs", "--type=apfs", "--type=hfs"]; - - let output = await runCommand("df", dfArgs); - if (!output && !isMac) { - output = await runCommand("df", ["-h"]); - } - - return { - toolCallId: "", - output: output || "Could not retrieve disk info.", - isError: !output, - }; - } - - private actionUptime(): ToolResult { - const uptime = os.uptime(); - const bootTime = new Date(Date.now() - uptime * 1000); - - const lines = [`Uptime: ${formatUptime(uptime)}`, `Boot time: ${bootTime.toISOString()}`]; - - return { toolCallId: "", output: lines.join("\n"), isError: false }; - } - - private async actionProcesses(count: number): Promise { - const isWindows = process.platform === "win32"; - - if (isWindows) { - const output = await runCommand("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-Command", - `Get-Process | Sort-Object CPU -Descending | Select-Object -First ${count} ` + - `Id, ProcessName, ` + - `@{N='CPU(s)';E={[math]::Round($_.CPU,1)}}, ` + - `@{N='Mem(MB)';E={[math]::Round($_.WorkingSet64/1MB,1)}} ` + - `| Format-Table -AutoSize`, - ]); - return { - toolCallId: "", - output: output - ? `Top ${count} processes by CPU:\n\n${output}` - : "Could not retrieve process list.", - isError: !output, - }; - } - - const isMac = process.platform === "darwin"; - const psArgs = isMac ? ["aux", "-r"] : ["aux", "--sort=-%cpu"]; - const output = await runCommand("ps", psArgs); - - if (!output) { - return { toolCallId: "", output: "Could not retrieve process list.", isError: true }; - } - - const lines = output.split("\n"); - const header = lines[0] || ""; - const processes = lines.slice(1, count + 1); - - return { - toolCallId: "", - output: `Top ${count} processes by CPU:\n\n${header}\n${processes.join("\n")}`, - isError: false, - }; - } -} diff --git a/src/tools/terminal.ts b/src/tools/terminal.ts deleted file mode 100644 index 7599f01..0000000 --- a/src/tools/terminal.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { spawn, ChildProcessWithoutNullStreams } from "child_process"; -import { killProcessTree, forceKillProcess } from "../shared/process-kill"; -import { - createSession, - appendOutput, - markExited, - markBackgrounded, - ProcessSession, -} from "./process-registry"; -import { Tool, ToolDefinition, ToolResult } from "./types"; - -const DANGEROUS_ENV_VARS = new Set([ - "LD_PRELOAD", - "LD_LIBRARY_PATH", - "LD_AUDIT", - "DYLD_INSERT_LIBRARIES", - "DYLD_LIBRARY_PATH", - "NODE_OPTIONS", - "NODE_PATH", - "PYTHONPATH", - "PYTHONHOME", - "RUBYLIB", - "PERL5LIB", - "BASH_ENV", - "ENV", - "GCONV_PATH", - "IFS", - "SSLKEYLOGFILE", -]); -const DANGEROUS_ENV_PREFIXES = ["DYLD_", "LD_"]; - -const DEFAULT_TIMEOUT_SEC = 120; -const DEFAULT_YIELD_MS = 10_000; -const MAX_YIELD_MS = 120_000; -const MAX_OUTPUT_CHARS = 200_000; -const PENDING_MAX_OUTPUT_CHARS = 30_000; - -function resolveShell(): { shell: string; buildArgs: (cmd: string) => string[] } { - const isWindows = process.platform === "win32"; - - if (isWindows) { - // Prefer PowerShell (pwsh/powershell), fall back to cmd.exe - for (const candidate of ["pwsh.exe", "powershell.exe"]) { - try { - require("child_process").execSync(`${candidate} -NoProfile -Command "echo ok"`, { - stdio: "ignore", - timeout: 3000, - }); - return { - shell: candidate, - buildArgs: (cmd: string) => [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - cmd, - ], - }; - } catch {} - } - - return { - shell: process.env.COMSPEC || "cmd.exe", - buildArgs: (cmd: string) => ["/c", cmd], - }; - } - - // Unix: prefer bash, fall back to sh - const fs = require("fs"); - for (const candidate of ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]) { - try { - if (fs.existsSync(candidate)) { - return { shell: candidate, buildArgs: (cmd: string) => ["-c", cmd] }; - } - } catch {} - } - - return { shell: "/bin/sh", buildArgs: (cmd: string) => ["-c", cmd] }; -} - -let cachedShell: ReturnType | null = null; - -function getShell(): ReturnType { - if (!cachedShell) { - cachedShell = resolveShell(); - } - return cachedShell; -} - -function validateEnv(env: Record): void { - for (const key of Object.keys(env)) { - const upper = key.toUpperCase(); - if (DANGEROUS_ENV_VARS.has(upper)) { - throw new Error(`Security: environment variable '${key}' is blocked.`); - } - if (DANGEROUS_ENV_PREFIXES.some((p) => upper.startsWith(p))) { - throw new Error(`Security: environment variable '${key}' is blocked.`); - } - if (upper === "PATH") { - throw new Error(`Security: custom 'PATH' is blocked. Use workdir to change context.`); - } - } -} - -export interface ExecOutcome { - status: "completed" | "failed" | "backgrounded"; - exitCode: number | null; - durationMs: number; - aggregated: string; - timedOut: boolean; - sessionId?: string; -} - -export class TerminalTool implements Tool { - name = "exec"; - description = - "Execute a shell command with full filesystem access. " + - "You can run commands in ANY directory on the machine by setting workdir to an absolute path. " + - "Supports env vars, timeout, and background execution via yieldMs. " + - "Long-running commands auto-background; use the process tool to poll/kill them. " + - "On Windows uses PowerShell, on macOS/Linux uses bash (falls back to sh)."; - - private defaultCwd: string; - private defaultTimeoutSec: number; - - constructor(opts?: { cwd?: string; timeoutSec?: number }) { - this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd(); - this.defaultTimeoutSec = opts?.timeoutSec ?? DEFAULT_TIMEOUT_SEC; - } - - getDefinition(): ToolDefinition { - return { - name: this.name, - description: this.description, - parameters: { - type: "object", - properties: { - command: { - type: "string", - description: "Shell command to execute.", - }, - workdir: { - type: "string", - description: - "Working directory — any absolute path on the machine (e.g. /home/user, C:\\Users\\user, /etc). " + - "Defaults to the project root if not specified.", - }, - env: { - type: "object", - description: - "Additional environment variables as key-value pairs. PATH and dangerous vars are blocked.", - }, - timeout: { - type: "number", - description: `Timeout in seconds (default ${DEFAULT_TIMEOUT_SEC}). Process is killed on expiry.`, - }, - yieldMs: { - type: "number", - description: - "Milliseconds to wait before backgrounding a still-running command (default 10000). " + - "Set to 0 to background immediately. Use the process tool to check on backgrounded commands.", - }, - background: { - type: "boolean", - description: "If true, run in background immediately (equivalent to yieldMs=0).", - }, - }, - required: ["command"], - }, - }; - } - - async execute(args: Record, signal?: AbortSignal): Promise { - const command = args.command as string; - if (!command) { - return { toolCallId: "", output: "Error: no command provided.", isError: true }; - } - - if (signal?.aborted) { - return { toolCallId: "", output: "Command execution aborted", isError: true }; - } - - const workdir = (args.workdir as string)?.trim() || this.defaultCwd; - - try { - const fs = require("fs"); - if (!fs.existsSync(workdir)) { - return { - toolCallId: "", - output: `Error: working directory does not exist: ${workdir}`, - isError: true, - }; - } - const stat = fs.statSync(workdir); - if (!stat.isDirectory()) { - return { - toolCallId: "", - output: `Error: workdir path is not a directory: ${workdir}`, - isError: true, - }; - } - } catch (err) { - return { - toolCallId: "", - output: `Error: cannot access working directory: ${workdir} (${err instanceof Error ? err.message : "permission denied"})`, - isError: true, - }; - } - - const userEnv = args.env as Record | undefined; - const timeoutSec = - typeof args.timeout === "number" && args.timeout > 0 ? args.timeout : this.defaultTimeoutSec; - const backgroundImmediate = args.background === true; - const yieldMs = backgroundImmediate - ? 0 - : typeof args.yieldMs === "number" - ? Math.max(0, Math.min(args.yieldMs, MAX_YIELD_MS)) - : DEFAULT_YIELD_MS; - - if (userEnv) { - try { - validateEnv(userEnv); - } catch (err) { - return { - toolCallId: "", - output: err instanceof Error ? err.message : "Invalid env.", - isError: true, - }; - } - } - - const env = userEnv ? { ...process.env, ...userEnv } : { ...process.env }; - - const session = createSession({ - command, - cwd: workdir, - maxOutputChars: MAX_OUTPUT_CHARS, - pendingMaxOutputChars: PENDING_MAX_OUTPUT_CHARS, - }); - - try { - const outcome = await this.runProcess( - session, - command, - workdir, - env, - timeoutSec, - yieldMs, - signal, - ); - return this.formatResult(outcome, session); - } catch (error) { - return { - toolCallId: "", - output: `Exec failed: ${error instanceof Error ? error.message : "Unknown error"}`, - isError: true, - }; - } - } - - private runProcess( - session: ProcessSession, - command: string, - workdir: string, - env: Record, - timeoutSec: number, - yieldMs: number, - signal?: AbortSignal, - ): Promise { - return new Promise((resolve) => { - const { shell, buildArgs: shellBuildArgs } = getShell(); - const shellArgs = shellBuildArgs(command); - const startedAt = Date.now(); - - let proc: ChildProcessWithoutNullStreams; - try { - proc = spawn(shell, shellArgs, { cwd: workdir, env: env as NodeJS.ProcessEnv }); - } catch (err) { - markExited(session, null, null, "failed"); - resolve({ - status: "failed", - exitCode: null, - durationMs: Date.now() - startedAt, - aggregated: `Spawn error: ${err instanceof Error ? err.message : String(err)}`, - timedOut: false, - }); - return; - } - - session.child = proc; - session.pid = proc.pid; - let yielded = false; - let processExited = false; - - const cleanupAbortListener = () => { - if (signal && abortHandler) { - signal.removeEventListener("abort", abortHandler); - } - }; - - const abortHandler = () => { - if (!processExited) { - cleanupAbortListener(); - killProcessTree(proc); - if (!yielded) { - markExited(session, null, null, "killed"); - resolve({ - status: "failed", - exitCode: null, - durationMs: Date.now() - startedAt, - aggregated: session.aggregated.trim() + "\n\n(Command aborted)", - timedOut: false, - }); - } else { - markExited(session, null, null, "killed"); - } - } - }; - - signal?.addEventListener("abort", abortHandler, { once: true }); - - proc.stdout.on("data", (data: Buffer) => { - appendOutput(session, "stdout", data.toString()); - }); - - proc.stderr.on("data", (data: Buffer) => { - appendOutput(session, "stderr", data.toString()); - }); - - const timeoutMs = timeoutSec * 1000; - const killTimer = setTimeout(() => { - if (!processExited) { - cleanupAbortListener(); - forceKillProcess(proc); - if (!yielded) { - markExited(session, null, null, "killed"); - resolve({ - status: "failed", - exitCode: null, - durationMs: Date.now() - startedAt, - aggregated: - session.aggregated.trim() + `\n\n(Command timed out after ${timeoutSec}s)`, - timedOut: true, - }); - } else { - markExited(session, null, null, "killed"); - } - } - }, timeoutMs); - - let yieldTimer: ReturnType | null = null; - if (yieldMs >= 0) { - if (yieldMs === 0) { - yielded = true; - markBackgrounded(session); - resolve({ - status: "backgrounded", - exitCode: null, - durationMs: 0, - aggregated: "", - timedOut: false, - sessionId: session.id, - }); - } else { - yieldTimer = setTimeout(() => { - if (!processExited && !yielded) { - yielded = true; - markBackgrounded(session); - resolve({ - status: "backgrounded", - exitCode: null, - durationMs: Date.now() - startedAt, - aggregated: session.tail, - timedOut: false, - sessionId: session.id, - }); - } - }, yieldMs); - } - } - - proc.on("close", (code, sig) => { - processExited = true; - cleanupAbortListener(); - clearTimeout(killTimer); - if (yieldTimer) { - clearTimeout(yieldTimer); - } - - const status: "completed" | "failed" = code === 0 ? "completed" : "failed"; - markExited(session, code, sig?.toString() ?? null, status); - - if (!yielded) { - const aggregated = session.aggregated.trim(); - const exitMsg = code !== null && code !== 0 ? `\n\n(exit code ${code})` : ""; - resolve({ - status, - exitCode: code, - durationMs: Date.now() - startedAt, - aggregated: aggregated + exitMsg, - timedOut: false, - }); - } - }); - - proc.on("error", (err) => { - processExited = true; - cleanupAbortListener(); - clearTimeout(killTimer); - if (yieldTimer) { - clearTimeout(yieldTimer); - } - - markExited(session, null, null, "failed"); - - if (!yielded) { - resolve({ - status: "failed", - exitCode: null, - durationMs: Date.now() - startedAt, - aggregated: `Process error: ${err.message}`, - timedOut: false, - }); - } - }); - }); - } - - private formatResult(outcome: ExecOutcome, session: ProcessSession): ToolResult { - if (outcome.status === "backgrounded") { - return { - toolCallId: "", - output: - `Command backgrounded (session ${session.id}, pid ${session.pid ?? "n/a"}). ` + - `Use the process tool with action "poll" and session_id "${session.id}" to check status.` + - (outcome.aggregated ? `\n\nInitial output:\n${outcome.aggregated}` : ""), - isError: false, - metadata: { - sessionId: session.id, - pid: session.pid, - status: "running", - }, - }; - } - - const output = outcome.aggregated || "(no output)"; - return { - toolCallId: "", - output, - isError: outcome.status === "failed", - metadata: { - exitCode: outcome.exitCode, - durationMs: outcome.durationMs, - timedOut: outcome.timedOut, - truncated: session.truncated, - }, - }; - } -} diff --git a/src/tools/types.ts b/src/tools/types.ts deleted file mode 100644 index 1f26d11..0000000 --- a/src/tools/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type ParameterType = "string" | "number" | "boolean" | "object" | "array"; - -export interface ParameterProperty { - type: ParameterType; - description: string; - enum?: string[]; - items?: { type: ParameterType }; - properties?: Record; - required?: string[]; - default?: unknown; -} - -export interface ToolDefinition { - name: string; - description: string; - parameters: { - type: "object"; - properties: Record; - required: string[]; - }; -} - -export interface ToolCall { - id: string; - name: string; - arguments: Record; -} - -export interface ToolResult { - toolCallId: string; - output: string; - isError: boolean; - metadata?: Record; -} - -export interface Tool { - name: string; - description: string; - getDefinition(): ToolDefinition; - execute(args: Record, signal?: AbortSignal): Promise; -} diff --git a/test/unit/agent-commands.test.ts b/test/unit/agent-commands.test.ts index c502333..786abbd 100644 --- a/test/unit/agent-commands.test.ts +++ b/test/unit/agent-commands.test.ts @@ -67,26 +67,6 @@ vi.mock("../../src/core/context-store", () => ({ loadLatestSession: vi.fn().mockReturnValue(null), })); -vi.mock("../../src/tools/terminal", () => ({ - TerminalTool: class { - name = "terminal"; - execute() {} - }, -})); - -vi.mock("../../src/tools/process", () => ({ - ProcessTool: class { - name = "process"; - execute() {} - }, -})); - -vi.mock("../../src/tools/registry", () => ({ - ToolRegistry: class { - register() {} - }, -})); - import { AgentCore } from "../../src/core/agent"; function msg(text: string, from = "user1"): Message { diff --git a/test/unit/router.test.ts b/test/unit/router.test.ts index aa6e92e..923fd4f 100644 --- a/test/unit/router.test.ts +++ b/test/unit/router.test.ts @@ -33,26 +33,6 @@ vi.mock("../../src/core/context-store", () => ({ loadLatestSession: vi.fn().mockReturnValue(null), })); -vi.mock("../../src/tools/terminal", () => ({ - TerminalTool: class { - name = "terminal"; - execute() {} - }, -})); - -vi.mock("../../src/tools/process", () => ({ - ProcessTool: class { - name = "process"; - execute() {} - }, -})); - -vi.mock("../../src/tools/registry", () => ({ - ToolRegistry: class { - register() {} - }, -})); - vi.mock("fs", () => ({ default: { existsSync: vi.fn().mockReturnValue(false), From 2524a1812a11f646a21eb0f79043e16d0b2366e5 Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Mon, 2 Mar 2026 14:09:38 -0800 Subject: [PATCH 2/3] format --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 74178e0..81e7406 100644 --- a/README.md +++ b/README.md @@ -179,15 +179,15 @@ Use `/code` mode to route messages directly to a coding adapter with full coding Send these commands in any messaging app while connected: -| Command | Description | -| :----------- | :----------------------------------------------------------------------------- | -| `/chat` | Switch to **Chat mode** to send messages to primary LLM _(default)_ | -| `/code` | Switch to **Code mode** to send messages to coding adapter (full CLI control) | -| `/switch` | Switch primary LLM provider or coding adapter on the fly | -| `/cli-model` | Change the model used by the current coding adapter | -| `/cancel` | Cancel the currently running command | -| `/status` | Show adapter connection and current configuration | -| `/help` | Show available commands | +| Command | Description | +| :----------- | :---------------------------------------------------------------------------- | +| `/chat` | Switch to **Chat mode** to send messages to primary LLM _(default)_ | +| `/code` | Switch to **Code mode** to send messages to coding adapter (full CLI control) | +| `/switch` | Switch primary LLM provider or coding adapter on the fly | +| `/cli-model` | Change the model used by the current coding adapter | +| `/cancel` | Cancel the currently running command | +| `/status` | Show adapter connection and current configuration | +| `/help` | Show available commands | --- From e4edb72b28ffae1c8c52f039050e5d938ccaf3af Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Mon, 2 Mar 2026 14:11:38 -0800 Subject: [PATCH 3/3] remove system prompt --- src/data/primary_llm_system_prompt.txt | 16 ---------------- src/providers/anthropic.ts | 14 +++----------- src/providers/gemini.ts | 14 +++----------- src/providers/huggingface.ts | 14 +++----------- src/providers/minimax.ts | 14 +++----------- src/providers/mistral.ts | 14 +++----------- src/providers/moonshot.ts | 14 +++----------- src/providers/openai.ts | 14 +++----------- src/providers/openrouter.ts | 14 +++----------- src/providers/xai.ts | 14 +++----------- 10 files changed, 27 insertions(+), 115 deletions(-) delete mode 100644 src/data/primary_llm_system_prompt.txt diff --git a/src/data/primary_llm_system_prompt.txt b/src/data/primary_llm_system_prompt.txt deleted file mode 100644 index 8930f14..0000000 --- a/src/data/primary_llm_system_prompt.txt +++ /dev/null @@ -1,16 +0,0 @@ -You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging platforms (WhatsApp, Telegram, Discord, Slack, Teams, Signal). - -ROLE: -- You are the user's primary AI assistant for general conversation, coding questions, debugging help, and technical discussions - -BEHAVIOR: -- Respond naturally and conversationally to greetings and general questions -- Be concise — remember you're communicating via a messaging platform, not a full IDE -- Provide clear, well-formatted responses (use markdown where helpful) -- If the user needs deep coding work (editing files, writing code across multiple files), suggest switching to /code mode - -CONTEXT: -- The user is communicating from a mobile or desktop messaging app -- Keep responses short and scannable — avoid long walls of text -- Use bullet points and code blocks for clarity -- The user can switch to /code mode for direct coding adapter access diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 04a7380..0dfd1b8 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -1,16 +1,8 @@ -import fs from "fs"; -import path from "path"; import Anthropic from "@anthropic-ai/sdk"; import { logger } from "../shared/logger"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithAnthropic( instruction: string, @@ -26,7 +18,7 @@ export async function processWithAnthropic( const response = await anthropic.messages.create({ model, max_tokens: 4096, - system: loadSystemPrompt(), + system: SYSTEM_PROMPT, messages: [{ role: "user", content: instruction }], }); diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index d94dbfa..0e0191f 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -1,16 +1,8 @@ -import fs from "fs"; -import path from "path"; import { GoogleGenerativeAI } from "@google/generative-ai"; import { logger } from "../shared/logger"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithGemini( instruction: string, @@ -25,7 +17,7 @@ export async function processWithGemini( const genModel = genAI.getGenerativeModel({ model, - systemInstruction: loadSystemPrompt(), + systemInstruction: SYSTEM_PROMPT, }); const result = await genModel.generateContent(instruction); diff --git a/src/providers/huggingface.ts b/src/providers/huggingface.ts index 4ec81b0..2d9a071 100644 --- a/src/providers/huggingface.ts +++ b/src/providers/huggingface.ts @@ -1,18 +1,10 @@ -import fs from "fs"; -import path from "path"; import OpenAI from "openai"; import { logger } from "../shared/logger"; const HUGGINGFACE_BASE_URL = "https://router.huggingface.co/v1"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithHuggingFace( instruction: string, @@ -32,7 +24,7 @@ export async function processWithHuggingFace( model, max_tokens: 4096, messages: [ - { role: "system", content: loadSystemPrompt() }, + { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: instruction }, ], }); diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index 78ed83b..92445fb 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -1,18 +1,10 @@ -import fs from "fs"; -import path from "path"; import Anthropic from "@anthropic-ai/sdk"; import { logger } from "../shared/logger"; const MINIMAX_BASE_URL = "https://api.minimax.chat/v1"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithMiniMax( instruction: string, @@ -31,7 +23,7 @@ export async function processWithMiniMax( const response = await client.messages.create({ model, max_tokens: 4096, - system: loadSystemPrompt(), + system: SYSTEM_PROMPT, messages: [{ role: "user", content: instruction }], }); diff --git a/src/providers/mistral.ts b/src/providers/mistral.ts index f613ec0..c53f5ba 100644 --- a/src/providers/mistral.ts +++ b/src/providers/mistral.ts @@ -1,16 +1,8 @@ -import fs from "fs"; -import path from "path"; import OpenAI from "openai"; import { logger } from "../shared/logger"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithMistral( instruction: string, @@ -30,7 +22,7 @@ export async function processWithMistral( model, max_tokens: 4096, messages: [ - { role: "system", content: loadSystemPrompt() }, + { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: instruction }, ], }); diff --git a/src/providers/moonshot.ts b/src/providers/moonshot.ts index a7d5847..b014ff5 100644 --- a/src/providers/moonshot.ts +++ b/src/providers/moonshot.ts @@ -1,16 +1,8 @@ -import fs from "fs"; -import path from "path"; import OpenAI from "openai"; import { logger } from "../shared/logger"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithMoonshot( instruction: string, @@ -30,7 +22,7 @@ export async function processWithMoonshot( model, max_tokens: 4096, messages: [ - { role: "system", content: loadSystemPrompt() }, + { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: instruction }, ], }); diff --git a/src/providers/openai.ts b/src/providers/openai.ts index 2cdffa7..86cf85a 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -1,16 +1,8 @@ -import fs from "fs"; -import path from "path"; import OpenAI from "openai"; import { logger } from "../shared/logger"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithOpenAI( instruction: string, @@ -27,7 +19,7 @@ export async function processWithOpenAI( model, max_tokens: 4096, messages: [ - { role: "system", content: loadSystemPrompt() }, + { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: instruction }, ], }); diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index 62d4c10..9fa99ee 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -1,16 +1,8 @@ -import fs from "fs"; -import path from "path"; import OpenAI from "openai"; import { logger } from "../shared/logger"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithOpenRouter( instruction: string, @@ -34,7 +26,7 @@ export async function processWithOpenRouter( model, max_tokens: 4096, messages: [ - { role: "system", content: loadSystemPrompt() }, + { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: instruction }, ], }); diff --git a/src/providers/xai.ts b/src/providers/xai.ts index 373b91d..c4f4c77 100644 --- a/src/providers/xai.ts +++ b/src/providers/xai.ts @@ -1,16 +1,8 @@ -import fs from "fs"; -import path from "path"; import OpenAI from "openai"; import { logger } from "../shared/logger"; -function loadSystemPrompt(): string { - try { - const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt"); - return fs.readFileSync(promptPath, "utf-8"); - } catch { - return "You are a helpful coding assistant."; - } -} +const SYSTEM_PROMPT = + "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work."; export async function processWithXAI( instruction: string, @@ -30,7 +22,7 @@ export async function processWithXAI( model, max_tokens: 4096, messages: [ - { role: "system", content: loadSystemPrompt() }, + { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: instruction }, ], });