From 67e120b2ed686378d2ea049c3161296ea3abb599 Mon Sep 17 00:00:00 2001 From: idoubi Date: Wed, 1 Apr 2026 17:51:53 +0800 Subject: [PATCH] Initial commit: Open Agent SDK (TypeScript) Open-source Agent SDK with 30+ built-in tools, MCP integration, multi-turn sessions, subagents, and streaming support. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 8 + .gitignore | 8 + LICENSE | 21 + README.md | 388 ++++++ examples/01-simple-query.ts | 43 + examples/02-multi-tool.ts | 44 + examples/03-multi-turn.ts | 39 + examples/04-prompt-api.ts | 29 + examples/05-custom-system-prompt.ts | 26 + examples/06-mcp-server.ts | 49 + examples/07-custom-tools.ts | 87 ++ examples/08-official-api-compat.ts | 38 + examples/09-subagents.ts | 48 + examples/10-permissions.ts | 40 + examples/11-custom-mcp-tools.ts | 101 ++ examples/web/index.html | 365 ++++++ examples/web/server.ts | 157 +++ package-lock.json | 1732 +++++++++++++++++++++++++++ package.json | 60 + src/agent.ts | 425 +++++++ src/engine.ts | 520 ++++++++ src/hooks.ts | 261 ++++ src/index.ts | 376 ++++++ src/mcp/client.ts | 150 +++ src/sdk-mcp-server.ts | 78 ++ src/session.ts | 227 ++++ src/tool-helper.ts | 127 ++ src/tools/agent-tool.ts | 153 +++ src/tools/ask-user.ts | 79 ++ src/tools/bash.ts | 75 ++ src/tools/config-tool.ts | 89 ++ src/tools/cron-tools.ts | 153 +++ src/tools/edit.ts | 74 ++ src/tools/glob.ts | 77 ++ src/tools/grep.ts | 168 +++ src/tools/index.ts | 232 ++++ src/tools/lsp-tool.ts | 163 +++ src/tools/mcp-resource-tools.ts | 125 ++ src/tools/notebook-edit.ts | 93 ++ src/tools/plan-tools.ts | 88 ++ src/tools/read.ts | 73 ++ src/tools/send-message.ts | 96 ++ src/tools/task-tools.ts | 290 +++++ src/tools/team-tools.ts | 128 ++ src/tools/todo-tool.ts | 112 ++ src/tools/tool-search.ts | 87 ++ src/tools/types.ts | 62 + src/tools/web-fetch.ts | 66 + src/tools/web-search.ts | 86 ++ src/tools/worktree-tools.ts | 140 +++ src/tools/write.ts | 42 + src/types.ts | 459 +++++++ src/utils/compact.ts | 206 ++++ src/utils/context.ts | 191 +++ src/utils/fileCache.ts | 148 +++ src/utils/messages.ts | 196 +++ src/utils/retry.ts | 140 +++ src/utils/tokens.ts | 122 ++ tsconfig.json | 19 + 59 files changed, 9679 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/01-simple-query.ts create mode 100644 examples/02-multi-tool.ts create mode 100644 examples/03-multi-turn.ts create mode 100644 examples/04-prompt-api.ts create mode 100644 examples/05-custom-system-prompt.ts create mode 100644 examples/06-mcp-server.ts create mode 100644 examples/07-custom-tools.ts create mode 100644 examples/08-official-api-compat.ts create mode 100644 examples/09-subagents.ts create mode 100644 examples/10-permissions.ts create mode 100644 examples/11-custom-mcp-tools.ts create mode 100644 examples/web/index.html create mode 100644 examples/web/server.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/agent.ts create mode 100644 src/engine.ts create mode 100644 src/hooks.ts create mode 100644 src/index.ts create mode 100644 src/mcp/client.ts create mode 100644 src/sdk-mcp-server.ts create mode 100644 src/session.ts create mode 100644 src/tool-helper.ts create mode 100644 src/tools/agent-tool.ts create mode 100644 src/tools/ask-user.ts create mode 100644 src/tools/bash.ts create mode 100644 src/tools/config-tool.ts create mode 100644 src/tools/cron-tools.ts create mode 100644 src/tools/edit.ts create mode 100644 src/tools/glob.ts create mode 100644 src/tools/grep.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/lsp-tool.ts create mode 100644 src/tools/mcp-resource-tools.ts create mode 100644 src/tools/notebook-edit.ts create mode 100644 src/tools/plan-tools.ts create mode 100644 src/tools/read.ts create mode 100644 src/tools/send-message.ts create mode 100644 src/tools/task-tools.ts create mode 100644 src/tools/team-tools.ts create mode 100644 src/tools/todo-tool.ts create mode 100644 src/tools/tool-search.ts create mode 100644 src/tools/types.ts create mode 100644 src/tools/web-fetch.ts create mode 100644 src/tools/web-search.ts create mode 100644 src/tools/worktree-tools.ts create mode 100644 src/tools/write.ts create mode 100644 src/types.ts create mode 100644 src/utils/compact.ts create mode 100644 src/utils/context.ts create mode 100644 src/utils/fileCache.ts create mode 100644 src/utils/messages.ts create mode 100644 src/utils/retry.ts create mode 100644 src/utils/tokens.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10eeb91 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Required: your LLM API key +CODEANY_API_KEY=sk-or-... + +# Optional: override model +# CODEANY_MODEL=anthropic/claude-sonnet-4 + +# Optional: custom API endpoint (e.g. third-party proxy) +# CODEANY_BASE_URL=https://openrouter.ai/api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..100138d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +*.tsbuildinfo +.DS_Store +.env +.env.* +!.env.example diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07323d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 CodeAny (https://codeany.ai) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70ed1c0 --- /dev/null +++ b/README.md @@ -0,0 +1,388 @@ +# Open Agent SDK (TypeScript) + +[![npm version](https://img.shields.io/npm/v/@codeany/open-agent-sdk)](https://www.npmjs.com/package/@codeany/open-agent-sdk) +[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) + +Open-source Agent SDK that runs the full agent loop **in-process** — no subprocess or CLI required. Deploy anywhere: cloud, serverless, Docker, CI/CD. + +Also available in **Go**: [open-agent-sdk-go](https://github.com/codeany-ai/open-agent-sdk-go) + +## Get started + +```bash +npm install @codeany/open-agent-sdk +``` + +Set your API key: + +```bash +export CODEANY_API_KEY=your-api-key +``` + +Third-party providers (e.g. OpenRouter) are supported via `CODEANY_BASE_URL`: + +```bash +export CODEANY_BASE_URL=https://openrouter.ai/api +export CODEANY_API_KEY=sk-or-... +export CODEANY_MODEL=anthropic/claude-sonnet-4 +``` + +## Quick start + +### One-shot query (streaming) + +```typescript +import { query } from "@codeany/open-agent-sdk"; + +for await (const message of query({ + prompt: "Read package.json and tell me the project name.", + options: { + allowedTools: ["Read", "Glob"], + permissionMode: "bypassPermissions", + }, +})) { + if (message.type === "assistant") { + for (const block of message.message.content) { + if ("text" in block) console.log(block.text); + } + } +} +``` + +### Simple blocking prompt + +```typescript +import { createAgent } from "@codeany/open-agent-sdk"; + +const agent = createAgent({ model: "claude-sonnet-4-6" }); +const result = await agent.prompt("What files are in this project?"); + +console.log(result.text); +console.log( + `Turns: ${result.num_turns}, Tokens: ${result.usage.input_tokens + result.usage.output_tokens}`, +); +``` + +### Multi-turn conversation + +```typescript +import { createAgent } from "@codeany/open-agent-sdk"; + +const agent = createAgent({ maxTurns: 5 }); + +const r1 = await agent.prompt( + 'Create a file /tmp/hello.txt with "Hello World"', +); +console.log(r1.text); + +const r2 = await agent.prompt("Read back the file you just created"); +console.log(r2.text); + +console.log(`Session messages: ${agent.getMessages().length}`); +``` + +### Custom tools (Zod schema) + +```typescript +import { z } from "zod"; +import { query, tool, createSdkMcpServer } from "@codeany/open-agent-sdk"; + +const getWeather = tool( + "get_weather", + "Get the temperature for a city", + { city: z.string().describe("City name") }, + async ({ city }) => ({ + content: [{ type: "text", text: `${city}: 22°C, sunny` }], + }), +); + +const server = createSdkMcpServer({ name: "weather", tools: [getWeather] }); + +for await (const msg of query({ + prompt: "What is the weather in Tokyo?", + options: { mcpServers: { weather: server } }, +})) { + if (msg.type === "result") + console.log(`Done: $${msg.total_cost_usd?.toFixed(4)}`); +} +``` + +### Custom tools (low-level) + +```typescript +import { + createAgent, + getAllBaseTools, + defineTool, +} from "@codeany/open-agent-sdk"; + +const calculator = defineTool({ + name: "Calculator", + description: "Evaluate a math expression", + inputSchema: { + type: "object", + properties: { expression: { type: "string" } }, + required: ["expression"], + }, + isReadOnly: true, + async call(input) { + const result = Function(`'use strict'; return (${input.expression})`)(); + return `${input.expression} = ${result}`; + }, +}); + +const agent = createAgent({ tools: [...getAllBaseTools(), calculator] }); +const r = await agent.prompt("Calculate 2**10 * 3"); +console.log(r.text); +``` + +### MCP server integration + +```typescript +import { createAgent } from "@codeany/open-agent-sdk"; + +const agent = createAgent({ + mcpServers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + }, + }, +}); + +const result = await agent.prompt("List files in /tmp"); +console.log(result.text); +await agent.close(); +``` + +### Subagents + +```typescript +import { query } from "@codeany/open-agent-sdk"; + +for await (const msg of query({ + prompt: "Use the code-reviewer agent to review src/index.ts", + options: { + agents: { + "code-reviewer": { + description: "Expert code reviewer", + prompt: "Analyze code quality. Focus on security and performance.", + tools: ["Read", "Glob", "Grep"], + }, + }, + }, +})) { + if (msg.type === "result") console.log("Done"); +} +``` + +### Permissions + +```typescript +import { query } from "@codeany/open-agent-sdk"; + +// Read-only agent — can only analyze, not modify +for await (const msg of query({ + prompt: "Review the code in src/ for best practices.", + options: { + allowedTools: ["Read", "Glob", "Grep"], + permissionMode: "dontAsk", + }, +})) { + // ... +} +``` + +### Web UI + +A built-in web chat interface is included for testing: + +```bash +npx tsx examples/web/server.ts +# Open http://localhost:8081 +``` + +## API reference + +### Top-level functions + +| Function | Description | +| ------------------------------------- | -------------------------------------------------------------- | +| `query({ prompt, options })` | One-shot streaming query, returns `AsyncGenerator` | +| `createAgent(options)` | Create a reusable agent with session persistence | +| `tool(name, desc, schema, handler)` | Create a tool with Zod schema validation | +| `createSdkMcpServer({ name, tools })` | Bundle tools into an in-process MCP server | +| `defineTool(config)` | Low-level tool definition helper | +| `getAllBaseTools()` | Get all 34 built-in tools | +| `listSessions()` | List persisted sessions | +| `getSessionMessages(id)` | Retrieve messages from a session | +| `forkSession(id)` | Fork a session for branching | + +### Agent methods + +| Method | Description | +| ------------------------------- | ----------------------------------------------------- | +| `agent.query(prompt)` | Streaming query, returns `AsyncGenerator` | +| `agent.prompt(text)` | Blocking query, returns `Promise` | +| `agent.getMessages()` | Get conversation history | +| `agent.clear()` | Reset session | +| `agent.interrupt()` | Abort current query | +| `agent.setModel(model)` | Change model mid-session | +| `agent.setPermissionMode(mode)` | Change permission mode | +| `agent.close()` | Close MCP connections, persist session | + +### Options + +| Option | Type | Default | Description | +| -------------------- | --------------------------------------- | ---------------------- | -------------------------------------------------------------------- | +| `model` | `string` | `claude-sonnet-4-6` | LLM model ID | +| `apiKey` | `string` | `CODEANY_API_KEY` | API key | +| `baseURL` | `string` | — | Custom API endpoint | +| `cwd` | `string` | `process.cwd()` | Working directory | +| `systemPrompt` | `string` | — | System prompt override | +| `appendSystemPrompt` | `string` | — | Append to default system prompt | +| `tools` | `ToolDefinition[]` | All built-in | Available tools | +| `allowedTools` | `string[]` | — | Tool allow-list | +| `disallowedTools` | `string[]` | — | Tool deny-list | +| `permissionMode` | `string` | `bypassPermissions` | `default` / `acceptEdits` / `dontAsk` / `bypassPermissions` / `plan` | +| `canUseTool` | `function` | — | Custom permission callback | +| `maxTurns` | `number` | `10` | Max agentic turns | +| `maxBudgetUsd` | `number` | — | Spending cap | +| `thinking` | `ThinkingConfig` | `{ type: 'adaptive' }` | Extended thinking | +| `effort` | `string` | `high` | Reasoning effort: `low` / `medium` / `high` / `max` | +| `mcpServers` | `Record` | — | MCP server connections | +| `agents` | `Record` | — | Subagent definitions | +| `hooks` | `Record` | — | Lifecycle hooks | +| `resume` | `string` | — | Resume session by ID | +| `continue` | `boolean` | `false` | Continue most recent session | +| `persistSession` | `boolean` | `true` | Persist session to disk | +| `sessionId` | `string` | auto | Explicit session ID | +| `outputFormat` | `{ type: 'json_schema', schema }` | — | Structured output | +| `sandbox` | `SandboxSettings` | — | Filesystem/network sandbox | +| `settingSources` | `SettingSource[]` | — | Load AGENT.md, project settings | +| `env` | `Record` | — | Environment variables | +| `abortController` | `AbortController` | — | Cancellation controller | + +### Environment variables + +| Variable | Description | +| -------------------- | ---------------------- | +| `CODEANY_API_KEY` | API key (required) | +| `CODEANY_MODEL` | Default model override | +| `CODEANY_BASE_URL` | Custom API endpoint | +| `CODEANY_AUTH_TOKEN` | Alternative auth token | + +## Built-in tools + +| Tool | Description | +| ------------------------------------------ | -------------------------------------------- | +| **Bash** | Execute shell commands | +| **Read** | Read files with line numbers | +| **Write** | Create / overwrite files | +| **Edit** | Precise string replacement in files | +| **Glob** | Find files by pattern | +| **Grep** | Search file contents with regex | +| **WebFetch** | Fetch and parse web content | +| **WebSearch** | Search the web | +| **NotebookEdit** | Edit Jupyter notebook cells | +| **Agent** | Spawn subagents for parallel work | +| **TaskCreate/List/Update/Get/Stop/Output** | Task management system | +| **TeamCreate/Delete** | Multi-agent team coordination | +| **SendMessage** | Inter-agent messaging | +| **EnterWorktree/ExitWorktree** | Git worktree isolation | +| **EnterPlanMode/ExitPlanMode** | Structured planning workflow | +| **AskUserQuestion** | Ask the user for input | +| **ToolSearch** | Discover lazy-loaded tools | +| **ListMcpResources/ReadMcpResource** | MCP resource access | +| **CronCreate/Delete/List** | Scheduled task management | +| **RemoteTrigger** | Remote agent triggers | +| **LSP** | Language Server Protocol (code intelligence) | +| **Config** | Dynamic configuration | +| **TodoWrite** | Session todo list | + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Your Application │ +│ │ +│ import { createAgent } from '@codeany/open-agent-sdk' │ +└────────────────────────┬─────────────────────────────┘ + │ + ┌──────────▼──────────┐ + │ Agent │ Session state, tool pool, + │ query() / prompt() │ MCP connections + └──────────┬──────────┘ + │ + ┌──────────▼──────────┐ + │ QueryEngine │ Agentic loop: + │ submitMessage() │ API call → tools → repeat + └──────────┬──────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ LLM API │ │ 34 Tools │ │ MCP │ + │ Client │ │ Bash,Read │ │ Servers │ + │ (streaming)│ │ Edit,... │ │ stdio/SSE/ │ + └───────────┘ └───────────┘ │ HTTP/SDK │ + └───────────┘ +``` + +**Key internals:** + +| Component | Description | +| --------------------- | ---------------------------------------------------------------- | +| **QueryEngine** | Core agentic loop with auto-compact, retry, tool orchestration | +| **Auto-compact** | Summarizes conversation when context window fills up | +| **Micro-compact** | Truncates oversized tool results | +| **Retry** | Exponential backoff for rate limits and transient errors | +| **Token estimation** | Rough token counting for budget and compaction thresholds | +| **File cache** | LRU cache (100 entries, 25 MB) for file reads | +| **Hook system** | 20 lifecycle events (PreToolUse, PostToolUse, SessionStart, ...) | +| **Session storage** | Persist / resume / fork sessions on disk | +| **Context injection** | Git status + AGENT.md automatically injected into system prompt | + +## Examples + +| # | File | Description | +| --- | ------------------------------------- | -------------------------------------- | +| 01 | `examples/01-simple-query.ts` | Streaming query with event handling | +| 02 | `examples/02-multi-tool.ts` | Multi-tool orchestration (Glob + Bash) | +| 03 | `examples/03-multi-turn.ts` | Multi-turn session persistence | +| 04 | `examples/04-prompt-api.ts` | Blocking `prompt()` API | +| 05 | `examples/05-custom-system-prompt.ts` | Custom system prompt | +| 06 | `examples/06-mcp-server.ts` | MCP server integration | +| 07 | `examples/07-custom-tools.ts` | Custom tools with `defineTool()` | +| 08 | `examples/08-official-api-compat.ts` | `query()` API pattern | +| 09 | `examples/09-subagents.ts` | Subagent delegation | +| 10 | `examples/10-permissions.ts` | Read-only agent with tool restrictions | +| 11 | `examples/11-custom-mcp-tools.ts` | `tool()` + `createSdkMcpServer()` | +| web | `examples/web/` | Web chat UI for testing | + +Run any example: + +```bash +npx tsx examples/01-simple-query.ts +``` + +Start the web UI: + +```bash +npx tsx examples/web/server.ts +``` + +## Star History + + + + + + Star History Chart + + + +## License + +MIT diff --git a/examples/01-simple-query.ts b/examples/01-simple-query.ts new file mode 100644 index 0000000..294818f --- /dev/null +++ b/examples/01-simple-query.ts @@ -0,0 +1,43 @@ +/** + * Example 1: Simple Query with Streaming + * + * Demonstrates the basic createAgent() + query() flow with + * real-time event streaming. + * + * Run: npx tsx examples/01-simple-query.ts + */ +import { createAgent } from '../src/index.js' + +async function main() { + console.log('--- Example 1: Simple Query ---\n') + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 10, + }) + + for await (const event of agent.query( + 'Read package.json and tell me the project name and version in one sentence.', + )) { + const msg = event as any + + if (msg.type === 'assistant') { + // Print tool calls + for (const block of msg.message?.content || []) { + if (block.type === 'tool_use') { + console.log(`[Tool] ${block.name}(${JSON.stringify(block.input).slice(0, 80)})`) + } + if (block.type === 'text') { + console.log(`\nAssistant: ${block.text}`) + } + } + } + + if (msg.type === 'result') { + console.log(`\n--- Result: ${msg.subtype} ---`) + console.log(`Tokens: ${msg.usage?.input_tokens} in / ${msg.usage?.output_tokens} out`) + } + } +} + +main().catch(console.error) diff --git a/examples/02-multi-tool.ts b/examples/02-multi-tool.ts new file mode 100644 index 0000000..263b64d --- /dev/null +++ b/examples/02-multi-tool.ts @@ -0,0 +1,44 @@ +/** + * Example 2: Multi-Tool Orchestration + * + * The agent autonomously uses Glob, Bash, and Read tools to + * accomplish a multi-step task. + * + * Run: npx tsx examples/02-multi-tool.ts + */ +import { createAgent } from '../src/index.js' + +async function main() { + console.log('--- Example 2: Multi-Tool Orchestration ---\n') + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 15, + }) + + for await (const event of agent.query( + 'Do these steps: ' + + '1) Use Glob to find all .ts files in src/ (pattern "src/*.ts"). ' + + '2) Use Bash to count lines in src/agent.ts with `wc -l`. ' + + '3) Give a brief summary.', + )) { + const msg = event as any + + if (msg.type === 'assistant') { + for (const block of msg.message?.content || []) { + if (block.type === 'tool_use') { + console.log(`[${block.name}] ${JSON.stringify(block.input).slice(0, 100)}`) + } + if (block.type === 'text' && block.text.trim()) { + console.log(`\n${block.text}`) + } + } + } + + if (msg.type === 'result') { + console.log(`\n--- ${msg.subtype} | ${msg.usage?.input_tokens}/${msg.usage?.output_tokens} tokens ---`) + } + } +} + +main().catch(console.error) diff --git a/examples/03-multi-turn.ts b/examples/03-multi-turn.ts new file mode 100644 index 0000000..ee758d7 --- /dev/null +++ b/examples/03-multi-turn.ts @@ -0,0 +1,39 @@ +/** + * Example 3: Multi-Turn Conversation + * + * Demonstrates session persistence across multiple turns. + * The agent remembers context from previous interactions. + * + * Run: npx tsx examples/03-multi-turn.ts + */ +import { createAgent } from '../src/index.js' + +async function main() { + console.log('--- Example 3: Multi-Turn Conversation ---\n') + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 5, + }) + + // Turn 1: Create a file + console.log('> Turn 1: Create a file') + const r1 = await agent.prompt( + 'Use Bash to run: echo "Hello Open Agent SDK" > /tmp/oas-test.txt. Confirm briefly.', + ) + console.log(` ${r1.text}\n`) + + // Turn 2: Read back (should remember context) + console.log('> Turn 2: Read the file back') + const r2 = await agent.prompt('Read the file you just created and tell me its contents.') + console.log(` ${r2.text}\n`) + + // Turn 3: Clean up + console.log('> Turn 3: Cleanup') + const r3 = await agent.prompt('Delete that file with Bash. Confirm.') + console.log(` ${r3.text}\n`) + + console.log(`Session history: ${agent.getMessages().length} messages`) +} + +main().catch(console.error) diff --git a/examples/04-prompt-api.ts b/examples/04-prompt-api.ts new file mode 100644 index 0000000..5797876 --- /dev/null +++ b/examples/04-prompt-api.ts @@ -0,0 +1,29 @@ +/** + * Example 4: Simple Prompt API + * + * Uses the blocking prompt() method for quick one-shot queries. + * No need to iterate over streaming events. + * + * Run: npx tsx examples/04-prompt-api.ts + */ +import { createAgent } from '../src/index.js' + +async function main() { + console.log('--- Example 4: Simple Prompt API ---\n') + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 5, + }) + + const result = await agent.prompt( + 'Use Bash to run `node --version` and `npm --version`, then tell me the versions.', + ) + + console.log(`Answer: ${result.text}`) + console.log(`Turns: ${result.num_turns}`) + console.log(`Tokens: ${result.usage.input_tokens} in / ${result.usage.output_tokens} out`) + console.log(`Duration: ${result.duration_ms}ms`) +} + +main().catch(console.error) diff --git a/examples/05-custom-system-prompt.ts b/examples/05-custom-system-prompt.ts new file mode 100644 index 0000000..9a08b49 --- /dev/null +++ b/examples/05-custom-system-prompt.ts @@ -0,0 +1,26 @@ +/** + * Example 5: Custom System Prompt + * + * Shows how to customize the agent's behavior with a system prompt. + * + * Run: npx tsx examples/05-custom-system-prompt.ts + */ +import { createAgent } from '../src/index.js' + +async function main() { + console.log('--- Example 5: Custom System Prompt ---\n') + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 5, + systemPrompt: + 'You are a senior code reviewer. When asked to review code, focus on: ' + + '1) Security issues, 2) Performance concerns, 3) Maintainability. ' + + 'Be concise and use bullet points.', + }) + + const result = await agent.prompt('Read src/agent.ts and give a brief code review.') + console.log(result.text) +} + +main().catch(console.error) diff --git a/examples/06-mcp-server.ts b/examples/06-mcp-server.ts new file mode 100644 index 0000000..6187f30 --- /dev/null +++ b/examples/06-mcp-server.ts @@ -0,0 +1,49 @@ +/** + * Example 6: MCP Server Integration + * + * Connects to an MCP (Model Context Protocol) server and uses + * its tools through the agent. This example uses the filesystem + * MCP server as a demonstration. + * + * Prerequisites: + * npm install -g @modelcontextprotocol/server-filesystem + * + * Run: npx tsx examples/06-mcp-server.ts + */ +import { createAgent } from '../src/index.js' + +async function main() { + console.log('--- Example 6: MCP Server Integration ---\n') + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 10, + mcpServers: { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + }, + }) + + console.log('Connecting to MCP filesystem server...\n') + + const result = await agent.prompt( + 'Use the filesystem MCP tools to list files in /tmp. Be brief.', + ) + + console.log(`Answer: ${result.text}`) + console.log(`Turns: ${result.num_turns}`) + + await agent.close() +} + +main().catch(e => { + console.error('Error:', e.message) + if (e.message.includes('ENOENT') || e.message.includes('not found')) { + console.error( + '\nMCP server not found. Install it with:\n' + + ' npm install -g @modelcontextprotocol/server-filesystem\n', + ) + } +}) diff --git a/examples/07-custom-tools.ts b/examples/07-custom-tools.ts new file mode 100644 index 0000000..474e79a --- /dev/null +++ b/examples/07-custom-tools.ts @@ -0,0 +1,87 @@ +/** + * Example 7: Custom Tools + * + * Shows how to define and use custom tools alongside built-in tools. + * + * Run: npx tsx examples/07-custom-tools.ts + */ +import { createAgent, getAllBaseTools, defineTool } from '../src/index.js' + +const weatherTool = defineTool({ + name: 'GetWeather', + description: 'Get current weather for a city. Returns temperature and conditions.', + inputSchema: { + type: 'object', + properties: { + city: { type: 'string', description: 'City name (e.g., "Tokyo", "London")' }, + }, + required: ['city'], + }, + isReadOnly: true, + isConcurrencySafe: true, + async call(input) { + const temps: Record = { + tokyo: 22, london: 14, beijing: 25, 'new york': 18, paris: 16, + } + const temp = temps[input.city?.toLowerCase()] ?? 20 + return `Weather in ${input.city}: ${temp}°C, partly cloudy` + }, +}) + +const calculatorTool = defineTool({ + name: 'Calculator', + description: 'Evaluate a mathematical expression. Use ** for exponentiation.', + inputSchema: { + type: 'object', + properties: { + expression: { type: 'string', description: 'Math expression (e.g., "42 * 17 + 3", "2 ** 10")' }, + }, + required: ['expression'], + }, + isReadOnly: true, + isConcurrencySafe: true, + async call(input) { + try { + const result = Function(`'use strict'; return (${input.expression})`)() + return `${input.expression} = ${result}` + } catch (e: any) { + return { data: `Error: ${e.message}`, is_error: true } + } + }, +}) + +async function main() { + console.log('--- Example 7: Custom Tools ---\n') + + const builtinTools = getAllBaseTools() + const allTools = [...builtinTools, weatherTool, calculatorTool] + + const agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 10, + tools: allTools, + }) + + console.log(`Loaded ${allTools.length} tools (${builtinTools.length} built-in + 2 custom)\n`) + + for await (const event of agent.query( + 'What is the weather in Tokyo and London? Also calculate 2**10 * 3. Be brief.', + )) { + const msg = event as any + if (msg.type === 'assistant') { + for (const block of msg.message?.content || []) { + if (block.type === 'tool_use') { + console.log(`[${block.name}] ${JSON.stringify(block.input)}`) + } + if (block.type === 'text' && block.text.trim()) { + console.log(`\n${block.text}`) + } + } + } + if (msg.type === 'result') { + console.log(`\n--- ${msg.subtype} ---`) + } + } +} + +main().catch(console.error) diff --git a/examples/08-official-api-compat.ts b/examples/08-official-api-compat.ts new file mode 100644 index 0000000..35e4558 --- /dev/null +++ b/examples/08-official-api-compat.ts @@ -0,0 +1,38 @@ +/** + * Example 8: Official SDK-Compatible API + * + * Demonstrates the query() function with the same API pattern + * as open-agent-sdk. Drop-in compatible. + * + * Run: npx tsx examples/08-official-api-compat.ts + */ +import { query } from '../src/index.js' + +async function main() { + console.log('--- Example 8: Official SDK-Compatible API ---\n') + + // Standard SDK query pattern + for await (const message of query({ + prompt: 'What files are in this directory? Be brief.', + options: { + allowedTools: ['Bash', 'Glob'], + permissionMode: 'bypassPermissions', + }, + })) { + const msg = message as any + + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if ('text' in block && block.text) { + console.log(block.text) + } else if ('name' in block) { + console.log(`Tool: ${block.name}`) + } + } + } else if (msg.type === 'result') { + console.log(`\nDone: ${msg.subtype}`) + } + } +} + +main().catch(console.error) diff --git a/examples/09-subagents.ts b/examples/09-subagents.ts new file mode 100644 index 0000000..73e52a9 --- /dev/null +++ b/examples/09-subagents.ts @@ -0,0 +1,48 @@ +/** + * Example 9: Subagents + * + * Define specialized subagents that the main agent can delegate + * tasks to. Matches the official SDK's agents option. + * + * Run: npx tsx examples/09-subagents.ts + */ +import { query } from '../src/index.js' + +async function main() { + console.log('--- Example 9: Subagents ---\n') + + for await (const message of query({ + prompt: 'Use the code-reviewer agent to review src/agent.ts', + options: { + allowedTools: ['Read', 'Glob', 'Grep', 'Agent'], + agents: { + 'code-reviewer': { + description: 'Expert code reviewer for quality and security reviews.', + prompt: + 'Analyze code quality and suggest improvements. Focus on ' + + 'security, performance, and maintainability. Be concise.', + tools: ['Read', 'Glob', 'Grep'], + }, + }, + }, + })) { + const msg = message as any + + if (msg.type === 'assistant') { + for (const block of msg.message?.content || []) { + if ('text' in block && block.text?.trim()) { + console.log(block.text) + } + if ('name' in block) { + console.log(`[${block.name}] ${JSON.stringify(block.input || {}).slice(0, 80)}`) + } + } + } + + if (msg.type === 'result') { + console.log(`\n--- ${msg.subtype} ---`) + } + } +} + +main().catch(console.error) diff --git a/examples/10-permissions.ts b/examples/10-permissions.ts new file mode 100644 index 0000000..27dce79 --- /dev/null +++ b/examples/10-permissions.ts @@ -0,0 +1,40 @@ +/** + * Example 10: Permissions and Allowed Tools + * + * Shows how to restrict which tools the agent can use. + * Creates a read-only agent that can analyze but not modify code. + * + * Run: npx tsx examples/10-permissions.ts + */ +import { query } from '../src/index.js' + +async function main() { + console.log('--- Example 10: Read-Only Agent ---\n') + + // Read-only agent: can only use Read, Glob, Grep + for await (const message of query({ + prompt: 'Review the code in src/agent.ts for best practices. Be concise.', + options: { + allowedTools: ['Read', 'Glob', 'Grep'], + }, + })) { + const msg = message as any + + if (msg.type === 'assistant') { + for (const block of msg.message?.content || []) { + if ('text' in block && block.text?.trim()) { + console.log(block.text) + } + if ('name' in block) { + console.log(`[${block.name}]`) + } + } + } + + if (msg.type === 'result') { + console.log(`\n--- ${msg.subtype} ---`) + } + } +} + +main().catch(console.error) diff --git a/examples/11-custom-mcp-tools.ts b/examples/11-custom-mcp-tools.ts new file mode 100644 index 0000000..3cbc142 --- /dev/null +++ b/examples/11-custom-mcp-tools.ts @@ -0,0 +1,101 @@ +/** + * Example 11: Custom Tools with tool() + createSdkMcpServer() + * + * Shows the Zod-based tool() helper and in-process MCP server creation. + * This is the recommended way to add custom tools. + * + * Run: npx tsx examples/11-custom-mcp-tools.ts + */ +import { z } from 'zod' +import { query, tool, createSdkMcpServer } from '../src/index.js' + +// Define tools using Zod schemas for type-safe input validation +const getTemperature = tool( + 'get_temperature', + 'Get the current temperature at a location', + { + city: z.string().describe('City name'), + unit: z.enum(['celsius', 'fahrenheit']).default('celsius').describe('Temperature unit'), + }, + async ({ city, unit }) => { + // Mock weather data + const temps: Record = { + tokyo: 22, london: 14, paris: 16, 'new york': 18, beijing: 25, + } + const tempC = temps[city.toLowerCase()] ?? 20 + const temp = unit === 'fahrenheit' ? tempC * 9 / 5 + 32 : tempC + const symbol = unit === 'fahrenheit' ? '°F' : '°C' + + return { + content: [{ type: 'text' as const, text: `Temperature in ${city}: ${temp}${symbol}` }], + } + }, + { annotations: { readOnlyHint: true } }, +) + +const convertUnits = tool( + 'convert_units', + 'Convert between measurement units', + { + value: z.number().describe('Value to convert'), + from_unit: z.string().describe('Source unit'), + to_unit: z.string().describe('Target unit'), + }, + async ({ value, from_unit, to_unit }) => { + const conversions: Record number>> = { + km: { miles: (v) => v * 0.621371, m: (v) => v * 1000 }, + miles: { km: (v) => v * 1.60934, m: (v) => v * 1609.34 }, + kg: { lbs: (v) => v * 2.20462, g: (v) => v * 1000 }, + lbs: { kg: (v) => v * 0.453592, g: (v) => v * 453.592 }, + } + + const fn = conversions[from_unit]?.[to_unit] + if (!fn) { + return { + content: [{ type: 'text' as const, text: `Cannot convert from ${from_unit} to ${to_unit}` }], + isError: true, + } + } + + const result = fn(value) + return { + content: [{ type: 'text' as const, text: `${value} ${from_unit} = ${result.toFixed(2)} ${to_unit}` }], + } + }, +) + +// Bundle tools into an in-process MCP server +const utilityServer = createSdkMcpServer({ + name: 'utilities', + version: '1.0.0', + tools: [getTemperature, convertUnits], +}) + +async function main() { + console.log('--- Example 11: Custom MCP Tools (tool + createSdkMcpServer) ---\n') + + for await (const message of query({ + prompt: 'What is the temperature in Tokyo and Paris? Also convert 10 km to miles. Be brief.', + options: { + mcpServers: { utilities: utilityServer as any }, + allowedTools: ['mcp__utilities__*'], + permissionMode: 'bypassPermissions', + }, + })) { + const msg = message as any + + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if ('text' in block && block.text?.trim()) { + console.log(block.text) + } else if ('name' in block) { + console.log(`[${block.name}] ${JSON.stringify(block.input || {})}`) + } + } + } else if (msg.type === 'result') { + console.log(`\nDone: ${msg.subtype} (cost: $${msg.total_cost_usd?.toFixed(4) || '0'})`) + } + } +} + +main().catch(console.error) diff --git a/examples/web/index.html b/examples/web/index.html new file mode 100644 index 0000000..fad4421 --- /dev/null +++ b/examples/web/index.html @@ -0,0 +1,365 @@ + + + + + +Open Agent SDK + + + + +
+

Open Agent SDK

+ +
+ +
+
+
+

What can I do for you?

+
+ + + + +
+
+
+
+ +
+
+ + +
+
+ + + + diff --git a/examples/web/server.ts b/examples/web/server.ts new file mode 100644 index 0000000..7428cb4 --- /dev/null +++ b/examples/web/server.ts @@ -0,0 +1,157 @@ +/** + * Web Chat Server + * + * A lightweight HTTP server providing: + * GET / — serves the chat UI + * POST /api/chat — SSE stream of agent events + * POST /api/new — resets the session + * + * Run: npx tsx examples/web/server.ts + */ + +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { readFile } from 'fs/promises' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { createAgent, type Agent } from '../../src/index.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PORT = parseInt(process.env.PORT || '8081') + +let agent: Agent | null = null + +function getOrCreateAgent(): Agent { + if (!agent) { + agent = createAgent({ + model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + maxTurns: 20, + }) + } + return agent +} + +function resetAgent(): void { + agent?.close().catch(() => {}) + agent = null +} + +/** Read the full request body as a string. */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on('data', (c: Buffer) => chunks.push(c)) + req.on('end', () => resolve(Buffer.concat(chunks).toString())) + req.on('error', reject) + }) +} + +/** Handle POST /api/chat — SSE stream */ +async function handleChat(req: IncomingMessage, res: ServerResponse) { + const body = JSON.parse(await readBody(req)) + const prompt = body.message?.trim() + if (!prompt) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'empty message' })) + return + } + + // SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + const send = (event: string, data: unknown) => { + res.write(`data: ${JSON.stringify({ event, data })}\n\n`) + } + + const ag = getOrCreateAgent() + const startMs = Date.now() + + try { + for await (const ev of ag.query(prompt)) { + switch (ev.type) { + case 'assistant': { + for (const block of ev.message.content) { + if (block.type === 'text') { + send('text', { text: block.text }) + } else if (block.type === 'tool_use') { + send('tool_use', { + id: block.id, + name: block.name, + input: block.input, + }) + } else if ('thinking' in block) { + send('thinking', { thinking: (block as any).thinking }) + } + } + break + } + case 'tool_result': + send('tool_result', { + tool_use_id: ev.result.tool_use_id, + content: ev.result.output, + is_error: false, + }) + break + case 'result': + send('result', { + num_turns: ev.num_turns ?? 0, + input_tokens: ev.usage?.input_tokens ?? 0, + output_tokens: ev.usage?.output_tokens ?? 0, + cost: ev.total_cost_usd ?? ev.cost ?? 0, + duration_ms: Date.now() - startMs, + }) + break + } + } + } catch (err: any) { + send('error', { message: err.message }) + } + + send('done', null) + res.end() +} + +/** Handle POST /api/new */ +function handleNewSession(_req: IncomingMessage, res: ServerResponse) { + resetAgent() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) +} + +/** Serve the static index.html */ +async function serveIndex(_req: IncomingMessage, res: ServerResponse) { + const html = await readFile(join(__dirname, 'index.html'), 'utf-8') + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(html) +} + +// --- HTTP Server --- + +const server = createServer(async (req, res) => { + const url = req.url || '/' + const method = req.method || 'GET' + + try { + if (url === '/' && method === 'GET') return await serveIndex(req, res) + if (url === '/api/chat' && method === 'POST') return await handleChat(req, res) + if (url === '/api/new' && method === 'POST') return handleNewSession(req, res) + + res.writeHead(404) + res.end('Not Found') + } catch (err: any) { + console.error(err) + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + } + res.end(JSON.stringify({ error: err.message })) + } +}) + +server.listen(PORT, () => { + console.log(`\n Open Agent SDK — Web Chat`) + console.log(` http://localhost:${PORT}\n`) +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..19c3f30 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1732 @@ +{ + "name": "@anthropic-ai/claude-agent-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@anthropic-ai/claude-agent-sdk", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.23.0", + "zod-to-json-schema": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", + "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz", + "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..55ad05a --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "@codeany/open-agent-sdk", + "author": { + "name": "CodeAny", + "url": "https://codeany.ai" + }, + "homepage": "https://github.com/codeany-ai/open-agent-sdk-typescript", + "repository": { + "type": "git", + "url": "https://github.com/codeany-ai/open-agent-sdk-typescript.git" + }, + "bugs": { + "url": "https://github.com/codeany-ai/open-agent-sdk-typescript/issues" + }, + "version": "0.1.0", + "description": "Open-source Agent SDK. Runs the full agent loop in-process — no local CLI required. Deploy anywhere: cloud, serverless, Docker, CI/CD.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "npx tsx examples/01-simple-query.ts", + "test:all": "for f in examples/*.ts; do echo \"--- Running $f ---\"; npx tsx $f; echo; done", + "web": "npx tsx examples/web/server.ts" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.23.0", + "zod-to-json-schema": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "keywords": [ + "open-agent-sdk", + "codeany", + "agent", + "sdk", + "ai", + "llm", + "tools", + "agentic", + "coding-agent", + "mcp" + ], + "license": "MIT" +} diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..8e93de7 --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,425 @@ +/** + * Agent - High-level API + * + * Provides createAgent() and query() interfaces compatible with + * open-agent-sdk. + * + * Usage: + * import { createAgent } from 'open-agent-sdk' + * const agent = createAgent({ model: 'claude-sonnet-4-6' }) + * for await (const event of agent.query('Hello')) { ... } + */ + +import type { + AgentOptions, + QueryResult, + SDKMessage, + ToolDefinition, + CanUseToolFn, + Message, + TokenUsage, + PermissionMode, +} from './types.js' +import { QueryEngine } from './engine.js' +import { getAllBaseTools, filterTools } from './tools/index.js' +import { connectMCPServer, type MCPConnection } from './mcp/client.js' +import { isSdkServerConfig } from './sdk-mcp-server.js' +import { registerAgents } from './tools/agent-tool.js' +import { + saveSession, + loadSession, +} from './session.js' +import type Anthropic from '@anthropic-ai/sdk' + +// -------------------------------------------------------------------------- +// Agent class +// -------------------------------------------------------------------------- + +export class Agent { + private cfg: AgentOptions + private toolPool: ToolDefinition[] + private modelId: string + private apiCredentials: { key?: string; baseUrl?: string } + private mcpLinks: MCPConnection[] = [] + private history: Anthropic.MessageParam[] = [] + private messageLog: Message[] = [] + private setupDone: Promise + private sid: string + private abortCtrl: AbortController | null = null + private currentEngine: QueryEngine | null = null + + constructor(options: AgentOptions = {}) { + // Shallow copy to avoid mutating caller's object + this.cfg = { ...options } + + // Merge credentials from options.env map, direct options, and process.env + this.apiCredentials = this.pickCredentials() + this.modelId = this.cfg.model ?? this.readEnv('CODEANY_MODEL') ?? 'claude-sonnet-4-6' + this.sid = this.cfg.sessionId ?? crypto.randomUUID() + + // The underlying @anthropic-ai/sdk reads ANTHROPIC_API_KEY from process.env, + // so we bridge our resolved credentials into it. + if (this.apiCredentials.key) { + process.env.ANTHROPIC_API_KEY = this.apiCredentials.key + } + if (this.apiCredentials.baseUrl) { + process.env.ANTHROPIC_BASE_URL = this.apiCredentials.baseUrl + } + + // Build tool pool from options (supports ToolDefinition[], string[], or preset) + this.toolPool = this.buildToolPool() + + // Kick off async setup (MCP connections, agent registration, session resume) + this.setupDone = this.setup() + } + + /** Pick API key and base URL from options or CODEANY_* env vars. */ + private pickCredentials(): { key?: string; baseUrl?: string } { + const envMap = this.cfg.env + return { + key: + this.cfg.apiKey ?? + envMap?.CODEANY_API_KEY ?? + envMap?.CODEANY_AUTH_TOKEN ?? + this.readEnv('CODEANY_API_KEY') ?? + this.readEnv('CODEANY_AUTH_TOKEN'), + baseUrl: + this.cfg.baseURL ?? + envMap?.CODEANY_BASE_URL ?? + this.readEnv('CODEANY_BASE_URL'), + } + } + + /** Read a value from process.env (returns undefined if missing). */ + private readEnv(key: string): string | undefined { + return process.env[key] || undefined + } + + /** Assemble the available tool set based on options. */ + private buildToolPool(): ToolDefinition[] { + const raw = this.cfg.tools + let pool: ToolDefinition[] + + if (!raw || (typeof raw === 'object' && !Array.isArray(raw) && 'type' in raw)) { + pool = getAllBaseTools() + } else if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string') { + pool = filterTools(getAllBaseTools(), raw as string[]) + } else { + pool = raw as ToolDefinition[] + } + + return filterTools(pool, this.cfg.allowedTools, this.cfg.disallowedTools) + } + + /** + * Async initialization: connect MCP servers, register agents, resume sessions. + */ + private async setup(): Promise { + // Register custom agent definitions + if (this.cfg.agents) { + registerAgents(this.cfg.agents) + } + + // Connect MCP servers (supports stdio, SSE, HTTP, and in-process SDK servers) + if (this.cfg.mcpServers) { + for (const [name, config] of Object.entries(this.cfg.mcpServers)) { + try { + if (isSdkServerConfig(config)) { + // In-process SDK MCP server - directly add tools + this.toolPool = [...this.toolPool, ...config.tools] + } else { + // External MCP server + const connection = await connectMCPServer(name, config) + this.mcpLinks.push(connection) + + if (connection.status === 'connected' && connection.tools.length > 0) { + this.toolPool = [...this.toolPool, ...connection.tools] + } + } + } catch (err: any) { + console.error(`[MCP] Failed to connect to "${name}": ${err.message}`) + } + } + } + + // Resume or continue session + if (this.cfg.resume) { + const sessionData = await loadSession(this.cfg.resume) + if (sessionData) { + this.history = sessionData.messages + this.sid = this.cfg.resume + } + } + } + + /** + * Run a query with streaming events. + */ + async *query( + prompt: string, + overrides?: Partial, + ): AsyncGenerator { + await this.setupDone + + const opts = { ...this.cfg, ...overrides } + const cwd = opts.cwd || process.cwd() + + // Create abort controller for this query + this.abortCtrl = opts.abortController || new AbortController() + if (opts.abortSignal) { + opts.abortSignal.addEventListener('abort', () => this.abortCtrl?.abort(), { once: true }) + } + + // Resolve systemPrompt (handle preset object) + let systemPrompt: string | undefined + let appendSystemPrompt = opts.appendSystemPrompt + if (typeof opts.systemPrompt === 'object' && opts.systemPrompt?.type === 'preset') { + systemPrompt = undefined // Use engine default (default style) + if (opts.systemPrompt.append) { + appendSystemPrompt = (appendSystemPrompt || '') + '\n' + opts.systemPrompt.append + } + } else { + systemPrompt = opts.systemPrompt as string | undefined + } + + // Build canUseTool based on permission mode + const permMode = opts.permissionMode ?? 'bypassPermissions' + const canUseTool: CanUseToolFn = opts.canUseTool ?? (async (_tool, _input) => { + if (permMode === 'bypassPermissions' || permMode === 'dontAsk' || permMode === 'auto') { + return { behavior: 'allow' } + } + if (permMode === 'acceptEdits') { + return { behavior: 'allow' } + } + return { behavior: 'allow' } + }) + + // Resolve tools with overrides + let tools = this.toolPool + if (overrides?.allowedTools || overrides?.disallowedTools) { + tools = filterTools(tools, overrides.allowedTools, overrides.disallowedTools) + } + if (overrides?.tools) { + const ot = overrides.tools + if (Array.isArray(ot) && ot.length > 0 && typeof ot[0] === 'string') { + tools = filterTools(this.toolPool, ot as string[]) + } else if (Array.isArray(ot)) { + tools = ot as ToolDefinition[] + } + } + + // Create query engine with current conversation state + const engine = new QueryEngine({ + cwd, + model: opts.model || this.modelId, + apiKey: this.apiCredentials.key, + baseURL: this.apiCredentials.baseUrl, + tools, + systemPrompt, + appendSystemPrompt, + maxTurns: opts.maxTurns ?? 10, + maxBudgetUsd: opts.maxBudgetUsd, + maxTokens: opts.maxTokens ?? 16384, + thinking: opts.thinking, + jsonSchema: opts.jsonSchema, + canUseTool, + includePartialMessages: opts.includePartialMessages ?? false, + abortSignal: this.abortCtrl.signal, + agents: opts.agents, + }) + this.currentEngine = engine + + // Inject existing conversation history + for (const msg of this.history) { + (engine as any).messages.push(msg) + } + + // Run the engine + for await (const event of engine.submitMessage(prompt)) { + yield event + + // Track assistant messages for multi-turn persistence + if (event.type === 'assistant') { + const uuid = crypto.randomUUID() + const timestamp = new Date().toISOString() + this.messageLog.push({ + type: 'assistant', + message: event.message, + uuid, + timestamp, + }) + } + } + + // Persist conversation state for multi-turn + this.history = engine.getMessages() + + // Add user message to tracked messages + const userUuid = crypto.randomUUID() + this.messageLog.push({ + type: 'user', + message: { role: 'user', content: prompt }, + uuid: userUuid, + timestamp: new Date().toISOString(), + }) + } + + /** + * Convenience method: send a prompt and collect the final answer as a single object. + * Internally iterates through the streaming query and aggregates the outcome. + */ + async prompt( + text: string, + overrides?: Partial, + ): Promise { + const t0 = performance.now() + const collected = { text: '', turns: 0, tokens: { in: 0, out: 0 } } + + for await (const ev of this.query(text, overrides)) { + switch (ev.type) { + case 'assistant': { + // Extract the last assistant text (multi-turn: only final answer matters) + const fragments = ev.message.content + .filter((c): c is Anthropic.TextBlock => c.type === 'text') + .map((c) => c.text) + if (fragments.length) collected.text = fragments.join('') + break + } + case 'result': + collected.turns = ev.num_turns ?? 0 + collected.tokens.in = ev.usage?.input_tokens ?? 0 + collected.tokens.out = ev.usage?.output_tokens ?? 0 + break + } + } + + return { + text: collected.text, + usage: { input_tokens: collected.tokens.in, output_tokens: collected.tokens.out }, + num_turns: collected.turns, + duration_ms: Math.round(performance.now() - t0), + messages: [...this.messageLog], + } + } + + /** + * Get conversation messages. + */ + getMessages(): Message[] { + return [...this.messageLog] + } + + /** + * Reset conversation history. + */ + clear(): void { + this.history = [] + this.messageLog = [] + } + + /** + * Interrupt the current query. + */ + async interrupt(): Promise { + this.abortCtrl?.abort() + } + + /** + * Change the model during a session. + */ + async setModel(model?: string): Promise { + if (model) { + this.modelId = model + this.cfg.model = model + } + } + + /** + * Change the permission mode during a session. + */ + async setPermissionMode(mode: PermissionMode): Promise { + this.cfg.permissionMode = mode + } + + /** + * Set maximum thinking tokens. + */ + async setMaxThinkingTokens(maxThinkingTokens: number | null): Promise { + if (maxThinkingTokens === null) { + this.cfg.thinking = { type: 'disabled' } + } else { + this.cfg.thinking = { type: 'enabled', budgetTokens: maxThinkingTokens } + } + } + + /** + * Get the session ID. + */ + getSessionId(): string { + return this.sid + } + + /** + * Stop a background task. + */ + async stopTask(taskId: string): Promise { + const { getTask } = await import('./tools/task-tools.js') + const task = getTask(taskId) + if (task) { + task.status = 'cancelled' + } + } + + /** + * Close MCP connections and clean up. + * Optionally persist session to disk. + */ + async close(): Promise { + // Persist session if enabled + if (this.cfg.persistSession !== false && this.history.length > 0) { + try { + await saveSession(this.sid, this.history, { + cwd: this.cfg.cwd || process.cwd(), + model: this.modelId, + summary: undefined, + }) + } catch { + // Session persistence is best-effort + } + } + + for (const conn of this.mcpLinks) { + await conn.close() + } + this.mcpLinks = [] + } +} + +// -------------------------------------------------------------------------- +// Factory function +// -------------------------------------------------------------------------- + +/** Factory: shorthand for `new Agent(options)`. */ +export function createAgent(options: AgentOptions = {}): Agent { + return new Agent(options) +} + +// -------------------------------------------------------------------------- +// Standalone query — one-shot convenience wrapper +// -------------------------------------------------------------------------- + +/** + * Execute a single agentic query without managing an Agent instance. + * The agent is created, used, and cleaned up automatically. + */ +export async function* query(params: { + prompt: string + options?: AgentOptions +}): AsyncGenerator { + const ephemeral = createAgent(params.options) + try { + yield* ephemeral.query(params.prompt) + } finally { + await ephemeral.close() + } +} diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..1fa8573 --- /dev/null +++ b/src/engine.ts @@ -0,0 +1,520 @@ +/** + * QueryEngine - Core agentic loop + * + * Manages the full conversation lifecycle: + * 1. Take user prompt + * 2. Build system prompt with context (git status, project context, tools) + * 3. Call LLM API with tools + * 4. Stream response + * 5. Execute tool calls (concurrent for read-only, serial for mutations) + * 6. Send results back, repeat until done + * 7. Auto-compact when context exceeds threshold + * 8. Retry with exponential backoff on transient errors + */ + +import Anthropic from '@anthropic-ai/sdk' +import type { + SDKMessage, + QueryEngineConfig, + ToolDefinition, + ToolResult, + ToolContext, + TokenUsage, +} from './types.js' +import { toApiTool } from './tools/types.js' +import { + estimateMessagesTokens, + estimateCost, + getAutoCompactThreshold, +} from './utils/tokens.js' +import { + shouldAutoCompact, + compactConversation, + microCompactMessages, + createAutoCompactState, + type AutoCompactState, +} from './utils/compact.js' +import { + withRetry, + isPromptTooLongError, + formatApiError, +} from './utils/retry.js' +import { getSystemContext, getUserContext } from './utils/context.js' +import { normalizeMessagesForAPI } from './utils/messages.js' + +// ============================================================================ +// System Prompt Builder +// ============================================================================ + +async function buildSystemPrompt(config: QueryEngineConfig): Promise { + if (config.systemPrompt) { + const base = config.systemPrompt + return config.appendSystemPrompt + ? base + '\n\n' + config.appendSystemPrompt + : base + } + + const parts: string[] = [] + + parts.push( + 'You are an AI assistant with access to tools. Use the tools provided to help the user accomplish their tasks.', + 'You should use tools when they would help you complete the task more accurately or efficiently.', + ) + + // List available tools with descriptions + parts.push('\n# Available Tools\n') + for (const tool of config.tools) { + parts.push(`- **${tool.name}**: ${tool.description}`) + } + + // Add agent definitions + if (config.agents && Object.keys(config.agents).length > 0) { + parts.push('\n# Available Subagents\n') + for (const [name, def] of Object.entries(config.agents)) { + parts.push(`- **${name}**: ${def.description}`) + } + } + + // System context (git status, etc.) + try { + const sysCtx = await getSystemContext(config.cwd) + if (sysCtx) { + parts.push('\n# Environment\n') + parts.push(sysCtx) + } + } catch { + // Context is best-effort + } + + // User context (AGENT.md, date) + try { + const userCtx = await getUserContext(config.cwd) + if (userCtx) { + parts.push('\n# Project Context\n') + parts.push(userCtx) + } + } catch { + // Context is best-effort + } + + // Working directory + parts.push(`\n# Working Directory\n${config.cwd}`) + + if (config.appendSystemPrompt) { + parts.push('\n' + config.appendSystemPrompt) + } + + return parts.join('\n') +} + +// ============================================================================ +// QueryEngine +// ============================================================================ + +export class QueryEngine { + private config: QueryEngineConfig + private client: Anthropic + public messages: Anthropic.MessageParam[] = [] + private totalUsage: TokenUsage = { input_tokens: 0, output_tokens: 0 } + private totalCost = 0 + private turnCount = 0 + private compactState: AutoCompactState + private sessionId: string + private apiTimeMs = 0 + + constructor(config: QueryEngineConfig) { + this.config = config + this.client = new Anthropic({ + apiKey: config.apiKey, + baseURL: config.baseURL, + }) + this.compactState = createAutoCompactState() + this.sessionId = crypto.randomUUID() + } + + /** + * Submit a user message and run the agentic loop. + * Yields SDKMessage events as the agent works. + */ + async *submitMessage( + prompt: string | Anthropic.ContentBlockParam[], + ): AsyncGenerator { + // Add user message + this.messages.push({ role: 'user', content: prompt }) + + // Build tool definitions for API + const tools = this.config.tools.map(toApiTool) + + // Build system prompt + const systemPrompt = await buildSystemPrompt(this.config) + + // Emit init system message + yield { + type: 'system', + subtype: 'init', + session_id: this.sessionId, + tools: this.config.tools.map(t => t.name), + model: this.config.model, + cwd: this.config.cwd, + mcp_servers: [], + permission_mode: 'bypassPermissions', + } as SDKMessage + + // Agentic loop + let turnsRemaining = this.config.maxTurns + let budgetExceeded = false + let maxOutputRecoveryAttempts = 0 + const MAX_OUTPUT_RECOVERY = 3 + + while (turnsRemaining > 0) { + if (this.config.abortSignal?.aborted) break + + // Check budget + if (this.config.maxBudgetUsd && this.totalCost >= this.config.maxBudgetUsd) { + budgetExceeded = true + break + } + + // Auto-compact if context is too large + if (shouldAutoCompact(this.messages, this.config.model, this.compactState)) { + try { + const result = await compactConversation( + this.client, + this.config.model, + this.messages, + this.compactState, + ) + this.messages = result.compactedMessages + this.compactState = result.state + } catch { + // Continue with uncompacted messages + } + } + + // Micro-compact: truncate large tool results + const apiMessages = microCompactMessages( + normalizeMessagesForAPI(this.messages), + ) + + this.turnCount++ + turnsRemaining-- + + // Make API call with retry + let response: Anthropic.Message + const apiStart = performance.now() + try { + response = await withRetry( + async () => { + const requestParams: Anthropic.MessageCreateParamsNonStreaming = { + model: this.config.model, + max_tokens: this.config.maxTokens, + system: systemPrompt, + messages: apiMessages, + tools: tools.length > 0 ? tools : undefined, + } + + // Add thinking if configured + if ( + this.config.thinking?.type === 'enabled' && + this.config.thinking.budgetTokens + ) { + (requestParams as any).thinking = { + type: 'enabled', + budget_tokens: this.config.thinking.budgetTokens, + } + } + + return this.client.messages.create(requestParams) + }, + undefined, + this.config.abortSignal, + ) + } catch (err: any) { + // Handle prompt-too-long by compacting + if (isPromptTooLongError(err) && !this.compactState.compacted) { + try { + const result = await compactConversation( + this.client, + this.config.model, + this.messages, + this.compactState, + ) + this.messages = result.compactedMessages + this.compactState = result.state + turnsRemaining++ // Retry this turn + this.turnCount-- + continue + } catch { + // Can't compact, give up + } + } + + yield { + type: 'result', + subtype: 'error', + usage: this.totalUsage, + num_turns: this.turnCount, + cost: this.totalCost, + } + return + } + + // Track API timing + this.apiTimeMs += performance.now() - apiStart + + // Track usage + if (response.usage) { + this.totalUsage.input_tokens += response.usage.input_tokens + this.totalUsage.output_tokens += response.usage.output_tokens + if ('cache_creation_input_tokens' in response.usage) { + this.totalUsage.cache_creation_input_tokens = + (this.totalUsage.cache_creation_input_tokens || 0) + + ((response.usage as any).cache_creation_input_tokens || 0) + } + if ('cache_read_input_tokens' in response.usage) { + this.totalUsage.cache_read_input_tokens = + (this.totalUsage.cache_read_input_tokens || 0) + + ((response.usage as any).cache_read_input_tokens || 0) + } + this.totalCost += estimateCost(this.config.model, response.usage as TokenUsage) + } + + // Add assistant message to conversation + this.messages.push({ role: 'assistant', content: response.content }) + + // Yield assistant message + yield { + type: 'assistant', + message: { + role: 'assistant', + content: response.content, + }, + } + + // Handle max_output_tokens recovery + if ( + response.stop_reason === 'max_tokens' && + maxOutputRecoveryAttempts < MAX_OUTPUT_RECOVERY + ) { + maxOutputRecoveryAttempts++ + // Add continuation prompt + this.messages.push({ + role: 'user', + content: 'Please continue from where you left off.', + }) + continue + } + + // Check for tool use + const toolUseBlocks = response.content.filter( + (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use', + ) + + if (toolUseBlocks.length === 0) { + break // No tool calls - agent is done + } + + // Reset max_output recovery counter on successful tool use + maxOutputRecoveryAttempts = 0 + + // Execute tools (concurrent read-only, serial mutations) + const toolResults = await this.executeTools(toolUseBlocks) + + // Yield tool results + for (const result of toolResults) { + yield { + type: 'tool_result', + result: { + tool_use_id: result.tool_use_id, + tool_name: result.tool_name || '', + output: + typeof result.content === 'string' + ? result.content + : JSON.stringify(result.content), + }, + } + } + + // Add tool results to conversation + this.messages.push({ + role: 'user', + content: toolResults.map((r) => ({ + type: 'tool_result' as const, + tool_use_id: r.tool_use_id, + content: + typeof r.content === 'string' + ? r.content + : JSON.stringify(r.content), + is_error: r.is_error, + })), + }) + + if (response.stop_reason === 'end_turn') break + } + + // Yield enriched final result + const endSubtype = budgetExceeded + ? 'error_max_budget_usd' + : turnsRemaining <= 0 + ? 'error_max_turns' + : 'success' + + yield { + type: 'result', + subtype: endSubtype, + session_id: this.sessionId, + is_error: endSubtype !== 'success', + num_turns: this.turnCount, + total_cost_usd: this.totalCost, + duration_api_ms: Math.round(this.apiTimeMs), + usage: this.totalUsage, + model_usage: { [this.config.model]: { input_tokens: this.totalUsage.input_tokens, output_tokens: this.totalUsage.output_tokens } }, + cost: this.totalCost, + } + } + + /** + * Execute tool calls with concurrency control. + * + * Read-only tools run concurrently (up to 10 at a time). + * Mutation tools run sequentially. + */ + private async executeTools( + toolUseBlocks: Anthropic.ToolUseBlock[], + ): Promise<(ToolResult & { tool_name?: string })[]> { + const context: ToolContext = { + cwd: this.config.cwd, + abortSignal: this.config.abortSignal, + } + + const MAX_CONCURRENCY = parseInt( + process.env.AGENT_SDK_MAX_TOOL_CONCURRENCY || '10', + ) + + // Partition into read-only (concurrent) and mutation (serial) + const readOnly: Array<{ block: Anthropic.ToolUseBlock; tool?: ToolDefinition }> = [] + const mutations: Array<{ block: Anthropic.ToolUseBlock; tool?: ToolDefinition }> = [] + + for (const block of toolUseBlocks) { + const tool = this.config.tools.find((t) => t.name === block.name) + if (tool?.isReadOnly?.()) { + readOnly.push({ block, tool }) + } else { + mutations.push({ block, tool }) + } + } + + const results: (ToolResult & { tool_name?: string })[] = [] + + // Execute read-only tools concurrently (batched by MAX_CONCURRENCY) + for (let i = 0; i < readOnly.length; i += MAX_CONCURRENCY) { + const batch = readOnly.slice(i, i + MAX_CONCURRENCY) + const batchResults = await Promise.all( + batch.map((item) => + this.executeSingleTool(item.block, item.tool, context), + ), + ) + results.push(...batchResults) + } + + // Execute mutation tools sequentially + for (const item of mutations) { + const result = await this.executeSingleTool(item.block, item.tool, context) + results.push(result) + } + + return results + } + + /** + * Execute a single tool with permission checking. + */ + private async executeSingleTool( + block: Anthropic.ToolUseBlock, + tool: ToolDefinition | undefined, + context: ToolContext, + ): Promise { + if (!tool) { + return { + type: 'tool_result', + tool_use_id: block.id, + content: `Error: Unknown tool "${block.name}"`, + is_error: true, + tool_name: block.name, + } + } + + // Check enabled + if (tool.isEnabled && !tool.isEnabled()) { + return { + type: 'tool_result', + tool_use_id: block.id, + content: `Error: Tool "${block.name}" is not enabled`, + is_error: true, + tool_name: block.name, + } + } + + // Check permissions + if (this.config.canUseTool) { + try { + const permission = await this.config.canUseTool(tool, block.input) + if (permission.behavior === 'deny') { + return { + type: 'tool_result', + tool_use_id: block.id, + content: permission.message || `Permission denied for tool "${block.name}"`, + is_error: true, + tool_name: block.name, + } + } + if (permission.updatedInput !== undefined) { + block = { ...block, input: permission.updatedInput as Record } + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: block.id, + content: `Permission check error: ${err.message}`, + is_error: true, + tool_name: block.name, + } + } + } + + // Execute the tool + try { + const result = await tool.call(block.input, context) + return { ...result, tool_use_id: block.id, tool_name: block.name } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: block.id, + content: `Tool execution error: ${err.message}`, + is_error: true, + tool_name: block.name, + } + } + } + + /** + * Get current messages for session persistence. + */ + getMessages(): Anthropic.MessageParam[] { + return [...this.messages] + } + + /** + * Get total usage across all turns. + */ + getUsage(): TokenUsage { + return { ...this.totalUsage } + } + + /** + * Get total cost. + */ + getCost(): number { + return this.totalCost + } +} diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..f7b95d0 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,261 @@ +/** + * Hook System + * + * Lifecycle hooks for intercepting agent behavior. + * Supports pre/post tool use, session lifecycle, and custom events. + * + * Hook events: + * - PreToolUse: before tool execution + * - PostToolUse: after tool execution + * - PostToolUseFailure: after tool failure + * - SessionStart: session initialization + * - SessionEnd: session cleanup + * - Stop: when turn completes + * - SubagentStart: subagent spawned + * - SubagentStop: subagent completed + * - UserPromptSubmit: user sends message + * - PermissionRequest: permission check triggered + * - TaskCreated: task created + * - TaskCompleted: task finished + * - ConfigChange: settings changed + * - CwdChanged: working directory changed + * - FileChanged: file modified + * - Notification: system notification + */ + +import { spawn, type ChildProcess } from 'child_process' + +/** + * All supported hook events. + */ +export const HOOK_EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'SubagentStart', + 'SubagentStop', + 'UserPromptSubmit', + 'PermissionRequest', + 'PermissionDenied', + 'TaskCreated', + 'TaskCompleted', + 'ConfigChange', + 'CwdChanged', + 'FileChanged', + 'Notification', + 'PreCompact', + 'PostCompact', + 'TeammateIdle', +] as const + +export type HookEvent = typeof HOOK_EVENTS[number] + +/** + * Hook definition. + */ +export interface HookDefinition { + /** Shell command or function to execute */ + command?: string + /** Function handler */ + handler?: (input: HookInput) => Promise + /** Tool name matcher (regex pattern) */ + matcher?: string + /** Timeout in milliseconds */ + timeout?: number +} + +/** + * Hook input passed to handlers. + */ +export interface HookInput { + event: HookEvent + toolName?: string + toolInput?: unknown + toolOutput?: unknown + toolUseId?: string + sessionId?: string + cwd?: string + error?: string + [key: string]: unknown +} + +/** + * Hook output returned by handlers. + */ +export interface HookOutput { + /** Message to append to conversation */ + message?: string + /** Permission update */ + permissionUpdate?: { + tool: string + behavior: 'allow' | 'deny' + } + /** Whether to block the action */ + block?: boolean + /** Notification */ + notification?: { + title: string + body: string + level?: 'info' | 'warning' | 'error' + } +} + +/** + * Hook configuration (from settings). + */ +export type HookConfig = Record + +/** + * Hook registry for managing and executing hooks. + */ +export class HookRegistry { + private hooks: Map = new Map() + + /** + * Register hooks from configuration. + */ + registerFromConfig(config: HookConfig): void { + for (const [event, definitions] of Object.entries(config)) { + const hookEvent = event as HookEvent + if (!HOOK_EVENTS.includes(hookEvent)) continue + + const existing = this.hooks.get(hookEvent) || [] + this.hooks.set(hookEvent, [...existing, ...definitions]) + } + } + + /** + * Register a single hook. + */ + register(event: HookEvent, definition: HookDefinition): void { + const existing = this.hooks.get(event) || [] + existing.push(definition) + this.hooks.set(event, existing) + } + + /** + * Execute hooks for an event. + */ + async execute( + event: HookEvent, + input: HookInput, + ): Promise { + const definitions = this.hooks.get(event) || [] + const results: HookOutput[] = [] + + for (const def of definitions) { + // Check matcher for tool-specific hooks + if (def.matcher && input.toolName) { + const regex = new RegExp(def.matcher) + if (!regex.test(input.toolName)) continue + } + + try { + let output: HookOutput | void = undefined + + if (def.handler) { + // Function handler + output = await Promise.race([ + def.handler(input), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Hook timeout')), def.timeout || 30000), + ), + ]) + } else if (def.command) { + // Shell command handler + output = await executeShellHook(def.command, input, def.timeout || 30000) + } + + if (output) { + results.push(output) + } + } catch (err: any) { + // Log but don't fail on hook errors + console.error(`[Hook] ${event} hook failed: ${err.message}`) + } + } + + return results + } + + /** + * Check if any hooks are registered for an event. + */ + hasHooks(event: HookEvent): boolean { + return (this.hooks.get(event)?.length || 0) > 0 + } + + /** + * Clear all hooks. + */ + clear(): void { + this.hooks.clear() + } +} + +/** + * Execute a shell command as a hook. + */ +async function executeShellHook( + command: string, + input: HookInput, + timeout: number, +): Promise { + return new Promise((resolve) => { + const proc = spawn('bash', ['-c', command], { + timeout, + env: { + ...process.env, + HOOK_EVENT: input.event, + HOOK_TOOL_NAME: input.toolName || '', + HOOK_SESSION_ID: input.sessionId || '', + HOOK_CWD: input.cwd || '', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + // Send input as JSON on stdin + proc.stdin?.write(JSON.stringify(input)) + proc.stdin?.end() + + const chunks: Buffer[] = [] + proc.stdout?.on('data', (d: Buffer) => chunks.push(d)) + + proc.on('close', (code) => { + if (code !== 0) { + resolve(undefined) + return + } + + const stdout = Buffer.concat(chunks).toString('utf-8').trim() + if (!stdout) { + resolve(undefined) + return + } + + try { + const output = JSON.parse(stdout) as HookOutput + resolve(output) + } catch { + // Non-JSON output treated as message + resolve({ message: stdout }) + } + }) + + proc.on('error', () => resolve(undefined)) + }) +} + +/** + * Create a default hook registry. + */ +export function createHookRegistry(config?: HookConfig): HookRegistry { + const registry = new HookRegistry() + if (config) { + registry.registerFromConfig(config) + } + return registry +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ccea65e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,376 @@ +/** + * @codeany/open-agent-sdk + * + * Open-source Agent SDK by CodeAny (https://codeany.ai). + * Runs the full agent loop in-process without spawning subprocesses. + * + * Features: + * - 30+ built-in tools (file I/O, shell, web, agents, tasks, teams, etc.) + * - MCP server integration (stdio, SSE, HTTP) + * - Context compression (auto-compact, micro-compact) + * - Retry with exponential backoff + * - Git status & project context injection + * - Multi-turn session persistence + * - Permission system (allow/deny/bypass modes) + * - Subagent spawning & team coordination + * - Task management & scheduling + * - Hook system (pre/post tool use, lifecycle events) + * - Token estimation & cost tracking + * - File state LRU caching + * - Plan mode for structured workflows + */ + +// -------------------------------------------------------------------------- +// High-level Agent API +// -------------------------------------------------------------------------- + +export { Agent, createAgent, query } from './agent.js' + +// -------------------------------------------------------------------------- +// Tool Helper (Zod-based tool creation, compatible with official SDK) +// -------------------------------------------------------------------------- + +export { tool, sdkToolToToolDefinition } from './tool-helper.js' +export type { + ToolAnnotations, + CallToolResult, + SdkMcpToolDefinition, +} from './tool-helper.js' + +// -------------------------------------------------------------------------- +// In-Process MCP Server +// -------------------------------------------------------------------------- + +export { createSdkMcpServer, isSdkServerConfig } from './sdk-mcp-server.js' +export type { McpSdkServerConfig } from './sdk-mcp-server.js' + +// -------------------------------------------------------------------------- +// Core Engine +// -------------------------------------------------------------------------- + +export { QueryEngine } from './engine.js' + +// -------------------------------------------------------------------------- +// Tool System (30+ tools) +// -------------------------------------------------------------------------- + +export { + // Registry + getAllBaseTools, + filterTools, + assembleToolPool, + + // Helpers + defineTool, + toApiTool, + + // Core file I/O & execution + BashTool, + FileReadTool, + FileWriteTool, + FileEditTool, + GlobTool, + GrepTool, + NotebookEditTool, + + // Web + WebFetchTool, + WebSearchTool, + + // Agent & Multi-agent + AgentTool, + SendMessageTool, + TeamCreateTool, + TeamDeleteTool, + + // Tasks + TaskCreateTool, + TaskListTool, + TaskUpdateTool, + TaskGetTool, + TaskStopTool, + TaskOutputTool, + + // Worktree + EnterWorktreeTool, + ExitWorktreeTool, + + // Planning + EnterPlanModeTool, + ExitPlanModeTool, + + // User interaction + AskUserQuestionTool, + + // Discovery + ToolSearchTool, + + // MCP Resources + ListMcpResourcesTool, + ReadMcpResourceTool, + + // Scheduling + CronCreateTool, + CronDeleteTool, + CronListTool, + RemoteTriggerTool, + + // LSP + LSPTool, + + // Config + ConfigTool, + + // Todo + TodoWriteTool, +} from './tools/index.js' + +// -------------------------------------------------------------------------- +// MCP Client +// -------------------------------------------------------------------------- + +export { connectMCPServer, closeAllConnections } from './mcp/client.js' +export type { MCPConnection } from './mcp/client.js' + +// -------------------------------------------------------------------------- +// Hook System +// -------------------------------------------------------------------------- + +export { + HookRegistry, + createHookRegistry, + HOOK_EVENTS, +} from './hooks.js' +export type { + HookEvent, + HookDefinition, + HookInput, + HookOutput, + HookConfig, +} from './hooks.js' + +// -------------------------------------------------------------------------- +// Session Management +// -------------------------------------------------------------------------- + +export { + saveSession, + loadSession, + listSessions, + forkSession, + getSessionMessages, + getSessionInfo, + renameSession, + tagSession, + appendToSession, + deleteSession, +} from './session.js' +export type { SessionMetadata, SessionData } from './session.js' + +// -------------------------------------------------------------------------- +// Context Utilities +// -------------------------------------------------------------------------- + +export { + getSystemContext, + getUserContext, + getGitStatus, + readProjectContextContent, + discoverProjectContextFiles, + clearContextCache, +} from './utils/context.js' + +// -------------------------------------------------------------------------- +// Message Utilities +// -------------------------------------------------------------------------- + +export { + createUserMessage, + createAssistantMessage, + normalizeMessagesForAPI, + stripImagesFromMessages, + extractTextFromContent, + createCompactBoundaryMessage, + truncateText, +} from './utils/messages.js' + +// -------------------------------------------------------------------------- +// Token Estimation & Cost +// -------------------------------------------------------------------------- + +export { + estimateTokens, + estimateMessagesTokens, + estimateSystemPromptTokens, + getTokenCountFromUsage, + getContextWindowSize, + getAutoCompactThreshold, + estimateCost, + MODEL_PRICING, + AUTOCOMPACT_BUFFER_TOKENS, +} from './utils/tokens.js' + +// -------------------------------------------------------------------------- +// Context Compression +// -------------------------------------------------------------------------- + +export { + shouldAutoCompact, + compactConversation, + microCompactMessages, + createAutoCompactState, +} from './utils/compact.js' +export type { AutoCompactState } from './utils/compact.js' + +// -------------------------------------------------------------------------- +// Retry Logic +// -------------------------------------------------------------------------- + +export { + withRetry, + isRetryableError, + isPromptTooLongError, + isAuthError, + isRateLimitError, + formatApiError, + getRetryDelay, + DEFAULT_RETRY_CONFIG, +} from './utils/retry.js' +export type { RetryConfig } from './utils/retry.js' + +// -------------------------------------------------------------------------- +// File State Cache +// -------------------------------------------------------------------------- + +export { + FileStateCache, + createFileStateCache, +} from './utils/fileCache.js' +export type { FileState } from './utils/fileCache.js' + +// -------------------------------------------------------------------------- +// Task & Team State (for advanced usage) +// -------------------------------------------------------------------------- + +export { + getAllTasks, + getTask, + clearTasks, +} from './tools/task-tools.js' +export type { Task, TaskStatus } from './tools/task-tools.js' + +export { + getAllTeams, + getTeam, + clearTeams, +} from './tools/team-tools.js' +export type { Team } from './tools/team-tools.js' + +export { + readMailbox, + writeToMailbox, + clearMailboxes, +} from './tools/send-message.js' +export type { AgentMessage } from './tools/send-message.js' + +export { + isPlanModeActive, + getCurrentPlan, +} from './tools/plan-tools.js' + +export { + registerAgents, + clearAgents, +} from './tools/agent-tool.js' + +export { + setQuestionHandler, + clearQuestionHandler, +} from './tools/ask-user.js' + +export { + setDeferredTools, +} from './tools/tool-search.js' + +export { + setMcpConnections, +} from './tools/mcp-resource-tools.js' + +export { + getAllCronJobs, + clearCronJobs, +} from './tools/cron-tools.js' +export type { CronJob } from './tools/cron-tools.js' + +export { + getConfig, + setConfig, + clearConfig, +} from './tools/config-tool.js' + +export { + getTodos, + clearTodos, +} from './tools/todo-tool.js' +export type { TodoItem } from './tools/todo-tool.js' + +// -------------------------------------------------------------------------- +// Types +// -------------------------------------------------------------------------- + +export type { + // Message types + Message, + UserMessage, + AssistantMessage, + ConversationMessage, + MessageRole, + + // SDK message types (streaming events) + SDKMessage, + SDKAssistantMessage, + SDKToolResultMessage, + SDKResultMessage, + SDKPartialMessage, + + // Tool types + ToolDefinition, + ToolInputSchema, + ToolContext, + ToolResult, + + // Permission types + PermissionMode, + CanUseToolFn, + CanUseToolResult, + + // MCP types + McpServerConfig, + McpStdioConfig, + McpSseConfig, + McpHttpConfig, + + // Agent types + AgentOptions, + AgentDefinition, + QueryResult, + ThinkingConfig, + TokenUsage, + + // Engine types + QueryEngineConfig, + + // Sandbox types + SandboxSettings, + SandboxNetworkConfig, + SandboxFilesystemConfig, + + // Output format + OutputFormat, + + // Setting sources + SettingSource, + + // Model info + ModelInfo, +} from './types.js' diff --git a/src/mcp/client.ts b/src/mcp/client.ts new file mode 100644 index 0000000..1d57cfa --- /dev/null +++ b/src/mcp/client.ts @@ -0,0 +1,150 @@ +/** + * MCP Client - Connect to Model Context Protocol servers + */ + +import type { ToolDefinition, McpServerConfig, ToolContext, ToolResult } from '../types.js' + +export interface MCPConnection { + name: string + status: 'connected' | 'disconnected' | 'error' + tools: ToolDefinition[] + close: () => Promise +} + +/** + * Connect to an MCP server and fetch its tools. + */ +export async function connectMCPServer( + name: string, + config: McpServerConfig, +): Promise { + try { + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + + let transport: any + + if (!config.type || config.type === 'stdio') { + const stdioConfig = config as { command: string; args?: string[]; env?: Record } + const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js') + transport = new StdioClientTransport({ + command: stdioConfig.command, + args: stdioConfig.args || [], + env: { ...process.env, ...stdioConfig.env } as Record, + }) + } else if (config.type === 'sse') { + const sseConfig = config as { url: string; headers?: Record } + const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js') + transport = new SSEClientTransport(new URL(sseConfig.url), { + requestInit: sseConfig.headers ? { headers: sseConfig.headers } : undefined, + } as any) + } else if (config.type === 'http') { + const httpConfig = config as { url: string; headers?: Record } + const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js') + transport = new StreamableHTTPClientTransport(new URL(httpConfig.url), { + requestInit: httpConfig.headers ? { headers: httpConfig.headers } : undefined, + } as any) + } else { + throw new Error(`Unsupported MCP transport type: ${(config as any).type}`) + } + + const client = new Client( + { name: `agent-sdk-${name}`, version: '1.0.0' }, + { capabilities: {} }, + ) + + await client.connect(transport) + + // Fetch available tools + const toolList = await client.listTools() + const tools: ToolDefinition[] = (toolList.tools || []).map((mcpTool: any) => + createMCPToolDefinition(name, mcpTool, client), + ) + + return { + name, + status: 'connected', + tools, + async close() { + try { + await client.close() + } catch { + // ignore close errors + } + }, + } + } catch (err: any) { + console.error(`[MCP] Failed to connect to "${name}": ${err.message}`) + return { + name, + status: 'error', + tools: [], + async close() {}, + } + } +} + +/** + * Create a ToolDefinition wrapping an MCP server tool. + */ +function createMCPToolDefinition( + serverName: string, + mcpTool: { name: string; description?: string; inputSchema?: any }, + client: any, +): ToolDefinition { + const toolName = `mcp__${serverName}__${mcpTool.name}` + + return { + name: toolName, + description: mcpTool.description || `MCP tool: ${mcpTool.name} from ${serverName}`, + inputSchema: mcpTool.inputSchema || { type: 'object', properties: {} }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { + return mcpTool.description || '' + }, + async call(input: any): Promise { + try { + const result = await client.callTool({ + name: mcpTool.name, + arguments: input, + }) + + // Extract text content from MCP result + let output = '' + if (result.content) { + for (const block of result.content) { + if (block.type === 'text') { + output += block.text + } else { + output += JSON.stringify(block) + } + } + } else { + output = JSON.stringify(result) + } + + return { + type: 'tool_result', + tool_use_id: '', + content: output, + is_error: result.isError || false, + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `MCP tool error: ${err.message}`, + is_error: true, + } + } + }, + } +} + +/** + * Close all MCP connections. + */ +export async function closeAllConnections(connections: MCPConnection[]): Promise { + await Promise.allSettled(connections.map((c) => c.close())) +} diff --git a/src/sdk-mcp-server.ts b/src/sdk-mcp-server.ts new file mode 100644 index 0000000..c6b39fa --- /dev/null +++ b/src/sdk-mcp-server.ts @@ -0,0 +1,78 @@ +/** + * In-Process MCP Server + * + * createSdkMcpServer() creates an in-process MCP server from tool() definitions. + * Compatible with open-agent-sdk's createSdkMcpServer(). + * + * Usage: + * import { tool, createSdkMcpServer } from 'open-agent-sdk' + * import { z } from 'zod' + * + * const weatherTool = tool('get_weather', 'Get weather', { city: z.string() }, + * async ({ city }) => ({ content: [{ type: 'text', text: `22°C in ${city}` }] }) + * ) + * + * const server = createSdkMcpServer({ + * name: 'weather', + * tools: [weatherTool], + * }) + * + * // Use as MCP server config: + * const agent = createAgent({ + * mcpServers: { weather: server }, + * }) + */ + +import type { SdkMcpToolDefinition } from './tool-helper.js' +import { sdkToolToToolDefinition } from './tool-helper.js' +import type { ToolDefinition, McpServerConfig } from './types.js' + +/** + * SDK MCP server config that includes the in-process server instance. + */ +export interface McpSdkServerConfig { + type: 'sdk' + name: string + version: string + tools: ToolDefinition[] + _sdkTools: SdkMcpToolDefinition[] +} + +/** + * Create an in-process MCP server from tool definitions. + * + * The server runs in the same process as the agent, avoiding + * subprocess overhead. Tools are directly callable. + */ +export function createSdkMcpServer(options: { + name: string + version?: string + tools?: SdkMcpToolDefinition[] +}): McpSdkServerConfig { + const sdkTools = options.tools || [] + + // Convert SDK tools to engine-compatible tool definitions + // Prefix tool names with mcp__{server_name}__ for namespace isolation + const toolDefinitions: ToolDefinition[] = sdkTools.map((sdkTool) => { + const toolDef = sdkToolToToolDefinition(sdkTool) + return { + ...toolDef, + name: `mcp__${options.name}__${sdkTool.name}`, + } + }) + + return { + type: 'sdk', + name: options.name, + version: options.version || '1.0.0', + tools: toolDefinitions, + _sdkTools: sdkTools, + } +} + +/** + * Check if a server config is an in-process SDK server. + */ +export function isSdkServerConfig(config: any): config is McpSdkServerConfig { + return config?.type === 'sdk' && Array.isArray(config.tools) +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..523a3c9 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,227 @@ +/** + * Session Storage & Management + * + * Persists conversation transcripts to disk for resumption. + * Manages session lifecycle (create, resume, list, fork). + */ + +import { readFile, writeFile, mkdir, readdir, stat } from 'fs/promises' +import { join } from 'path' +import type { Message } from './types.js' +import type Anthropic from '@anthropic-ai/sdk' + +/** + * Session metadata. + */ +export interface SessionMetadata { + id: string + cwd: string + model: string + createdAt: string + updatedAt: string + messageCount: number + summary?: string +} + +/** + * Session data on disk. + */ +export interface SessionData { + metadata: SessionMetadata + messages: Anthropic.MessageParam[] +} + +/** + * Get the sessions directory path. + */ +function getSessionsDir(): string { + const home = process.env.HOME || process.env.USERPROFILE || '/tmp' + return join(home, '.open-agent-sdk', 'sessions') +} + +/** + * Get the path for a specific session. + */ +function getSessionPath(sessionId: string): string { + return join(getSessionsDir(), sessionId) +} + +/** + * Save session to disk. + */ +export async function saveSession( + sessionId: string, + messages: Anthropic.MessageParam[], + metadata: Partial, +): Promise { + const dir = getSessionPath(sessionId) + await mkdir(dir, { recursive: true }) + + const data: SessionData = { + metadata: { + id: sessionId, + cwd: metadata.cwd || process.cwd(), + model: metadata.model || 'claude-sonnet-4-6', + createdAt: metadata.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: messages.length, + summary: metadata.summary, + }, + messages, + } + + await writeFile( + join(dir, 'transcript.json'), + JSON.stringify(data, null, 2), + 'utf-8', + ) +} + +/** + * Load session from disk. + */ +export async function loadSession(sessionId: string): Promise { + try { + const filePath = join(getSessionPath(sessionId), 'transcript.json') + const content = await readFile(filePath, 'utf-8') + return JSON.parse(content) as SessionData + } catch { + return null + } +} + +/** + * List all sessions. + */ +export async function listSessions(): Promise { + try { + const dir = getSessionsDir() + const entries = await readdir(dir) + const sessions: SessionMetadata[] = [] + + for (const entry of entries) { + try { + const data = await loadSession(entry) + if (data?.metadata) { + sessions.push(data.metadata) + } + } catch { + // Skip invalid sessions + } + } + + // Sort by updatedAt descending + sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + + return sessions + } catch { + return [] + } +} + +/** + * Fork a session (create a copy with a new ID). + */ +export async function forkSession( + sourceSessionId: string, + newSessionId?: string, +): Promise { + const data = await loadSession(sourceSessionId) + if (!data) return null + + const forkId = newSessionId || crypto.randomUUID() + + await saveSession(forkId, data.messages, { + ...data.metadata, + id: forkId, + createdAt: new Date().toISOString(), + summary: `Forked from session ${sourceSessionId}`, + }) + + return forkId +} + +/** + * Get session messages. + */ +export async function getSessionMessages( + sessionId: string, +): Promise { + const data = await loadSession(sessionId) + return data?.messages || [] +} + +/** + * Append a message to a session transcript. + */ +export async function appendToSession( + sessionId: string, + message: Anthropic.MessageParam, +): Promise { + const data = await loadSession(sessionId) + if (!data) return + + data.messages.push(message) + data.metadata.updatedAt = new Date().toISOString() + data.metadata.messageCount = data.messages.length + + await saveSession(sessionId, data.messages, data.metadata) +} + +/** + * Delete a session. + */ +export async function deleteSession(sessionId: string): Promise { + try { + const { rm } = await import('fs/promises') + await rm(getSessionPath(sessionId), { recursive: true, force: true }) + return true + } catch { + return false + } +} + +/** + * Get info about a specific session. + */ +export async function getSessionInfo( + sessionId: string, + options?: { dir?: string }, +): Promise { + const data = await loadSession(sessionId) + return data?.metadata || null +} + +/** + * Rename a session. + */ +export async function renameSession( + sessionId: string, + title: string, + options?: { dir?: string }, +): Promise { + const data = await loadSession(sessionId) + if (!data) return + + data.metadata.summary = title + data.metadata.updatedAt = new Date().toISOString() + + await saveSession(sessionId, data.messages, data.metadata) +} + +/** + * Tag a session. + */ +export async function tagSession( + sessionId: string, + tag: string | null, + options?: { dir?: string }, +): Promise { + const data = await loadSession(sessionId) + if (!data) return + + ;(data.metadata as any).tag = tag + data.metadata.updatedAt = new Date().toISOString() + + await saveSession(sessionId, data.messages, data.metadata) +} diff --git a/src/tool-helper.ts b/src/tool-helper.ts new file mode 100644 index 0000000..2e51f94 --- /dev/null +++ b/src/tool-helper.ts @@ -0,0 +1,127 @@ +/** + * tool() helper - Create tools using Zod schemas + * + * Compatible with open-agent-sdk's tool() function. + * + * Usage: + * import { tool } from 'open-agent-sdk' + * import { z } from 'zod' + * + * const weatherTool = tool( + * 'get_weather', + * 'Get weather for a city', + * { city: z.string().describe('City name') }, + * async ({ city }) => { + * return { content: [{ type: 'text', text: `Weather in ${city}: 22°C` }] } + * } + * ) + */ + +import { z, type ZodRawShape, type ZodObject } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' +import type { ToolDefinition, ToolResult, ToolContext } from './types.js' + +/** + * Tool annotations (MCP standard). + */ +export interface ToolAnnotations { + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + openWorldHint?: boolean +} + +/** + * Tool call result (MCP-compatible). + */ +export interface CallToolResult { + content: Array< + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; resource: { uri: string; text?: string; blob?: string } } + > + isError?: boolean +} + +/** + * SDK MCP tool definition. + */ +export interface SdkMcpToolDefinition { + name: string + description: string + inputSchema: ZodObject + handler: (args: z.infer>, extra: unknown) => Promise + annotations?: ToolAnnotations +} + +/** + * Create a tool using Zod schema. + * + * Compatible with open-agent-sdk's tool() function. + */ +export function tool( + name: string, + description: string, + inputSchema: T, + handler: (args: z.infer>, extra: unknown) => Promise, + extras?: { annotations?: ToolAnnotations }, +): SdkMcpToolDefinition { + return { + name, + description, + inputSchema: z.object(inputSchema), + handler, + annotations: extras?.annotations, + } +} + +/** + * Convert an SdkMcpToolDefinition to a ToolDefinition for the engine. + */ +export function sdkToolToToolDefinition(sdkTool: SdkMcpToolDefinition): ToolDefinition { + const jsonSchema = zodToJsonSchema(sdkTool.inputSchema, { target: 'openApi3' }) as any + + return { + name: sdkTool.name, + description: sdkTool.description, + inputSchema: { + type: 'object', + properties: jsonSchema.properties || {}, + required: jsonSchema.required || [], + }, + isReadOnly: () => sdkTool.annotations?.readOnlyHint ?? false, + isConcurrencySafe: () => sdkTool.annotations?.readOnlyHint ?? false, + isEnabled: () => true, + async prompt() { return sdkTool.description }, + async call(input: any, _context: ToolContext): Promise { + try { + const parsed = sdkTool.inputSchema.parse(input) + const result = await sdkTool.handler(parsed, {}) + + // Convert MCP content blocks to string + const text = result.content + .map((block) => { + if (block.type === 'text') return block.text + if (block.type === 'image') return `[Image: ${block.mimeType}]` + if (block.type === 'resource') return block.resource.text || `[Resource: ${block.resource.uri}]` + return JSON.stringify(block) + }) + .join('\n') + + return { + type: 'tool_result', + tool_use_id: '', + content: text, + is_error: result.isError || false, + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Error: ${err.message}`, + is_error: true, + } + } + }, + } +} diff --git a/src/tools/agent-tool.ts b/src/tools/agent-tool.ts new file mode 100644 index 0000000..c02c844 --- /dev/null +++ b/src/tools/agent-tool.ts @@ -0,0 +1,153 @@ +/** + * AgentTool - Spawn subagents for parallel/delegated work + * + * Supports built-in agents (Explore, Plan) and custom agent definitions. + * Agents run as nested query loops with their own context and tool sets. + */ + +import type { ToolDefinition, ToolContext, ToolResult, AgentDefinition } from '../types.js' +import { QueryEngine } from '../engine.js' +import { getAllBaseTools, filterTools } from './index.js' +import { toApiTool } from './types.js' + +// Store for registered agent definitions +let registeredAgents: Record = {} + +/** + * Register agent definitions for the AgentTool to use. + */ +export function registerAgents(agents: Record): void { + registeredAgents = { ...registeredAgents, ...agents } +} + +/** + * Clear registered agents. + */ +export function clearAgents(): void { + registeredAgents = {} +} + +/** + * Built-in agent definitions. + */ +const BUILTIN_AGENTS: Record = { + Explore: { + description: 'Fast agent for exploring codebases. Use for finding files, searching code, and answering questions about the codebase.', + prompt: 'You are a codebase exploration agent. Search through files and code to answer questions. Be thorough but efficient. Use Glob to find files, Grep to search content, and Read to examine files.', + tools: ['Read', 'Glob', 'Grep', 'Bash'], + }, + Plan: { + description: 'Software architect agent for designing implementation plans. Returns step-by-step plans and identifies critical files.', + prompt: 'You are a software architect. Design implementation plans for the given task. Identify critical files, consider trade-offs, and provide step-by-step plans. Use search tools to understand the codebase before planning.', + tools: ['Read', 'Glob', 'Grep', 'Bash'], + }, +} + +export const AgentTool: ToolDefinition = { + name: 'Agent', + description: 'Launch a subagent to handle complex, multi-step tasks autonomously. Subagents have their own context and can run specialized tool sets.', + inputSchema: { + type: 'object', + properties: { + prompt: { + type: 'string', + description: 'The task for the agent to perform', + }, + description: { + type: 'string', + description: 'A short (3-5 word) description of the task', + }, + subagent_type: { + type: 'string', + description: 'The type of agent to use (e.g., "Explore", "Plan", or a custom agent name)', + }, + model: { + type: 'string', + description: 'Optional model override for this agent', + }, + name: { + type: 'string', + description: 'Name for the spawned agent', + }, + run_in_background: { + type: 'boolean', + description: 'Whether to run in background', + }, + }, + required: ['prompt', 'description'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { + return 'Launch a subagent to handle complex tasks autonomously.' + }, + async call(input: any, context: ToolContext): Promise { + const agentType = input.subagent_type || 'general-purpose' + + // Find agent definition + const agentDef = registeredAgents[agentType] || BUILTIN_AGENTS[agentType] + + // Determine tools for subagent + let tools = getAllBaseTools() + if (agentDef?.tools) { + tools = filterTools(tools, agentDef.tools) + } + + // Remove AgentTool from subagent to prevent infinite recursion + tools = tools.filter(t => t.name !== 'Agent') + + // Build system prompt + const systemPrompt = agentDef?.prompt || + 'You are a helpful assistant. Complete the given task using the available tools.' + + // Create subagent engine + const engine = new QueryEngine({ + cwd: context.cwd, + model: input.model || process.env.CODEANY_MODEL || 'claude-sonnet-4-6', + tools, + systemPrompt, + maxTurns: agentDef?.maxTurns || 10, + maxTokens: 16384, + canUseTool: async () => ({ behavior: 'allow' }), + includePartialMessages: false, + }) + + // Run the subagent + let resultText = '' + let toolCalls: string[] = [] + + try { + for await (const event of engine.submitMessage(input.prompt)) { + if (event.type === 'assistant') { + for (const block of event.message.content) { + if ('text' in block && block.text) { + resultText = block.text + } + if ('name' in block) { + toolCalls.push(block.name as string) + } + } + } + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Subagent error: ${err.message}`, + is_error: true, + } + } + + const output = resultText || '(Subagent completed with no text output)' + const toolSummary = toolCalls.length > 0 + ? `\n[Tools used: ${toolCalls.join(', ')}]` + : '' + + return { + type: 'tool_result', + tool_use_id: '', + content: output + toolSummary, + } + }, +} diff --git a/src/tools/ask-user.ts b/src/tools/ask-user.ts new file mode 100644 index 0000000..dda645b --- /dev/null +++ b/src/tools/ask-user.ts @@ -0,0 +1,79 @@ +/** + * AskUserQuestionTool - Interactive user questions + * + * In SDK mode, returns a permission_request event and waits + * for the consumer to provide an answer. + * In non-interactive mode, returns a default or denies. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +// Callback for handling user questions (set by the agent) +let questionHandler: ((question: string, options?: string[]) => Promise) | null = null + +/** + * Set the question handler for AskUserQuestion. + */ +export function setQuestionHandler( + handler: (question: string, options?: string[]) => Promise, +): void { + questionHandler = handler +} + +/** + * Clear the question handler. + */ +export function clearQuestionHandler(): void { + questionHandler = null +} + +export const AskUserQuestionTool: ToolDefinition = { + name: 'AskUserQuestion', + description: 'Ask the user a question and wait for their response. Use when you need clarification or input from the user.', + inputSchema: { + type: 'object', + properties: { + question: { type: 'string', description: 'The question to ask the user' }, + options: { + type: 'array', + items: { type: 'string' }, + description: 'Optional list of choices for the user', + }, + allow_multiselect: { + type: 'boolean', + description: 'Whether to allow multiple selections (for options)', + }, + }, + required: ['question'], + }, + isReadOnly: () => true, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { return 'Ask the user a question.' }, + async call(input: any): Promise { + if (questionHandler) { + try { + const answer = await questionHandler(input.question, input.options) + return { + type: 'tool_result', + tool_use_id: '', + content: answer, + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `User declined to answer: ${err.message}`, + is_error: true, + } + } + } + + // Non-interactive: return informative message + return { + type: 'tool_result', + tool_use_id: '', + content: `[Non-interactive mode] Question: ${input.question}${input.options ? `\nOptions: ${input.options.join(', ')}` : ''}\n\nNo user available to answer. Proceeding with best judgment.`, + } + }, +} diff --git a/src/tools/bash.ts b/src/tools/bash.ts new file mode 100644 index 0000000..4b1a609 --- /dev/null +++ b/src/tools/bash.ts @@ -0,0 +1,75 @@ +/** + * BashTool - Execute shell commands + */ + +import { spawn } from 'child_process' +import { defineTool } from './types.js' + +export const BashTool = defineTool({ + name: 'Bash', + description: 'Execute a bash command and return its output. Use for running shell commands, scripts, and system operations.', + inputSchema: { + type: 'object', + properties: { + command: { + type: 'string', + description: 'The bash command to execute', + }, + timeout: { + type: 'number', + description: 'Optional timeout in milliseconds (max 600000, default 120000)', + }, + }, + required: ['command'], + }, + isReadOnly: false, + isConcurrencySafe: false, + async call(input, context) { + const { command, timeout: userTimeout } = input + const timeoutMs = Math.min(userTimeout || 120000, 600000) + + return new Promise((resolve) => { + const chunks: Buffer[] = [] + const errChunks: Buffer[] = [] + + const proc = spawn('bash', ['-c', command], { + cwd: context.cwd, + env: { ...process.env }, + timeout: timeoutMs, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + proc.stdout?.on('data', (data: Buffer) => chunks.push(data)) + proc.stderr?.on('data', (data: Buffer) => errChunks.push(data)) + + if (context.abortSignal) { + context.abortSignal.addEventListener('abort', () => { + proc.kill('SIGTERM') + }, { once: true }) + } + + proc.on('close', (code) => { + const stdout = Buffer.concat(chunks).toString('utf-8') + const stderr = Buffer.concat(errChunks).toString('utf-8') + + let output = '' + if (stdout) output += stdout + if (stderr) output += (output ? '\n' : '') + stderr + if (code !== 0 && code !== null) { + output += `\nExit code: ${code}` + } + + // Truncate very large outputs + if (output.length > 100000) { + output = output.slice(0, 50000) + '\n...(truncated)...\n' + output.slice(-50000) + } + + resolve(output || '(no output)') + }) + + proc.on('error', (err) => { + resolve(`Error executing command: ${err.message}`) + }) + }) + }, +}) diff --git a/src/tools/config-tool.ts b/src/tools/config-tool.ts new file mode 100644 index 0000000..fa2455f --- /dev/null +++ b/src/tools/config-tool.ts @@ -0,0 +1,89 @@ +/** + * ConfigTool - Dynamic configuration management + * + * Get/set global configuration and session settings. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +// In-memory config store +const configStore = new Map() + +/** + * Get a config value. + */ +export function getConfig(key: string): unknown { + return configStore.get(key) +} + +/** + * Set a config value. + */ +export function setConfig(key: string, value: unknown): void { + configStore.set(key, value) +} + +/** + * Clear all config. + */ +export function clearConfig(): void { + configStore.clear() +} + +export const ConfigTool: ToolDefinition = { + name: 'Config', + description: 'Get or set configuration values. Supports session-scoped settings.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['get', 'set', 'list'], + description: 'Operation to perform', + }, + key: { type: 'string', description: 'Config key' }, + value: { description: 'Config value (for set)' }, + }, + required: ['action'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Manage configuration settings.' }, + async call(input: any): Promise { + switch (input.action) { + case 'get': { + if (!input.key) { + return { type: 'tool_result', tool_use_id: '', content: 'key required for get', is_error: true } + } + const value = configStore.get(input.key) + return { + type: 'tool_result', + tool_use_id: '', + content: value !== undefined ? JSON.stringify(value) : `Config key "${input.key}" not found`, + } + } + case 'set': { + if (!input.key) { + return { type: 'tool_result', tool_use_id: '', content: 'key required for set', is_error: true } + } + configStore.set(input.key, input.value) + return { + type: 'tool_result', + tool_use_id: '', + content: `Config set: ${input.key} = ${JSON.stringify(input.value)}`, + } + } + case 'list': { + const entries = Array.from(configStore.entries()) + if (entries.length === 0) { + return { type: 'tool_result', tool_use_id: '', content: 'No config values set.' } + } + const lines = entries.map(([k, v]) => `${k} = ${JSON.stringify(v)}`) + return { type: 'tool_result', tool_use_id: '', content: lines.join('\n') } + } + default: + return { type: 'tool_result', tool_use_id: '', content: `Unknown action: ${input.action}`, is_error: true } + } + }, +} diff --git a/src/tools/cron-tools.ts b/src/tools/cron-tools.ts new file mode 100644 index 0000000..9cd41d1 --- /dev/null +++ b/src/tools/cron-tools.ts @@ -0,0 +1,153 @@ +/** + * Cron/Scheduling Tools + * + * CronCreate, CronDelete, CronList - Schedule recurring tasks. + * RemoteTrigger - Manage remote scheduled agent triggers. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +/** + * Cron job definition. + */ +export interface CronJob { + id: string + name: string + schedule: string // cron expression + command: string + enabled: boolean + createdAt: string + lastRunAt?: string + nextRunAt?: string +} + +// In-memory cron store +const cronStore = new Map() +let cronCounter = 0 + +/** + * Get all cron jobs. + */ +export function getAllCronJobs(): CronJob[] { + return Array.from(cronStore.values()) +} + +/** + * Clear all cron jobs. + */ +export function clearCronJobs(): void { + cronStore.clear() + cronCounter = 0 +} + +export const CronCreateTool: ToolDefinition = { + name: 'CronCreate', + description: 'Create a scheduled recurring task (cron job). Supports cron expressions for scheduling.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Job name' }, + schedule: { type: 'string', description: 'Cron expression (e.g., "*/5 * * * *" for every 5 minutes)' }, + command: { type: 'string', description: 'Command or prompt to execute' }, + }, + required: ['name', 'schedule', 'command'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Create a scheduled cron job.' }, + async call(input: any): Promise { + const id = `cron_${++cronCounter}` + const job: CronJob = { + id, + name: input.name, + schedule: input.schedule, + command: input.command, + enabled: true, + createdAt: new Date().toISOString(), + } + cronStore.set(id, job) + + return { + type: 'tool_result', + tool_use_id: '', + content: `Cron job created: ${id} "${job.name}" schedule="${job.schedule}"`, + } + }, +} + +export const CronDeleteTool: ToolDefinition = { + name: 'CronDelete', + description: 'Delete a scheduled cron job.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Cron job ID to delete' }, + }, + required: ['id'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Delete a cron job.' }, + async call(input: any): Promise { + if (!cronStore.has(input.id)) { + return { type: 'tool_result', tool_use_id: '', content: `Cron job not found: ${input.id}`, is_error: true } + } + cronStore.delete(input.id) + return { type: 'tool_result', tool_use_id: '', content: `Cron job deleted: ${input.id}` } + }, +} + +export const CronListTool: ToolDefinition = { + name: 'CronList', + description: 'List all scheduled cron jobs.', + inputSchema: { type: 'object', properties: {} }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'List cron jobs.' }, + async call(): Promise { + const jobs = getAllCronJobs() + if (jobs.length === 0) { + return { type: 'tool_result', tool_use_id: '', content: 'No cron jobs scheduled.' } + } + const lines = jobs.map(j => + `[${j.id}] ${j.enabled ? '✓' : '✗'} "${j.name}" schedule="${j.schedule}" command="${j.command.slice(0, 50)}"` + ) + return { type: 'tool_result', tool_use_id: '', content: lines.join('\n') } + }, +} + +export const RemoteTriggerTool: ToolDefinition = { + name: 'RemoteTrigger', + description: 'Manage remote scheduled agent triggers. Supports list, get, create, update, and run operations.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['list', 'get', 'create', 'update', 'run'], + description: 'Operation to perform', + }, + id: { type: 'string', description: 'Trigger ID (for get/update/run)' }, + name: { type: 'string', description: 'Trigger name (for create)' }, + schedule: { type: 'string', description: 'Cron schedule (for create/update)' }, + prompt: { type: 'string', description: 'Agent prompt (for create/update)' }, + }, + required: ['action'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Manage remote agent triggers.' }, + async call(input: any): Promise { + // RemoteTrigger operations are typically handled by the remote backend + // In standalone SDK mode, we provide a stub implementation + return { + type: 'tool_result', + tool_use_id: '', + content: `RemoteTrigger ${input.action}: This feature requires a connected remote backend. In standalone SDK mode, use CronCreate/CronList/CronDelete for local scheduling.`, + } + }, +} diff --git a/src/tools/edit.ts b/src/tools/edit.ts new file mode 100644 index 0000000..bb97bf0 --- /dev/null +++ b/src/tools/edit.ts @@ -0,0 +1,74 @@ +/** + * FileEditTool - Precise string replacement in files + */ + +import { readFile, writeFile } from 'fs/promises' +import { resolve } from 'path' +import { defineTool } from './types.js' + +export const FileEditTool = defineTool({ + name: 'Edit', + description: 'Perform exact string replacements in files. The old_string must match exactly (including whitespace and indentation). Use replace_all to change every occurrence.', + inputSchema: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'The absolute path to the file to modify', + }, + old_string: { + type: 'string', + description: 'The exact text to find and replace', + }, + new_string: { + type: 'string', + description: 'The replacement text', + }, + replace_all: { + type: 'boolean', + description: 'Replace all occurrences (default false)', + }, + }, + required: ['file_path', 'old_string', 'new_string'], + }, + isReadOnly: false, + isConcurrencySafe: false, + async call(input, context) { + const filePath = resolve(context.cwd, input.file_path) + const { old_string, new_string, replace_all } = input + + if (old_string === new_string) { + return { data: 'Error: old_string and new_string are identical', is_error: true } + } + + try { + let content = await readFile(filePath, 'utf-8') + + if (!content.includes(old_string)) { + return { data: `Error: old_string not found in ${filePath}. Make sure it matches exactly including whitespace.`, is_error: true } + } + + if (!replace_all) { + // Check uniqueness + const count = content.split(old_string).length - 1 + if (count > 1) { + return { + data: `Error: old_string appears ${count} times in the file. Provide more context to make it unique, or set replace_all: true.`, + is_error: true, + } + } + content = content.replace(old_string, new_string) + } else { + content = content.split(old_string).join(new_string) + } + + await writeFile(filePath, content, 'utf-8') + return `File edited: ${filePath}` + } catch (err: any) { + if (err.code === 'ENOENT') { + return { data: `Error: File not found: ${filePath}`, is_error: true } + } + return { data: `Error editing file: ${err.message}`, is_error: true } + } + }, +}) diff --git a/src/tools/glob.ts b/src/tools/glob.ts new file mode 100644 index 0000000..4e11f10 --- /dev/null +++ b/src/tools/glob.ts @@ -0,0 +1,77 @@ +/** + * GlobTool - File pattern matching + */ + +import { resolve } from 'path' +import { defineTool } from './types.js' + +export const GlobTool = defineTool({ + name: 'Glob', + description: 'Find files matching a glob pattern. Returns matching file paths sorted by modification time. Supports patterns like "**/*.ts", "src/**/*.js".', + inputSchema: { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'The glob pattern to match files against', + }, + path: { + type: 'string', + description: 'The directory to search in (defaults to cwd)', + }, + }, + required: ['pattern'], + }, + isReadOnly: true, + isConcurrencySafe: true, + async call(input, context) { + const searchDir = input.path ? resolve(context.cwd, input.path) : context.cwd + const { pattern } = input + + try { + // Use Node.js glob (available in Node 22+) or fall back to bash find + const { glob } = await import('fs/promises') + + // @ts-ignore - glob is available in Node 22+ + if (typeof glob === 'function') { + const matches: string[] = [] + // @ts-ignore + for await (const entry of glob(pattern, { cwd: searchDir })) { + matches.push(entry) + if (matches.length >= 500) break + } + if (matches.length === 0) { + return `No files matching pattern "${pattern}" in ${searchDir}` + } + return matches.join('\n') + } + } catch { + // Fall through to bash-based approach + } + + // Fallback: use bash find/glob + const { spawn } = await import('child_process') + return new Promise((resolvePromise) => { + // Use bash glob expansion or find + const cmd = `shopt -s globstar nullglob 2>/dev/null; cd ${JSON.stringify(searchDir)} && ls -1d ${pattern} 2>/dev/null | head -500` + const proc = spawn('bash', ['-c', cmd], { + cwd: searchDir, + timeout: 30000, + }) + + const chunks: Buffer[] = [] + proc.stdout?.on('data', (d: Buffer) => chunks.push(d)) + proc.on('close', () => { + const result = Buffer.concat(chunks).toString('utf-8').trim() + if (!result) { + resolvePromise(`No files matching pattern "${pattern}" in ${searchDir}`) + } else { + resolvePromise(result) + } + }) + proc.on('error', () => { + resolvePromise(`Error searching for files with pattern "${pattern}"`) + }) + }) + }, +}) diff --git a/src/tools/grep.ts b/src/tools/grep.ts new file mode 100644 index 0000000..d7a61ae --- /dev/null +++ b/src/tools/grep.ts @@ -0,0 +1,168 @@ +/** + * GrepTool - Search file contents using regex + */ + +import { spawn } from 'child_process' +import { resolve } from 'path' +import { defineTool } from './types.js' + +export const GrepTool = defineTool({ + name: 'Grep', + description: 'Search file contents using regex patterns. Uses ripgrep (rg) if available, falls back to grep. Supports file type filtering and context lines.', + inputSchema: { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'The regex pattern to search for', + }, + path: { + type: 'string', + description: 'File or directory to search in (defaults to cwd)', + }, + glob: { + type: 'string', + description: 'Glob pattern to filter files (e.g., "*.ts", "*.{js,jsx}")', + }, + type: { + type: 'string', + description: 'File type filter (e.g., "ts", "py", "js")', + }, + output_mode: { + type: 'string', + enum: ['content', 'files_with_matches', 'count'], + description: 'Output mode (default: files_with_matches)', + }, + '-i': { + type: 'boolean', + description: 'Case insensitive search', + }, + '-n': { + type: 'boolean', + description: 'Show line numbers (default: true)', + }, + '-A': { type: 'number', description: 'Lines after match' }, + '-B': { type: 'number', description: 'Lines before match' }, + '-C': { type: 'number', description: 'Context lines' }, + context: { type: 'number', description: 'Context lines (alias for -C)' }, + head_limit: { type: 'number', description: 'Limit output entries (default: 250)' }, + }, + required: ['pattern'], + }, + isReadOnly: true, + isConcurrencySafe: true, + async call(input, context) { + const searchPath = input.path ? resolve(context.cwd, input.path) : context.cwd + const outputMode = input.output_mode || 'files_with_matches' + const headLimit = input.head_limit ?? 250 + + // Build rg command (fall back to grep if rg unavailable) + const args: string[] = [] + + // Try ripgrep first + let cmd = 'rg' + + if (outputMode === 'files_with_matches') { + args.push('--files-with-matches') + } else if (outputMode === 'count') { + args.push('--count') + } else { + // content mode + if (input['-n'] !== false) args.push('--line-number') + } + + if (input['-i']) args.push('--ignore-case') + if (input['-A']) args.push('-A', String(input['-A'])) + if (input['-B']) args.push('-B', String(input['-B'])) + const ctx = input['-C'] ?? input.context + if (ctx) args.push('-C', String(ctx)) + if (input.glob) args.push('--glob', input.glob) + if (input.type) args.push('--type', input.type) + + args.push('--', input.pattern, searchPath) + + return new Promise((resolvePromise) => { + const proc = spawn(cmd, args, { + cwd: context.cwd, + timeout: 30000, + }) + + const chunks: Buffer[] = [] + const errChunks: Buffer[] = [] + proc.stdout?.on('data', (d: Buffer) => chunks.push(d)) + proc.stderr?.on('data', (d: Buffer) => errChunks.push(d)) + + proc.on('close', (code) => { + let result = Buffer.concat(chunks).toString('utf-8').trim() + + if (!result && code !== 0) { + // Try fallback to grep + const grepArgs = ['-r'] + if (input['-i']) grepArgs.push('-i') + if (outputMode === 'files_with_matches') grepArgs.push('-l') + if (outputMode === 'count') grepArgs.push('-c') + if (outputMode === 'content' && input['-n'] !== false) grepArgs.push('-n') + if (input.glob) grepArgs.push('--include', input.glob) + grepArgs.push('--', input.pattern, searchPath) + + const grepProc = spawn('grep', grepArgs, { + cwd: context.cwd, + timeout: 30000, + }) + + const grepChunks: Buffer[] = [] + grepProc.stdout?.on('data', (d: Buffer) => grepChunks.push(d)) + grepProc.on('close', () => { + const grepResult = Buffer.concat(grepChunks).toString('utf-8').trim() + if (!grepResult) { + resolvePromise(`No matches found for pattern "${input.pattern}"`) + } else { + // Apply head limit + const lines = grepResult.split('\n') + if (headLimit > 0 && lines.length > headLimit) { + resolvePromise(lines.slice(0, headLimit).join('\n') + `\n... (${lines.length - headLimit} more)`) + } else { + resolvePromise(grepResult) + } + } + }) + grepProc.on('error', () => { + resolvePromise(`No matches found for pattern "${input.pattern}"`) + }) + return + } + + if (!result) { + resolvePromise(`No matches found for pattern "${input.pattern}"`) + return + } + + // Apply head limit + const lines = result.split('\n') + if (headLimit > 0 && lines.length > headLimit) { + result = lines.slice(0, headLimit).join('\n') + `\n... (${lines.length - headLimit} more)` + } + + resolvePromise(result) + }) + + proc.on('error', () => { + // rg not found, try grep directly + const grepArgs = ['-r', '-n', '--', input.pattern, searchPath] + const grepProc = spawn('grep', grepArgs, { + cwd: context.cwd, + timeout: 30000, + }) + const grepChunks: Buffer[] = [] + grepProc.stdout?.on('data', (d: Buffer) => grepChunks.push(d)) + grepProc.on('close', () => { + const grepResult = Buffer.concat(grepChunks).toString('utf-8').trim() + resolvePromise(grepResult || `No matches found for pattern "${input.pattern}"`) + }) + grepProc.on('error', () => { + resolvePromise(`Error: neither rg nor grep available`) + }) + }) + }) + }, +}) diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..6829398 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,232 @@ +/** + * Tool Registry - All built-in tool definitions + * + * 30+ tools covering file I/O, execution, search, web, agents, + * tasks, teams, messaging, worktree, planning, scheduling, and more. + */ + +import type { ToolDefinition } from '../types.js' + +// File I/O +import { BashTool } from './bash.js' +import { FileReadTool } from './read.js' +import { FileWriteTool } from './write.js' +import { FileEditTool } from './edit.js' +import { GlobTool } from './glob.js' +import { GrepTool } from './grep.js' +import { NotebookEditTool } from './notebook-edit.js' + +// Web +import { WebFetchTool } from './web-fetch.js' +import { WebSearchTool } from './web-search.js' + +// Agent & Multi-agent +import { AgentTool } from './agent-tool.js' +import { SendMessageTool } from './send-message.js' +import { TeamCreateTool, TeamDeleteTool } from './team-tools.js' + +// Tasks +import { + TaskCreateTool, + TaskListTool, + TaskUpdateTool, + TaskGetTool, + TaskStopTool, + TaskOutputTool, +} from './task-tools.js' + +// Worktree +import { EnterWorktreeTool, ExitWorktreeTool } from './worktree-tools.js' + +// Planning +import { EnterPlanModeTool, ExitPlanModeTool } from './plan-tools.js' + +// User interaction +import { AskUserQuestionTool } from './ask-user.js' + +// Discovery +import { ToolSearchTool } from './tool-search.js' + +// MCP Resources +import { ListMcpResourcesTool, ReadMcpResourceTool } from './mcp-resource-tools.js' + +// Scheduling +import { CronCreateTool, CronDeleteTool, CronListTool, RemoteTriggerTool } from './cron-tools.js' + +// LSP +import { LSPTool } from './lsp-tool.js' + +// Config +import { ConfigTool } from './config-tool.js' + +// Todo +import { TodoWriteTool } from './todo-tool.js' + +/** + * All built-in tools (30+). + */ +const ALL_TOOLS: ToolDefinition[] = [ + // Core file I/O & execution + BashTool, + FileReadTool, + FileWriteTool, + FileEditTool, + GlobTool, + GrepTool, + NotebookEditTool, + + // Web + WebFetchTool, + WebSearchTool, + + // Agent & Multi-agent + AgentTool, + SendMessageTool, + TeamCreateTool, + TeamDeleteTool, + + // Tasks + TaskCreateTool, + TaskListTool, + TaskUpdateTool, + TaskGetTool, + TaskStopTool, + TaskOutputTool, + + // Worktree + EnterWorktreeTool, + ExitWorktreeTool, + + // Planning + EnterPlanModeTool, + ExitPlanModeTool, + + // User interaction + AskUserQuestionTool, + + // Discovery + ToolSearchTool, + + // MCP Resources + ListMcpResourcesTool, + ReadMcpResourceTool, + + // Scheduling + CronCreateTool, + CronDeleteTool, + CronListTool, + RemoteTriggerTool, + + // LSP + LSPTool, + + // Config + ConfigTool, + + // Todo + TodoWriteTool, +] + +/** + * Get all built-in tools. + */ +export function getAllBaseTools(): ToolDefinition[] { + return [...ALL_TOOLS] +} + +/** + * Filter tools by allowed/disallowed lists. + */ +export function filterTools( + tools: ToolDefinition[], + allowedTools?: string[], + disallowedTools?: string[], +): ToolDefinition[] { + let filtered = tools + + if (allowedTools && allowedTools.length > 0) { + const allowed = new Set(allowedTools) + filtered = filtered.filter((t) => allowed.has(t.name)) + } + + if (disallowedTools && disallowedTools.length > 0) { + const disallowed = new Set(disallowedTools) + filtered = filtered.filter((t) => !disallowed.has(t.name)) + } + + return filtered +} + +/** + * Assemble tool pool: base tools + MCP tools, with deduplication. + */ +export function assembleToolPool( + baseTools: ToolDefinition[], + mcpTools: ToolDefinition[] = [], + allowedTools?: string[], + disallowedTools?: string[], +): ToolDefinition[] { + const combined = [...baseTools, ...mcpTools] + + // Deduplicate by name (later definitions override) + const byName = new Map() + for (const tool of combined) { + byName.set(tool.name, tool) + } + + let tools = Array.from(byName.values()) + return filterTools(tools, allowedTools, disallowedTools) +} + +// Re-export individual tools +export { + // Core + BashTool, + FileReadTool, + FileWriteTool, + FileEditTool, + GlobTool, + GrepTool, + NotebookEditTool, + WebFetchTool, + WebSearchTool, + // Agent + AgentTool, + SendMessageTool, + TeamCreateTool, + TeamDeleteTool, + // Tasks + TaskCreateTool, + TaskListTool, + TaskUpdateTool, + TaskGetTool, + TaskStopTool, + TaskOutputTool, + // Worktree + EnterWorktreeTool, + ExitWorktreeTool, + // Planning + EnterPlanModeTool, + ExitPlanModeTool, + // User + AskUserQuestionTool, + // Discovery + ToolSearchTool, + // MCP + ListMcpResourcesTool, + ReadMcpResourceTool, + // Scheduling + CronCreateTool, + CronDeleteTool, + CronListTool, + RemoteTriggerTool, + // LSP + LSPTool, + // Config + ConfigTool, + // Todo + TodoWriteTool, +} + +// Re-export helpers +export { defineTool, toApiTool } from './types.js' diff --git a/src/tools/lsp-tool.ts b/src/tools/lsp-tool.ts new file mode 100644 index 0000000..8aa6acf --- /dev/null +++ b/src/tools/lsp-tool.ts @@ -0,0 +1,163 @@ +/** + * LSPTool - Language Server Protocol integration + * + * Provides code intelligence: go-to-definition, find-references, + * hover, document symbols, workspace symbols, etc. + */ + +import { execSync } from 'child_process' +import type { ToolDefinition, ToolResult } from '../types.js' + +export const LSPTool: ToolDefinition = { + name: 'LSP', + description: 'Language Server Protocol operations for code intelligence. Supports go-to-definition, find-references, hover, and symbol lookup.', + inputSchema: { + type: 'object', + properties: { + operation: { + type: 'string', + enum: [ + 'goToDefinition', + 'findReferences', + 'hover', + 'documentSymbol', + 'workspaceSymbol', + 'goToImplementation', + 'prepareCallHierarchy', + 'incomingCalls', + 'outgoingCalls', + ], + description: 'LSP operation to perform', + }, + file_path: { type: 'string', description: 'File path for the operation' }, + line: { type: 'number', description: 'Line number (0-based)' }, + character: { type: 'number', description: 'Character position (0-based)' }, + query: { type: 'string', description: 'Symbol name (for workspace symbol search)' }, + }, + required: ['operation'], + }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Code intelligence via Language Server Protocol.' }, + async call(input: any, context: { cwd: string }): Promise { + const { operation, file_path, line, character, query } = input + + // LSP requires a running language server. In standalone mode, + // we fall back to basic grep/ripgrep-based symbol lookup. + try { + switch (operation) { + case 'goToDefinition': + case 'goToImplementation': { + if (!file_path || line === undefined) { + return { type: 'tool_result', tool_use_id: '', content: 'file_path and line required', is_error: true } + } + // Use grep to find definition + const symbol = await getSymbolAtPosition(file_path, line, character || 0, context.cwd) + if (!symbol) { + return { type: 'tool_result', tool_use_id: '', content: 'Could not identify symbol at position' } + } + const results = execSync( + `rg -n "(?:function|class|interface|type|const|let|var|export)\\s+${symbol}" --type-add 'src:*.{ts,tsx,js,jsx,py,go,rs,java}' -t src ${context.cwd} 2>/dev/null || grep -rn "(?:function|class|interface|type|const|let|var|export)\\s*${symbol}" ${context.cwd} --include='*.ts' --include='*.js' 2>/dev/null`, + { encoding: 'utf-8', timeout: 10000 }, + ).trim() + return { type: 'tool_result', tool_use_id: '', content: results || `No definition found for "${symbol}"` } + } + + case 'findReferences': { + if (!file_path || line === undefined) { + return { type: 'tool_result', tool_use_id: '', content: 'file_path and line required', is_error: true } + } + const sym = await getSymbolAtPosition(file_path, line, character || 0, context.cwd) + if (!sym) { + return { type: 'tool_result', tool_use_id: '', content: 'Could not identify symbol at position' } + } + const refs = execSync( + `rg -n "${sym}" ${context.cwd} --type-add 'src:*.{ts,tsx,js,jsx,py,go,rs,java}' -t src 2>/dev/null | head -50`, + { encoding: 'utf-8', timeout: 10000 }, + ).trim() + return { type: 'tool_result', tool_use_id: '', content: refs || `No references found for "${sym}"` } + } + + case 'hover': { + return { + type: 'tool_result', + tool_use_id: '', + content: 'Hover information requires a running language server. Use Read tool to examine the file content.', + } + } + + case 'documentSymbol': { + if (!file_path) { + return { type: 'tool_result', tool_use_id: '', content: 'file_path required', is_error: true } + } + const symbols = execSync( + `rg -n "^\\s*(export\\s+)?(function|class|interface|type|const|let|var|enum)\\s+" ${JSON.stringify(file_path)} 2>/dev/null || grep -n "^\\s*\\(export\\s\\+\\)\\?\\(function\\|class\\|interface\\|type\\|const\\|let\\|var\\|enum\\)\\s" ${JSON.stringify(file_path)} 2>/dev/null`, + { encoding: 'utf-8', cwd: context.cwd, timeout: 10000 }, + ).trim() + return { type: 'tool_result', tool_use_id: '', content: symbols || 'No symbols found' } + } + + case 'workspaceSymbol': { + if (!query) { + return { type: 'tool_result', tool_use_id: '', content: 'query required', is_error: true } + } + const wsSymbols = execSync( + `rg -n "${query}" ${context.cwd} --type-add 'src:*.{ts,tsx,js,jsx,py,go,rs,java}' -t src 2>/dev/null | head -30`, + { encoding: 'utf-8', timeout: 10000 }, + ).trim() + return { type: 'tool_result', tool_use_id: '', content: wsSymbols || `No symbols found for "${query}"` } + } + + default: + return { + type: 'tool_result', + tool_use_id: '', + content: `LSP operation "${operation}" requires a running language server.`, + } + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `LSP error: ${err.message}`, + is_error: true, + } + } + }, +} + +/** + * Get the symbol at a given position in a file. + */ +async function getSymbolAtPosition( + filePath: string, + line: number, + character: number, + cwd: string, +): Promise { + try { + const { readFile } = await import('fs/promises') + const { resolve } = await import('path') + const content = await readFile(resolve(cwd, filePath), 'utf-8') + const lines = content.split('\n') + + if (line >= lines.length) return null + + const lineText = lines[line] + if (!lineText || character >= lineText.length) return null + + // Extract word at position + const wordMatch = /\b\w+\b/g + let match + while ((match = wordMatch.exec(lineText)) !== null) { + if (match.index <= character && match.index + match[0].length >= character) { + return match[0] + } + } + + return null + } catch { + return null + } +} diff --git a/src/tools/mcp-resource-tools.ts b/src/tools/mcp-resource-tools.ts new file mode 100644 index 0000000..4b3a025 --- /dev/null +++ b/src/tools/mcp-resource-tools.ts @@ -0,0 +1,125 @@ +/** + * MCP Resource Tools + * + * ListMcpResources / ReadMcpResource - Access resources from MCP servers. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' +import type { MCPConnection } from '../mcp/client.js' + +// Registry of MCP connections (set by the agent) +let mcpConnections: MCPConnection[] = [] + +/** + * Set MCP connections for resource access. + */ +export function setMcpConnections(connections: MCPConnection[]): void { + mcpConnections = connections +} + +export const ListMcpResourcesTool: ToolDefinition = { + name: 'ListMcpResources', + description: 'List available resources from connected MCP servers. Resources can include files, databases, and other data sources.', + inputSchema: { + type: 'object', + properties: { + server: { type: 'string', description: 'Filter by MCP server name' }, + }, + }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'List MCP resources.' }, + async call(input: any): Promise { + const connections = input.server + ? mcpConnections.filter(c => c.name === input.server) + : mcpConnections + + if (connections.length === 0) { + return { + type: 'tool_result', + tool_use_id: '', + content: 'No MCP servers connected.', + } + } + + const results: string[] = [] + + for (const conn of connections) { + if (conn.status !== 'connected') continue + + try { + // Access the underlying client to list resources + const resources = (conn as any)._client?.listResources?.() + if (resources) { + results.push(`Server: ${conn.name}`) + for (const r of resources) { + results.push(` - ${r.name}: ${r.description || r.uri || ''}`) + } + } else { + results.push(`Server: ${conn.name} (${conn.tools.length} tools available)`) + } + } catch { + results.push(`Server: ${conn.name} (resource listing not supported)`) + } + } + + return { + type: 'tool_result', + tool_use_id: '', + content: results.join('\n') || 'No resources found.', + } + }, +} + +export const ReadMcpResourceTool: ToolDefinition = { + name: 'ReadMcpResource', + description: 'Read a specific resource from an MCP server.', + inputSchema: { + type: 'object', + properties: { + server: { type: 'string', description: 'MCP server name' }, + uri: { type: 'string', description: 'Resource URI to read' }, + }, + required: ['server', 'uri'], + }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Read an MCP resource.' }, + async call(input: any): Promise { + const conn = mcpConnections.find(c => c.name === input.server) + if (!conn) { + return { + type: 'tool_result', + tool_use_id: '', + content: `MCP server not found: ${input.server}`, + is_error: true, + } + } + + try { + const result = await (conn as any)._client?.readResource?.({ uri: input.uri }) + if (result?.contents) { + const texts = result.contents.map((c: any) => c.text || JSON.stringify(c)).join('\n') + return { + type: 'tool_result', + tool_use_id: '', + content: texts, + } + } + return { + type: 'tool_result', + tool_use_id: '', + content: 'Resource read returned no content.', + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Error reading resource: ${err.message}`, + is_error: true, + } + } + }, +} diff --git a/src/tools/notebook-edit.ts b/src/tools/notebook-edit.ts new file mode 100644 index 0000000..03eba8a --- /dev/null +++ b/src/tools/notebook-edit.ts @@ -0,0 +1,93 @@ +/** + * NotebookEditTool - Edit Jupyter notebooks + */ + +import { readFile, writeFile } from 'fs/promises' +import { resolve } from 'path' +import { defineTool } from './types.js' + +export const NotebookEditTool = defineTool({ + name: 'NotebookEdit', + description: 'Edit Jupyter notebook (.ipynb) cells. Can insert, replace, or delete cells.', + inputSchema: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Path to the .ipynb file', + }, + command: { + type: 'string', + enum: ['insert', 'replace', 'delete'], + description: 'The edit operation to perform', + }, + cell_number: { + type: 'number', + description: 'Cell index (0-based) to operate on', + }, + cell_type: { + type: 'string', + enum: ['code', 'markdown'], + description: 'Type of cell (for insert/replace)', + }, + source: { + type: 'string', + description: 'Cell content (for insert/replace)', + }, + }, + required: ['file_path', 'command', 'cell_number'], + }, + isReadOnly: false, + isConcurrencySafe: false, + async call(input, context) { + const filePath = resolve(context.cwd, input.file_path) + + try { + const content = await readFile(filePath, 'utf-8') + const notebook = JSON.parse(content) + + if (!notebook.cells || !Array.isArray(notebook.cells)) { + return { data: 'Error: Invalid notebook format', is_error: true } + } + + const { command, cell_number, cell_type, source } = input + + switch (command) { + case 'insert': { + const newCell = { + cell_type: cell_type || 'code', + source: (source || '').split('\n').map((l: string, i: number, arr: string[]) => + i < arr.length - 1 ? l + '\n' : l + ), + metadata: {}, + ...(cell_type !== 'markdown' ? { outputs: [], execution_count: null } : {}), + } + notebook.cells.splice(cell_number, 0, newCell) + break + } + case 'replace': { + if (cell_number >= notebook.cells.length) { + return { data: `Error: Cell ${cell_number} does not exist`, is_error: true } + } + notebook.cells[cell_number].source = (source || '').split('\n').map( + (l: string, i: number, arr: string[]) => i < arr.length - 1 ? l + '\n' : l + ) + if (cell_type) notebook.cells[cell_number].cell_type = cell_type + break + } + case 'delete': { + if (cell_number >= notebook.cells.length) { + return { data: `Error: Cell ${cell_number} does not exist`, is_error: true } + } + notebook.cells.splice(cell_number, 1) + break + } + } + + await writeFile(filePath, JSON.stringify(notebook, null, 1), 'utf-8') + return `Notebook ${command}: cell ${cell_number} in ${filePath}` + } catch (err: any) { + return { data: `Error: ${err.message}`, is_error: true } + } + }, +}) diff --git a/src/tools/plan-tools.ts b/src/tools/plan-tools.ts new file mode 100644 index 0000000..a888a60 --- /dev/null +++ b/src/tools/plan-tools.ts @@ -0,0 +1,88 @@ +/** + * Plan Mode Tools + * + * EnterPlanMode / ExitPlanMode - Structured planning workflow. + * Allows the agent to enter a design/planning phase before execution. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +// Track plan mode state +let planModeActive = false +let currentPlan: string | null = null + +export function isPlanModeActive(): boolean { + return planModeActive +} + +export function getCurrentPlan(): string | null { + return currentPlan +} + +export const EnterPlanModeTool: ToolDefinition = { + name: 'EnterPlanMode', + description: 'Enter plan/design mode for complex tasks. In plan mode, the agent focuses on designing the approach before executing.', + inputSchema: { + type: 'object', + properties: {}, + }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { return 'Enter plan mode for structured planning.' }, + async call(): Promise { + if (planModeActive) { + return { + type: 'tool_result', + tool_use_id: '', + content: 'Already in plan mode.', + } + } + + planModeActive = true + currentPlan = null + + return { + type: 'tool_result', + tool_use_id: '', + content: 'Entered plan mode. Design your approach before executing. Use ExitPlanMode when the plan is ready.', + } + }, +} + +export const ExitPlanModeTool: ToolDefinition = { + name: 'ExitPlanMode', + description: 'Exit plan mode with a completed plan. The plan will be recorded and execution can proceed.', + inputSchema: { + type: 'object', + properties: { + plan: { type: 'string', description: 'The completed plan' }, + approved: { type: 'boolean', description: 'Whether the plan is approved for execution' }, + }, + }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { return 'Exit plan mode with a completed plan.' }, + async call(input: any): Promise { + if (!planModeActive) { + return { + type: 'tool_result', + tool_use_id: '', + content: 'Not in plan mode.', + is_error: true, + } + } + + planModeActive = false + currentPlan = input.plan || null + + const status = input.approved !== false ? 'approved' : 'pending approval' + + return { + type: 'tool_result', + tool_use_id: '', + content: `Plan mode exited. Plan status: ${status}.${currentPlan ? `\n\nPlan:\n${currentPlan}` : ''}`, + } + }, +} diff --git a/src/tools/read.ts b/src/tools/read.ts new file mode 100644 index 0000000..f5873f3 --- /dev/null +++ b/src/tools/read.ts @@ -0,0 +1,73 @@ +/** + * FileReadTool - Read file contents with line numbers + */ + +import { readFile, stat } from 'fs/promises' +import { resolve } from 'path' +import { defineTool } from './types.js' + +export const FileReadTool = defineTool({ + name: 'Read', + description: 'Read a file from the filesystem. Returns content with line numbers. Supports text files, images (returns visual content), and PDFs.', + inputSchema: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'The absolute path to the file to read', + }, + offset: { + type: 'number', + description: 'Line number to start reading from (0-based)', + }, + limit: { + type: 'number', + description: 'Maximum number of lines to read', + }, + }, + required: ['file_path'], + }, + isReadOnly: true, + isConcurrencySafe: true, + async call(input, context) { + const filePath = resolve(context.cwd, input.file_path) + + try { + const fileStat = await stat(filePath) + if (fileStat.isDirectory()) { + return { data: `Error: ${filePath} is a directory, not a file. Use Bash with 'ls' to list directory contents.`, is_error: true } + } + + // Check for binary/image files + const ext = filePath.split('.').pop()?.toLowerCase() + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext || '')) { + return `[Image file: ${filePath} (${fileStat.size} bytes)]` + } + + const content = await readFile(filePath, 'utf-8') + const lines = content.split('\n') + + const offset = input.offset || 0 + const limit = input.limit || 2000 + const selectedLines = lines.slice(offset, offset + limit) + + // Format with line numbers (cat -n style) + const numbered = selectedLines.map((line: string, i: number) => { + const lineNum = offset + i + 1 + return `${lineNum}\t${line}` + }).join('\n') + + let result = numbered + if (lines.length > offset + limit) { + result += `\n\n(${lines.length - offset - limit} more lines not shown)` + } + + return result || '(empty file)' + } catch (err: any) { + if (err.code === 'ENOENT') { + return { data: `Error: File not found: ${filePath}`, is_error: true } + } + return { data: `Error reading file: ${err.message}`, is_error: true } + } + }, +}) diff --git a/src/tools/send-message.ts b/src/tools/send-message.ts new file mode 100644 index 0000000..21402f2 --- /dev/null +++ b/src/tools/send-message.ts @@ -0,0 +1,96 @@ +/** + * SendMessageTool - Inter-agent messaging + * + * Supports plain text and structured protocol messages + * between teammates in a multi-agent setup. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +/** + * Message inbox for inter-agent communication. + */ +export interface AgentMessage { + from: string + to: string + content: string + timestamp: string + type: 'text' | 'shutdown_request' | 'shutdown_response' | 'plan_approval_response' +} + +const mailboxes = new Map() + +/** + * Read messages from a mailbox. + */ +export function readMailbox(agentName: string): AgentMessage[] { + const messages = mailboxes.get(agentName) || [] + mailboxes.set(agentName, []) // Clear after reading + return messages +} + +/** + * Write to a mailbox. + */ +export function writeToMailbox(agentName: string, message: AgentMessage): void { + const messages = mailboxes.get(agentName) || [] + messages.push(message) + mailboxes.set(agentName, messages) +} + +/** + * Clear all mailboxes. + */ +export function clearMailboxes(): void { + mailboxes.clear() +} + +export const SendMessageTool: ToolDefinition = { + name: 'SendMessage', + description: 'Send a message to another agent or teammate. Supports plain text and structured protocol messages.', + inputSchema: { + type: 'object', + properties: { + to: { type: 'string', description: 'Recipient agent name or ID. Use "*" for broadcast.' }, + content: { type: 'string', description: 'Message content' }, + type: { + type: 'string', + enum: ['text', 'shutdown_request', 'shutdown_response', 'plan_approval_response'], + description: 'Message type (default: text)', + }, + }, + required: ['to', 'content'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Send a message to another agent.' }, + async call(input: any): Promise { + const message: AgentMessage = { + from: 'self', + to: input.to, + content: input.content, + timestamp: new Date().toISOString(), + type: input.type || 'text', + } + + if (input.to === '*') { + // Broadcast to all known mailboxes + for (const [name] of mailboxes) { + writeToMailbox(name, { ...message, to: name }) + } + return { + type: 'tool_result', + tool_use_id: '', + content: `Message broadcast to all agents`, + } + } + + writeToMailbox(input.to, message) + return { + type: 'tool_result', + tool_use_id: '', + content: `Message sent to ${input.to}`, + } + }, +} diff --git a/src/tools/task-tools.ts b/src/tools/task-tools.ts new file mode 100644 index 0000000..896aec0 --- /dev/null +++ b/src/tools/task-tools.ts @@ -0,0 +1,290 @@ +/** + * Task Management Tools + * + * TaskCreate, TaskList, TaskUpdate, TaskGet, TaskStop, TaskOutput + * + * Provides in-memory task tracking for agent coordination. + * Tasks persist across turns within a session. + */ + +import type { ToolDefinition, ToolContext, ToolResult } from '../types.js' + +/** + * Task status. + */ +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled' + +/** + * Task entry. + */ +export interface Task { + id: string + subject: string + description?: string + status: TaskStatus + owner?: string + createdAt: string + updatedAt: string + output?: string + blockedBy?: string[] + blocks?: string[] + metadata?: Record +} + +/** + * Global task store (shared across tools in a session). + */ +const taskStore = new Map() + +let taskCounter = 0 + +/** + * Get all tasks. + */ +export function getAllTasks(): Task[] { + return Array.from(taskStore.values()) +} + +/** + * Get a task by ID. + */ +export function getTask(id: string): Task | undefined { + return taskStore.get(id) +} + +/** + * Clear all tasks (for session reset). + */ +export function clearTasks(): void { + taskStore.clear() + taskCounter = 0 +} + +// ============================================================================ +// TaskCreateTool +// ============================================================================ + +export const TaskCreateTool: ToolDefinition = { + name: 'TaskCreate', + description: 'Create a new task for tracking work progress. Tasks help organize multi-step operations.', + inputSchema: { + type: 'object', + properties: { + subject: { type: 'string', description: 'Short task title' }, + description: { type: 'string', description: 'Detailed task description' }, + owner: { type: 'string', description: 'Task owner/assignee' }, + status: { type: 'string', enum: ['pending', 'in_progress'], description: 'Initial status' }, + }, + required: ['subject'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Create a task for tracking progress.' }, + async call(input: any): Promise { + const id = `task_${++taskCounter}` + const task: Task = { + id, + subject: input.subject, + description: input.description, + status: input.status || 'pending', + owner: input.owner, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + taskStore.set(id, task) + + return { + type: 'tool_result', + tool_use_id: '', + content: `Task created: ${id} - "${task.subject}" (${task.status})`, + } + }, +} + +// ============================================================================ +// TaskListTool +// ============================================================================ + +export const TaskListTool: ToolDefinition = { + name: 'TaskList', + description: 'List all tasks with their status, ownership, and dependencies.', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', description: 'Filter by status' }, + owner: { type: 'string', description: 'Filter by owner' }, + }, + }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'List tasks.' }, + async call(input: any): Promise { + let tasks = getAllTasks() + + if (input.status) { + tasks = tasks.filter(t => t.status === input.status) + } + if (input.owner) { + tasks = tasks.filter(t => t.owner === input.owner) + } + + if (tasks.length === 0) { + return { type: 'tool_result', tool_use_id: '', content: 'No tasks found.' } + } + + const lines = tasks.map(t => + `[${t.id}] ${t.status.toUpperCase()} - ${t.subject}${t.owner ? ` (owner: ${t.owner})` : ''}` + ) + + return { + type: 'tool_result', + tool_use_id: '', + content: lines.join('\n'), + } + }, +} + +// ============================================================================ +// TaskUpdateTool +// ============================================================================ + +export const TaskUpdateTool: ToolDefinition = { + name: 'TaskUpdate', + description: 'Update a task\'s status, description, or other properties.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID' }, + status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'failed', 'cancelled'] }, + description: { type: 'string', description: 'Updated description' }, + owner: { type: 'string', description: 'New owner' }, + output: { type: 'string', description: 'Task output/result' }, + }, + required: ['id'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Update a task.' }, + async call(input: any): Promise { + const task = taskStore.get(input.id) + if (!task) { + return { type: 'tool_result', tool_use_id: '', content: `Task not found: ${input.id}`, is_error: true } + } + + if (input.status) task.status = input.status + if (input.description) task.description = input.description + if (input.owner) task.owner = input.owner + if (input.output) task.output = input.output + task.updatedAt = new Date().toISOString() + + return { + type: 'tool_result', + tool_use_id: '', + content: `Task updated: ${task.id} - ${task.status} - "${task.subject}"`, + } + }, +} + +// ============================================================================ +// TaskGetTool +// ============================================================================ + +export const TaskGetTool: ToolDefinition = { + name: 'TaskGet', + description: 'Get full details of a specific task.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID' }, + }, + required: ['id'], + }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Get task details.' }, + async call(input: any): Promise { + const task = taskStore.get(input.id) + if (!task) { + return { type: 'tool_result', tool_use_id: '', content: `Task not found: ${input.id}`, is_error: true } + } + + return { + type: 'tool_result', + tool_use_id: '', + content: JSON.stringify(task, null, 2), + } + }, +} + +// ============================================================================ +// TaskStopTool +// ============================================================================ + +export const TaskStopTool: ToolDefinition = { + name: 'TaskStop', + description: 'Stop/cancel a running task.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID to stop' }, + reason: { type: 'string', description: 'Reason for stopping' }, + }, + required: ['id'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Stop a task.' }, + async call(input: any): Promise { + const task = taskStore.get(input.id) + if (!task) { + return { type: 'tool_result', tool_use_id: '', content: `Task not found: ${input.id}`, is_error: true } + } + + task.status = 'cancelled' + task.updatedAt = new Date().toISOString() + if (input.reason) task.output = `Stopped: ${input.reason}` + + return { + type: 'tool_result', + tool_use_id: '', + content: `Task stopped: ${task.id}`, + } + }, +} + +// ============================================================================ +// TaskOutputTool +// ============================================================================ + +export const TaskOutputTool: ToolDefinition = { + name: 'TaskOutput', + description: 'Get the output/result of a task.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID' }, + }, + required: ['id'], + }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Get task output.' }, + async call(input: any): Promise { + const task = taskStore.get(input.id) + if (!task) { + return { type: 'tool_result', tool_use_id: '', content: `Task not found: ${input.id}`, is_error: true } + } + + return { + type: 'tool_result', + tool_use_id: '', + content: task.output || '(no output yet)', + } + }, +} diff --git a/src/tools/team-tools.ts b/src/tools/team-tools.ts new file mode 100644 index 0000000..2528d57 --- /dev/null +++ b/src/tools/team-tools.ts @@ -0,0 +1,128 @@ +/** + * Team Management Tools + * + * TeamCreate, TeamDelete - Multi-agent team coordination. + * Manages team composition, task lists, and inter-agent messaging. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +/** + * Team definition. + */ +export interface Team { + id: string + name: string + members: string[] + leaderId: string + taskListId?: string + createdAt: string + status: 'active' | 'disbanded' +} + +/** + * Global team store. + */ +const teamStore = new Map() +let teamCounter = 0 + +/** + * Get all teams. + */ +export function getAllTeams(): Team[] { + return Array.from(teamStore.values()) +} + +/** + * Get a team by ID. + */ +export function getTeam(id: string): Team | undefined { + return teamStore.get(id) +} + +/** + * Clear all teams. + */ +export function clearTeams(): void { + teamStore.clear() + teamCounter = 0 +} + +// ============================================================================ +// TeamCreateTool +// ============================================================================ + +export const TeamCreateTool: ToolDefinition = { + name: 'TeamCreate', + description: 'Create a multi-agent team for coordinated work. Assigns a lead and manages member composition.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Team name' }, + members: { + type: 'array', + items: { type: 'string' }, + description: 'List of agent/teammate names', + }, + task_description: { type: 'string', description: 'Description of the team\'s mission' }, + }, + required: ['name'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { return 'Create a team for multi-agent coordination.' }, + async call(input: any): Promise { + const id = `team_${++teamCounter}` + const team: Team = { + id, + name: input.name, + members: input.members || [], + leaderId: 'self', + createdAt: new Date().toISOString(), + status: 'active', + } + teamStore.set(id, team) + + return { + type: 'tool_result', + tool_use_id: '', + content: `Team created: ${id} "${team.name}" with ${team.members.length} members`, + } + }, +} + +// ============================================================================ +// TeamDeleteTool +// ============================================================================ + +export const TeamDeleteTool: ToolDefinition = { + name: 'TeamDelete', + description: 'Disband a team and clean up resources.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID to disband' }, + }, + required: ['id'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { return 'Delete/disband a team.' }, + async call(input: any): Promise { + const team = teamStore.get(input.id) + if (!team) { + return { type: 'tool_result', tool_use_id: '', content: `Team not found: ${input.id}`, is_error: true } + } + + team.status = 'disbanded' + teamStore.delete(input.id) + + return { + type: 'tool_result', + tool_use_id: '', + content: `Team disbanded: ${team.name}`, + } + }, +} diff --git a/src/tools/todo-tool.ts b/src/tools/todo-tool.ts new file mode 100644 index 0000000..f1a6497 --- /dev/null +++ b/src/tools/todo-tool.ts @@ -0,0 +1,112 @@ +/** + * TodoWriteTool - Session todo/checklist management + * + * Manages a session-scoped todo list for tracking work items. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +export interface TodoItem { + id: number + text: string + done: boolean + priority?: 'high' | 'medium' | 'low' +} + +const todoList: TodoItem[] = [] +let todoCounter = 0 + +/** + * Get all todos. + */ +export function getTodos(): TodoItem[] { + return [...todoList] +} + +/** + * Clear all todos. + */ +export function clearTodos(): void { + todoList.length = 0 + todoCounter = 0 +} + +export const TodoWriteTool: ToolDefinition = { + name: 'TodoWrite', + description: 'Manage a session todo/checklist. Supports add, toggle, remove, and list operations.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['add', 'toggle', 'remove', 'list', 'clear'], + description: 'Operation to perform', + }, + text: { type: 'string', description: 'Todo item text (for add)' }, + id: { type: 'number', description: 'Todo item ID (for toggle/remove)' }, + priority: { + type: 'string', + enum: ['high', 'medium', 'low'], + description: 'Priority level (for add)', + }, + }, + required: ['action'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Manage session todo list.' }, + async call(input: any): Promise { + switch (input.action) { + case 'add': { + if (!input.text) { + return { type: 'tool_result', tool_use_id: '', content: 'text required', is_error: true } + } + const item: TodoItem = { + id: ++todoCounter, + text: input.text, + done: false, + priority: input.priority, + } + todoList.push(item) + return { type: 'tool_result', tool_use_id: '', content: `Todo added: #${item.id} "${item.text}"` } + } + + case 'toggle': { + const item = todoList.find(t => t.id === input.id) + if (!item) { + return { type: 'tool_result', tool_use_id: '', content: `Todo #${input.id} not found`, is_error: true } + } + item.done = !item.done + return { type: 'tool_result', tool_use_id: '', content: `Todo #${item.id} ${item.done ? 'completed' : 'reopened'}` } + } + + case 'remove': { + const idx = todoList.findIndex(t => t.id === input.id) + if (idx === -1) { + return { type: 'tool_result', tool_use_id: '', content: `Todo #${input.id} not found`, is_error: true } + } + todoList.splice(idx, 1) + return { type: 'tool_result', tool_use_id: '', content: `Todo #${input.id} removed` } + } + + case 'list': { + if (todoList.length === 0) { + return { type: 'tool_result', tool_use_id: '', content: 'No todos.' } + } + const lines = todoList.map(t => + `${t.done ? '[x]' : '[ ]'} #${t.id} ${t.text}${t.priority ? ` (${t.priority})` : ''}` + ) + return { type: 'tool_result', tool_use_id: '', content: lines.join('\n') } + } + + case 'clear': { + todoList.length = 0 + return { type: 'tool_result', tool_use_id: '', content: 'All todos cleared.' } + } + + default: + return { type: 'tool_result', tool_use_id: '', content: `Unknown action: ${input.action}`, is_error: true } + } + }, +} diff --git a/src/tools/tool-search.ts b/src/tools/tool-search.ts new file mode 100644 index 0000000..f0f8d9b --- /dev/null +++ b/src/tools/tool-search.ts @@ -0,0 +1,87 @@ +/** + * ToolSearchTool - Discover deferred/lazy-loaded tools + * + * Allows the model to search for tools that haven't been loaded yet. + * Supports keyword search and exact name selection. + */ + +import type { ToolDefinition, ToolResult } from '../types.js' + +// Registry of deferred tools (set by the agent) +let deferredTools: ToolDefinition[] = [] + +/** + * Set deferred tools available for search. + */ +export function setDeferredTools(tools: ToolDefinition[]): void { + deferredTools = tools +} + +export const ToolSearchTool: ToolDefinition = { + name: 'ToolSearch', + description: 'Search for additional tools that may be available but not yet loaded. Use keyword search or exact name selection.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query. Use "select:ToolName" for exact match or keywords for search.', + }, + max_results: { + type: 'number', + description: 'Maximum results to return (default: 5)', + }, + }, + required: ['query'], + }, + isReadOnly: () => true, + isConcurrencySafe: () => true, + isEnabled: () => true, + async prompt() { return 'Search for available tools.' }, + async call(input: any): Promise { + const { query, max_results = 5 } = input + + if (deferredTools.length === 0) { + return { + type: 'tool_result', + tool_use_id: '', + content: 'No deferred tools available.', + } + } + + let matches: ToolDefinition[] + + if (query.startsWith('select:')) { + // Exact name selection + const names = query.slice(7).split(',').map((n: string) => n.trim()) + matches = deferredTools.filter(t => names.includes(t.name)) + } else { + // Keyword search + const keywords: string[] = query.toLowerCase().split(/\s+/) + matches = deferredTools + .filter(t => { + const searchText = `${t.name} ${t.description}`.toLowerCase() + return keywords.some((kw: string) => searchText.includes(kw)) + }) + .slice(0, max_results) + } + + if (matches.length === 0) { + return { + type: 'tool_result', + tool_use_id: '', + content: `No tools found matching "${query}"`, + } + } + + const lines = matches.map(t => + `- ${t.name}: ${t.description.slice(0, 200)}` + ) + + return { + type: 'tool_result', + tool_use_id: '', + content: `Found ${matches.length} tool(s):\n${lines.join('\n')}`, + } + }, +} diff --git a/src/tools/types.ts b/src/tools/types.ts new file mode 100644 index 0000000..c2e4aa6 --- /dev/null +++ b/src/tools/types.ts @@ -0,0 +1,62 @@ +/** + * Tool interface and helper utilities + */ + +import type { ToolDefinition, ToolInputSchema, ToolContext, ToolResult } from '../types.js' +import type Anthropic from '@anthropic-ai/sdk' + +/** + * Helper to create a tool definition with sensible defaults. + */ +export function defineTool(config: { + name: string + description: string + inputSchema: ToolInputSchema + call: (input: any, context: ToolContext) => Promise + isReadOnly?: boolean + isConcurrencySafe?: boolean + prompt?: string | ((context: ToolContext) => Promise) +}): ToolDefinition { + return { + name: config.name, + description: config.description, + inputSchema: config.inputSchema, + isReadOnly: () => config.isReadOnly ?? false, + isConcurrencySafe: () => config.isConcurrencySafe ?? false, + isEnabled: () => true, + prompt: typeof config.prompt === 'function' + ? config.prompt + : async (_context: ToolContext) => (config.prompt as string) ?? config.description, + async call(input: any, context: ToolContext): Promise { + try { + const result = await config.call(input, context) + const output = typeof result === 'string' ? result : result.data + const isError = typeof result === 'object' && result.is_error + return { + type: 'tool_result', + tool_use_id: '', // filled by engine + content: output, + is_error: isError || false, + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Error: ${err.message}`, + is_error: true, + } + } + }, + } +} + +/** + * Convert a ToolDefinition to API-compatible tool format. + */ +export function toApiTool(tool: ToolDefinition): Anthropic.Tool { + return { + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, + } +} diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts new file mode 100644 index 0000000..5e31249 --- /dev/null +++ b/src/tools/web-fetch.ts @@ -0,0 +1,66 @@ +/** + * WebFetchTool - Fetch web content + */ + +import { defineTool } from './types.js' + +export const WebFetchTool = defineTool({ + name: 'WebFetch', + description: 'Fetch content from a URL and return it as text. Supports HTML pages, JSON APIs, and plain text. Strips HTML tags for readability.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The URL to fetch content from', + }, + headers: { + type: 'object', + description: 'Optional HTTP headers', + }, + }, + required: ['url'], + }, + isReadOnly: true, + isConcurrencySafe: true, + async call(input, _context) { + const { url, headers } = input + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; AgentSDK/1.0)', + ...headers, + }, + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + return { data: `HTTP ${response.status}: ${response.statusText}`, is_error: true } + } + + const contentType = response.headers.get('content-type') || '' + let text = await response.text() + + // Strip HTML tags for readability + if (contentType.includes('text/html')) { + // Remove script and style blocks + text = text.replace(/]*>[\s\S]*?<\/script>/gi, '') + text = text.replace(/]*>[\s\S]*?<\/style>/gi, '') + // Remove HTML tags + text = text.replace(/<[^>]+>/g, ' ') + // Clean up whitespace + text = text.replace(/\s+/g, ' ').trim() + } + + // Truncate very large responses + if (text.length > 100000) { + text = text.slice(0, 100000) + '\n...(truncated)' + } + + return text || '(empty response)' + } catch (err: any) { + return { data: `Error fetching ${url}: ${err.message}`, is_error: true } + } + }, +}) diff --git a/src/tools/web-search.ts b/src/tools/web-search.ts new file mode 100644 index 0000000..5e6914a --- /dev/null +++ b/src/tools/web-search.ts @@ -0,0 +1,86 @@ +/** + * WebSearchTool - Web search (via web fetch of search engines) + */ + +import { defineTool } from './types.js' + +export const WebSearchTool = defineTool({ + name: 'WebSearch', + description: 'Search the web for information. Returns search results with titles, URLs, and snippets.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query', + }, + num_results: { + type: 'number', + description: 'Number of results to return (default: 5)', + }, + }, + required: ['query'], + }, + isReadOnly: true, + isConcurrencySafe: true, + async call(input, _context) { + const { query } = input + + try { + // Use DuckDuckGo HTML search as a free fallback + const encoded = encodeURIComponent(query) + const url = `https://html.duckduckgo.com/html/?q=${encoded}` + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; AgentSDK/1.0)', + }, + signal: AbortSignal.timeout(15000), + }) + + if (!response.ok) { + return { data: `Search failed: HTTP ${response.status}`, is_error: true } + } + + const html = await response.text() + + // Parse search results from DuckDuckGo HTML + const results: string[] = [] + const resultRegex = /]*>([\s\S]*?)<\/a>/gi + const snippetRegex = /]*>([\s\S]*?)<\/a>/gi + + let match + const links: Array<{ title: string; url: string }> = [] + + while ((match = resultRegex.exec(html)) !== null) { + const href = match[1] + const title = match[2].replace(/<[^>]+>/g, '').trim() + if (href && title && !href.includes('duckduckgo.com')) { + links.push({ title, url: href }) + } + } + + const snippets: string[] = [] + while ((match = snippetRegex.exec(html)) !== null) { + snippets.push(match[1].replace(/<[^>]+>/g, '').trim()) + } + + const numResults = Math.min(input.num_results || 5, links.length) + for (let i = 0; i < numResults; i++) { + const link = links[i] + if (!link) continue + let entry = `${i + 1}. ${link.title}\n ${link.url}` + if (snippets[i]) { + entry += `\n ${snippets[i]}` + } + results.push(entry) + } + + return results.length > 0 + ? results.join('\n\n') + : `No results found for "${query}"` + } catch (err: any) { + return { data: `Search error: ${err.message}`, is_error: true } + } + }, +}) diff --git a/src/tools/worktree-tools.ts b/src/tools/worktree-tools.ts new file mode 100644 index 0000000..82fe663 --- /dev/null +++ b/src/tools/worktree-tools.ts @@ -0,0 +1,140 @@ +/** + * Git Worktree Tools + * + * EnterWorktree / ExitWorktree - Isolated git worktree environments + * for parallel work without affecting the main working tree. + */ + +import { execSync } from 'child_process' +import { existsSync } from 'fs' +import { join } from 'path' +import type { ToolDefinition, ToolResult } from '../types.js' + +// Track active worktrees +const activeWorktrees = new Map() + +export const EnterWorktreeTool: ToolDefinition = { + name: 'EnterWorktree', + description: 'Create an isolated git worktree for parallel work. The agent will work in the worktree without affecting the main working tree.', + inputSchema: { + type: 'object', + properties: { + branch: { type: 'string', description: 'Branch name for the worktree (auto-generated if not provided)' }, + path: { type: 'string', description: 'Path for the worktree (auto-generated if not provided)' }, + }, + }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { return 'Create an isolated git worktree for parallel work.' }, + async call(input: any, context: { cwd: string }): Promise { + try { + // Check if we're in a git repo + execSync('git rev-parse --git-dir', { cwd: context.cwd, encoding: 'utf-8' }) + + const branch = input.branch || `worktree-${Date.now()}` + const worktreePath = input.path || join(context.cwd, '..', `.worktree-${branch}`) + + // Create the branch if it doesn't exist + try { + execSync(`git branch ${branch}`, { cwd: context.cwd, encoding: 'utf-8', stdio: 'pipe' }) + } catch { + // Branch might already exist + } + + // Create worktree + execSync(`git worktree add ${JSON.stringify(worktreePath)} ${branch}`, { + cwd: context.cwd, + encoding: 'utf-8', + }) + + const id = crypto.randomUUID() + activeWorktrees.set(id, { + path: worktreePath, + branch, + originalCwd: context.cwd, + }) + + return { + type: 'tool_result', + tool_use_id: '', + content: `Worktree created:\n ID: ${id}\n Path: ${worktreePath}\n Branch: ${branch}\n\nYou are now working in the isolated worktree.`, + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Error creating worktree: ${err.message}`, + is_error: true, + } + } + }, +} + +export const ExitWorktreeTool: ToolDefinition = { + name: 'ExitWorktree', + description: 'Exit and optionally remove a git worktree. Use "keep" to preserve changes or "remove" to clean up.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Worktree ID' }, + action: { + type: 'string', + enum: ['keep', 'remove'], + description: 'Whether to keep or remove the worktree (default: remove)', + }, + }, + required: ['id'], + }, + isReadOnly: () => false, + isConcurrencySafe: () => false, + isEnabled: () => true, + async prompt() { return 'Exit a git worktree.' }, + async call(input: any): Promise { + const worktree = activeWorktrees.get(input.id) + if (!worktree) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Worktree not found: ${input.id}`, + is_error: true, + } + } + + const action = input.action || 'remove' + + try { + if (action === 'remove') { + execSync(`git worktree remove ${JSON.stringify(worktree.path)} --force`, { + cwd: worktree.originalCwd, + encoding: 'utf-8', + }) + // Clean up branch + try { + execSync(`git branch -D ${worktree.branch}`, { + cwd: worktree.originalCwd, + encoding: 'utf-8', + stdio: 'pipe', + }) + } catch { + // Branch might have commits + } + } + + activeWorktrees.delete(input.id) + + return { + type: 'tool_result', + tool_use_id: '', + content: `Worktree ${action === 'remove' ? 'removed' : 'kept'}: ${worktree.path}`, + } + } catch (err: any) { + return { + type: 'tool_result', + tool_use_id: '', + content: `Error: ${err.message}`, + is_error: true, + } + } + }, +} diff --git a/src/tools/write.ts b/src/tools/write.ts new file mode 100644 index 0000000..6621f62 --- /dev/null +++ b/src/tools/write.ts @@ -0,0 +1,42 @@ +/** + * FileWriteTool - Write/create files + */ + +import { writeFile, mkdir } from 'fs/promises' +import { resolve, dirname } from 'path' +import { defineTool } from './types.js' + +export const FileWriteTool = defineTool({ + name: 'Write', + description: 'Write content to a file. Creates the file if it does not exist, or overwrites if it does. Creates parent directories as needed.', + inputSchema: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'The absolute path to the file to write', + }, + content: { + type: 'string', + description: 'The content to write to the file', + }, + }, + required: ['file_path', 'content'], + }, + isReadOnly: false, + isConcurrencySafe: false, + async call(input, context) { + const filePath = resolve(context.cwd, input.file_path) + + try { + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, input.content, 'utf-8') + + const lines = input.content.split('\n').length + const bytes = Buffer.byteLength(input.content, 'utf-8') + return `File written: ${filePath} (${lines} lines, ${bytes} bytes)` + } catch (err: any) { + return { data: `Error writing file: ${err.message}`, is_error: true } + } + }, +}) diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d93ef52 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,459 @@ +/** + * Core type definitions for the Agent SDK + */ + +import type Anthropic from '@anthropic-ai/sdk' + +// -------------------------------------------------------------------------- +// Message Types +// -------------------------------------------------------------------------- + +export type MessageRole = 'user' | 'assistant' + +export interface ConversationMessage { + role: MessageRole + content: string | Anthropic.ContentBlockParam[] +} + +export interface UserMessage { + type: 'user' + message: ConversationMessage + uuid: string + timestamp: string +} + +export interface AssistantMessage { + type: 'assistant' + message: { + role: 'assistant' + content: Anthropic.ContentBlock[] + } + uuid: string + timestamp: string + usage?: TokenUsage + cost?: number +} + +export type Message = UserMessage | AssistantMessage + +// -------------------------------------------------------------------------- +// SDK Message Types (streaming events) +// -------------------------------------------------------------------------- + +export type SDKMessage = + | SDKAssistantMessage + | SDKToolResultMessage + | SDKResultMessage + | SDKPartialMessage + | SDKSystemMessage + | SDKCompactBoundaryMessage + | SDKStatusMessage + | SDKTaskNotificationMessage + | SDKRateLimitEvent + +export interface SDKAssistantMessage { + type: 'assistant' + uuid?: string + session_id?: string + message: { + role: 'assistant' + content: Anthropic.ContentBlock[] + } + parent_tool_use_id?: string | null +} + +export interface SDKToolResultMessage { + type: 'tool_result' + result: { + tool_use_id: string + tool_name: string + output: string + } +} + +export interface SDKResultMessage { + type: 'result' + subtype: 'success' | 'error_max_turns' | 'error_during_execution' | 'error_max_budget_usd' | string + uuid?: string + session_id?: string + is_error?: boolean + num_turns?: number + result?: string + stop_reason?: string | null + total_cost_usd?: number + duration_ms?: number + duration_api_ms?: number + usage?: TokenUsage + model_usage?: Record + permission_denials?: Array<{ tool: string; reason: string }> + structured_output?: unknown + errors?: string[] + /** @deprecated Use total_cost_usd */ + cost?: number +} + +export interface SDKPartialMessage { + type: 'partial_message' + partial: { + type: 'text' | 'tool_use' + text?: string + name?: string + input?: string + } +} + +/** Emitted once at session start with initialization info. */ +export interface SDKSystemMessage { + type: 'system' + subtype: 'init' + uuid?: string + session_id: string + tools: string[] + model: string + cwd: string + mcp_servers: Array<{ name: string; status: string }> + permission_mode: string +} + +/** Marks a compaction boundary in the conversation. */ +export interface SDKCompactBoundaryMessage { + type: 'system' + subtype: 'compact_boundary' + summary?: string +} + +/** Status update during long operations. */ +export interface SDKStatusMessage { + type: 'system' + subtype: 'status' + message: string +} + +/** Task lifecycle notification. */ +export interface SDKTaskNotificationMessage { + type: 'system' + subtype: 'task_notification' + task_id: string + status: string + message?: string +} + +/** Rate limit event. */ +export interface SDKRateLimitEvent { + type: 'system' + subtype: 'rate_limit' + retry_after_ms?: number + message: string +} + +// -------------------------------------------------------------------------- +// Token Usage +// -------------------------------------------------------------------------- + +export interface TokenUsage { + input_tokens: number + output_tokens: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +// -------------------------------------------------------------------------- +// Tool Types +// -------------------------------------------------------------------------- + +export interface ToolDefinition { + name: string + description: string + inputSchema: ToolInputSchema + call: (input: any, context: ToolContext) => Promise + isReadOnly?: () => boolean + isConcurrencySafe?: () => boolean + isEnabled?: () => boolean + prompt?: (context: ToolContext) => Promise +} + +export interface ToolInputSchema { + type: 'object' + properties: Record + required?: string[] +} + +export interface ToolContext { + cwd: string + abortSignal?: AbortSignal +} + +export interface ToolResult { + type: 'tool_result' + tool_use_id: string + content: string | Anthropic.ToolResultBlockParam['content'] + is_error?: boolean +} + +// -------------------------------------------------------------------------- +// Permission Types +// -------------------------------------------------------------------------- + +export type PermissionMode = + | 'default' + | 'acceptEdits' + | 'bypassPermissions' + | 'plan' + | 'dontAsk' + | 'auto' + +export type CanUseToolResult = { + behavior: 'allow' | 'deny' + updatedInput?: unknown + message?: string +} + +export type CanUseToolFn = ( + tool: ToolDefinition, + input: unknown, +) => Promise + +// -------------------------------------------------------------------------- +// MCP Types +// -------------------------------------------------------------------------- + +export type McpServerConfig = + | McpStdioConfig + | McpSseConfig + | McpHttpConfig + +export interface McpStdioConfig { + type?: 'stdio' + command: string + args?: string[] + env?: Record +} + +export interface McpSseConfig { + type: 'sse' + url: string + headers?: Record +} + +export interface McpHttpConfig { + type: 'http' + url: string + headers?: Record +} + +// -------------------------------------------------------------------------- +// Agent Types +// -------------------------------------------------------------------------- + +export interface AgentDefinition { + description: string + prompt: string + tools?: string[] + disallowedTools?: string[] + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit' | string + mcpServers?: Array + skills?: string[] + maxTurns?: number + criticalSystemReminder_EXPERIMENTAL?: string +} + +export interface ThinkingConfig { + type: 'adaptive' | 'enabled' | 'disabled' + budgetTokens?: number +} + +// -------------------------------------------------------------------------- +// Sandbox Types +// -------------------------------------------------------------------------- + +export interface SandboxSettings { + enabled?: boolean + autoAllowBashIfSandboxed?: boolean + excludedCommands?: string[] + allowUnsandboxedCommands?: boolean + network?: SandboxNetworkConfig + filesystem?: SandboxFilesystemConfig + ignoreViolations?: Record + enableWeakerNestedSandbox?: boolean + ripgrep?: { command: string; args?: string[] } +} + +export interface SandboxNetworkConfig { + allowedDomains?: string[] + allowManagedDomainsOnly?: boolean + allowLocalBinding?: boolean + allowUnixSockets?: string[] + allowAllUnixSockets?: boolean + httpProxyPort?: number + socksProxyPort?: number +} + +export interface SandboxFilesystemConfig { + allowWrite?: string[] + denyWrite?: string[] + denyRead?: string[] +} + +// -------------------------------------------------------------------------- +// Output Format +// -------------------------------------------------------------------------- + +export interface OutputFormat { + type: 'json_schema' + schema: Record +} + +// -------------------------------------------------------------------------- +// Setting Sources +// -------------------------------------------------------------------------- + +export type SettingSource = 'user' | 'project' | 'local' + +// -------------------------------------------------------------------------- +// Model Info +// -------------------------------------------------------------------------- + +export interface ModelInfo { + value: string + displayName: string + description: string + supportsEffort?: boolean + supportedEffortLevels?: ('low' | 'medium' | 'high' | 'max')[] + supportsAdaptiveThinking?: boolean + supportsFastMode?: boolean +} + +export interface AgentOptions { + /** LLM model ID */ + model?: string + /** API key. Falls back to CODEANY_API_KEY env var. */ + apiKey?: string + /** API base URL override */ + baseURL?: string + /** Working directory for file/shell tools */ + cwd?: string + /** System prompt override or preset */ + systemPrompt?: string | { type: 'preset'; preset: 'default'; append?: string } + /** Append to default system prompt */ + appendSystemPrompt?: string + /** Available tools (ToolDefinition[] or string[] preset) */ + tools?: ToolDefinition[] | string[] | { type: 'preset'; preset: 'default' } + /** Maximum number of agentic turns per query */ + maxTurns?: number + /** Maximum USD budget per query */ + maxBudgetUsd?: number + /** Extended thinking configuration */ + thinking?: ThinkingConfig + /** Maximum thinking tokens (deprecated, use thinking.budgetTokens) */ + maxThinkingTokens?: number + /** Structured output JSON schema */ + jsonSchema?: Record + /** Structured output format */ + outputFormat?: OutputFormat + /** Permission handler callback */ + canUseTool?: CanUseToolFn + /** Permission mode controlling tool approval behavior */ + permissionMode?: PermissionMode + /** Abort controller for cancellation */ + abortController?: AbortController + /** Abort signal for cancellation */ + abortSignal?: AbortSignal + /** Whether to include partial streaming events */ + includePartialMessages?: boolean + /** Environment variables */ + env?: Record + /** Tool names to pre-approve without prompting */ + allowedTools?: string[] + /** Tool names to deny */ + disallowedTools?: string[] + /** MCP server configurations */ + mcpServers?: Record // supports McpSdkServerConfig + /** Custom subagent definitions */ + agents?: Record + /** Maximum tokens for responses */ + maxTokens?: number + /** Effort level for reasoning */ + effort?: 'low' | 'medium' | 'high' | 'max' + /** Fallback model if primary is unavailable */ + fallbackModel?: string + /** Continue the most recent session in cwd */ + continue?: boolean + /** Resume a specific session by ID */ + resume?: string + /** Fork a session instead of continuing it */ + forkSession?: boolean + /** Persist session to disk */ + persistSession?: boolean + /** Explicit session ID */ + sessionId?: string + /** Enable file checkpointing (for rewindFiles) */ + enableFileCheckpointing?: boolean + /** Sandbox configuration */ + sandbox?: SandboxSettings + /** Load settings from filesystem */ + settingSources?: SettingSource[] + /** Plugin configurations */ + plugins?: Array<{ name: string; config?: Record }> + /** Additional working directories */ + additionalDirectories?: string[] + /** Default agent to use */ + agent?: string + /** Debug mode */ + debug?: boolean + /** Debug log file */ + debugFile?: string + /** Tool-specific configuration */ + toolConfig?: Record + /** Enable prompt suggestions */ + promptSuggestions?: boolean + /** Strict MCP config validation */ + strictMcpConfig?: boolean + /** Extra CLI arguments */ + extraArgs?: Record + /** SDK betas to enable */ + betas?: string[] + /** Permission prompt tool name override */ + permissionPromptToolName?: string + /** Hook configurations */ + hooks?: Record Promise> + timeout?: number + }>> +} + +export interface QueryResult { + /** Final text output from the assistant */ + text: string + /** Token usage */ + usage: TokenUsage + /** Number of agentic turns */ + num_turns: number + /** Duration in milliseconds */ + duration_ms: number + /** All conversation messages */ + messages: Message[] +} + +// -------------------------------------------------------------------------- +// Query Engine Types +// -------------------------------------------------------------------------- + +export interface QueryEngineConfig { + cwd: string + model: string + apiKey?: string + baseURL?: string + tools: ToolDefinition[] + systemPrompt?: string + appendSystemPrompt?: string + maxTurns: number + maxBudgetUsd?: number + maxTokens: number + thinking?: ThinkingConfig + jsonSchema?: Record + canUseTool: CanUseToolFn + includePartialMessages: boolean + abortSignal?: AbortSignal + agents?: Record +} diff --git a/src/utils/compact.ts b/src/utils/compact.ts new file mode 100644 index 0000000..13a2199 --- /dev/null +++ b/src/utils/compact.ts @@ -0,0 +1,206 @@ +/** + * Context Compression / Auto-Compaction + * + * Summarizes long conversation histories when context window fills up. + * Three-tier system: + * 1. Auto-compact: triggered when tokens exceed threshold + * 2. Micro-compact: cache-aware per-request optimization + * 3. Session memory compaction: consolidates across sessions + */ + +import Anthropic from '@anthropic-ai/sdk' +import { + estimateMessagesTokens, + getAutoCompactThreshold, +} from './tokens.js' + +/** + * State for tracking auto-compaction across turns. + */ +export interface AutoCompactState { + compacted: boolean + turnCounter: number + consecutiveFailures: number +} + +/** + * Create initial auto-compact state. + */ +export function createAutoCompactState(): AutoCompactState { + return { + compacted: false, + turnCounter: 0, + consecutiveFailures: 0, + } +} + +/** + * Check if auto-compaction should trigger. + */ +export function shouldAutoCompact( + messages: Anthropic.MessageParam[], + model: string, + state: AutoCompactState, +): boolean { + if (state.consecutiveFailures >= 3) return false + + const estimatedTokens = estimateMessagesTokens(messages) + const threshold = getAutoCompactThreshold(model) + + return estimatedTokens >= threshold +} + +/** + * Compact conversation by summarizing with the LLM. + * + * Sends the entire conversation to the LLM for summarization, + * then replaces the history with a compact summary. + */ +export async function compactConversation( + client: Anthropic, + model: string, + messages: Anthropic.MessageParam[], + state: AutoCompactState, +): Promise<{ + compactedMessages: Anthropic.MessageParam[] + summary: string + state: AutoCompactState +}> { + try { + // Strip images before compacting to save tokens + const strippedMessages = stripImagesFromMessages(messages) + + // Build compaction prompt + const compactionPrompt = buildCompactionPrompt(strippedMessages) + + const response = await client.messages.create({ + model, + max_tokens: 8192, + system: 'You are a conversation summarizer. Create a detailed summary of the conversation that preserves all important context, decisions made, files modified, tool outputs, and current state. The summary should allow the conversation to continue seamlessly.', + messages: [ + { + role: 'user', + content: compactionPrompt, + }, + ], + }) + + const summary = response.content + .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .map((b) => b.text) + .join('\n') + + // Replace messages with summary + const compactedMessages: Anthropic.MessageParam[] = [ + { + role: 'user', + content: `[Previous conversation summary]\n\n${summary}\n\n[End of summary - conversation continues below]`, + }, + { + role: 'assistant', + content: 'I understand the context from the previous conversation. I\'ll continue from where we left off.', + }, + ] + + return { + compactedMessages, + summary, + state: { + compacted: true, + turnCounter: state.turnCounter, + consecutiveFailures: 0, + }, + } + } catch (err: any) { + return { + compactedMessages: messages, + summary: '', + state: { + ...state, + consecutiveFailures: state.consecutiveFailures + 1, + }, + } + } +} + +/** + * Strip images from messages for compaction safety. + */ +function stripImagesFromMessages( + messages: Anthropic.MessageParam[], +): Anthropic.MessageParam[] { + return messages.map((msg) => { + if (typeof msg.content === 'string') return msg + + const filtered = (msg.content as any[]).filter((block: any) => { + return block.type !== 'image' + }) + + return { ...msg, content: filtered.length > 0 ? filtered : '[content removed for compaction]' } + }) +} + +/** + * Build compaction prompt from messages. + */ +function buildCompactionPrompt(messages: Anthropic.MessageParam[]): string { + const parts: string[] = ['Please summarize this conversation:\n'] + + for (const msg of messages) { + const role = msg.role === 'user' ? 'User' : 'Assistant' + + if (typeof msg.content === 'string') { + parts.push(`${role}: ${msg.content.slice(0, 5000)}`) + } else if (Array.isArray(msg.content)) { + const texts: string[] = [] + for (const block of msg.content as any[]) { + if (block.type === 'text') { + texts.push(block.text.slice(0, 3000)) + } else if (block.type === 'tool_use') { + texts.push(`[Tool: ${block.name}]`) + } else if (block.type === 'tool_result') { + const content = typeof block.content === 'string' + ? block.content.slice(0, 1000) + : '[tool result]' + texts.push(`[Tool Result: ${content}]`) + } + } + if (texts.length > 0) { + parts.push(`${role}: ${texts.join('\n')}`) + } + } + } + + return parts.join('\n\n') +} + +/** + * Micro-compact: optimize messages by truncating large tool results + * to fit within token budgets. + */ +export function microCompactMessages( + messages: Anthropic.MessageParam[], + maxToolResultChars: number = 50000, +): Anthropic.MessageParam[] { + return messages.map((msg) => { + if (typeof msg.content === 'string') return msg + if (!Array.isArray(msg.content)) return msg + + const content = (msg.content as any[]).map((block: any) => { + if (block.type === 'tool_result' && typeof block.content === 'string') { + if (block.content.length > maxToolResultChars) { + return { + ...block, + content: + block.content.slice(0, maxToolResultChars / 2) + + '\n...(truncated)...\n' + + block.content.slice(-maxToolResultChars / 2), + } + } + } + return block + }) + + return { ...msg, content } + }) +} diff --git a/src/utils/context.ts b/src/utils/context.ts new file mode 100644 index 0000000..37d7cfa --- /dev/null +++ b/src/utils/context.ts @@ -0,0 +1,191 @@ +/** + * System & User Context + * + * Builds context for the system prompt: + * - Git status injection (branch, commits, status) + * - AGENT.md / project context discovery and injection + * - Working directory info + * - Date injection + */ + +import { execSync } from 'child_process' +import { readFile, stat } from 'fs/promises' +import { join, resolve } from 'path' + +// Memoization cache +let cachedGitStatus: string | null = null +let cachedGitStatusCwd: string | null = null + +/** + * Get git status info for system prompt. + * Memoized per cwd (cleared on new session). + */ +export async function getGitStatus(cwd: string): Promise { + if (cachedGitStatus && cachedGitStatusCwd === cwd) { + return cachedGitStatus + } + + try { + const parts: string[] = [] + + const gitExec = (cmd: string, timeoutMs = 5000): string | null => { + try { + return execSync(cmd, { + cwd, timeout: timeoutMs, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim() + } catch { + return null + } + } + + // Check if this is a git repo at all + if (!gitExec('git rev-parse --git-dir')) return '' + + // Current branch + const branch = gitExec('git rev-parse --abbrev-ref HEAD') + if (branch) parts.push(`Current branch: ${branch}`) + + // Main branch detection + const mainBranch = detectMainBranch(cwd) + if (mainBranch) parts.push(`Main branch: ${mainBranch}`) + + // Git user + const user = gitExec('git config user.name', 3000) + if (user) parts.push(`Git user: ${user}`) + + // Status (staged + unstaged) + const status = gitExec('git status --short') + if (status) { + const truncated = status.length > 2000 + ? status.slice(0, 2000) + '\n...(truncated)' + : status + parts.push(`Status:\n${truncated}`) + } + + // Recent commits (only if HEAD exists) + const hasHead = gitExec('git rev-parse HEAD') + if (hasHead) { + const log = gitExec('git log --oneline -5 --no-decorate') + if (log) parts.push(`Recent commits:\n${log}`) + } + + cachedGitStatus = parts.join('\n\n') + cachedGitStatusCwd = cwd + + return cachedGitStatus + } catch { + return '' + } +} + +/** + * Detect the main branch name (main or master). + */ +function detectMainBranch(cwd: string): string | null { + try { + const branches = execSync('git branch -l main master', { + cwd, timeout: 3000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim() + if (branches.includes('main')) return 'main' + if (branches.includes('master')) return 'master' + return null + } catch { + return null + } +} + +/** + * Discover project context files (AGENT.md, CLAUDE.md) in the project. + */ +export async function discoverProjectContextFiles(cwd: string): Promise { + const candidates = [ + join(cwd, 'AGENT.md'), + join(cwd, 'CLAUDE.md'), + join(cwd, '.claude', 'CLAUDE.md'), + join(cwd, 'claude.md'), + ] + + // Also check home directory + const home = process.env.HOME || process.env.USERPROFILE || '' + if (home) { + candidates.push( + join(home, '.claude', 'CLAUDE.md'), + ) + } + + const found: string[] = [] + for (const path of candidates) { + try { + const s = await stat(path) + if (s.isFile()) { + found.push(path) + } + } catch { + // File doesn't exist + } + } + + return found +} + +/** + * Read project context file content from discovered files. + */ +export async function readProjectContextContent(cwd: string): Promise { + const files = await discoverProjectContextFiles(cwd) + if (files.length === 0) return '' + + const parts: string[] = [] + for (const file of files) { + try { + const content = await readFile(file, 'utf-8') + if (content.trim()) { + parts.push(`# From ${file}:\n${content.trim()}`) + } + } catch { + // Skip unreadable files + } + } + + return parts.join('\n\n') +} + +/** + * Get system context for the system prompt. + */ +export async function getSystemContext(cwd: string): Promise { + const parts: string[] = [] + + const gitStatus = await getGitStatus(cwd) + if (gitStatus) { + parts.push(`gitStatus: ${gitStatus}`) + } + + return parts.join('\n\n') +} + +/** + * Get user context (AGENT.md, date, etc). + */ +export async function getUserContext(cwd: string): Promise { + const parts: string[] = [] + + // Current date + parts.push(`# currentDate\nToday's date is ${new Date().toISOString().split('T')[0]}.`) + + // Project context files + const projectCtx = await readProjectContextContent(cwd) + if (projectCtx) { + parts.push(projectCtx) + } + + return parts.join('\n\n') +} + +/** + * Clear memoized context (call between sessions). + */ +export function clearContextCache(): void { + cachedGitStatus = null + cachedGitStatusCwd = null +} diff --git a/src/utils/fileCache.ts b/src/utils/fileCache.ts new file mode 100644 index 0000000..9a4a85f --- /dev/null +++ b/src/utils/fileCache.ts @@ -0,0 +1,148 @@ +/** + * File State LRU Cache + * + * Bounded cache for file contents with path normalization. + * Used to track file states for compaction diffs and + * avoiding redundant reads. + */ + +import { normalize, resolve } from 'path' + +/** + * Cached file state. + */ +export interface FileState { + content: string + timestamp: number + offset?: number + limit?: number + isPartialView?: boolean +} + +/** + * LRU file state cache with size limits. + */ +export class FileStateCache { + private cache = new Map() + private maxEntries: number + private maxSizeBytes: number + private currentSizeBytes = 0 + + constructor(maxEntries: number = 100, maxSizeBytes: number = 25 * 1024 * 1024) { + this.maxEntries = maxEntries + this.maxSizeBytes = maxSizeBytes + } + + /** + * Normalize a file path for cache lookup. + */ + private normalizePath(filePath: string): string { + return normalize(resolve(filePath)) + } + + /** + * Get a cached file state. + */ + get(filePath: string): FileState | undefined { + const key = this.normalizePath(filePath) + const entry = this.cache.get(key) + if (entry) { + // Move to end (most recently used) + this.cache.delete(key) + this.cache.set(key, entry) + } + return entry + } + + /** + * Set a cached file state. + */ + set(filePath: string, state: FileState): void { + const key = this.normalizePath(filePath) + + // Remove old entry if exists + const old = this.cache.get(key) + if (old) { + this.currentSizeBytes -= Buffer.byteLength(old.content, 'utf-8') + this.cache.delete(key) + } + + const newSize = Buffer.byteLength(state.content, 'utf-8') + + // Evict entries if necessary + while ( + (this.cache.size >= this.maxEntries || this.currentSizeBytes + newSize > this.maxSizeBytes) && + this.cache.size > 0 + ) { + const firstKey = this.cache.keys().next().value + if (firstKey) { + const entry = this.cache.get(firstKey) + if (entry) { + this.currentSizeBytes -= Buffer.byteLength(entry.content, 'utf-8') + } + this.cache.delete(firstKey) + } + } + + this.cache.set(key, state) + this.currentSizeBytes += newSize + } + + /** + * Delete a cached entry. + */ + delete(filePath: string): boolean { + const key = this.normalizePath(filePath) + const entry = this.cache.get(key) + if (entry) { + this.currentSizeBytes -= Buffer.byteLength(entry.content, 'utf-8') + this.cache.delete(key) + return true + } + return false + } + + /** + * Clear all cached entries. + */ + clear(): void { + this.cache.clear() + this.currentSizeBytes = 0 + } + + /** + * Get the number of cached entries. + */ + get size(): number { + return this.cache.size + } + + /** + * Get all cached file paths. + */ + keys(): string[] { + return Array.from(this.cache.keys()) + } + + /** + * Clone the cache. + */ + clone(): FileStateCache { + const clone = new FileStateCache(this.maxEntries, this.maxSizeBytes) + for (const [key, value] of this.cache) { + clone.cache.set(key, { ...value }) + } + clone.currentSizeBytes = this.currentSizeBytes + return clone + } +} + +/** + * Create a file state cache with default limits. + */ +export function createFileStateCache( + maxEntries: number = 100, + maxSizeBytes: number = 25 * 1024 * 1024, +): FileStateCache { + return new FileStateCache(maxEntries, maxSizeBytes) +} diff --git a/src/utils/messages.ts b/src/utils/messages.ts new file mode 100644 index 0000000..08252aa --- /dev/null +++ b/src/utils/messages.ts @@ -0,0 +1,196 @@ +/** + * Message Utilities + * + * Message creation factories, normalization for API, + * synthetic placeholders, and content processing. + */ + +import type Anthropic from '@anthropic-ai/sdk' +import type { Message, UserMessage, AssistantMessage, TokenUsage } from '../types.js' + +/** + * Create a user message. + */ +export function createUserMessage( + content: string | Anthropic.ContentBlockParam[], + options?: { + uuid?: string + isMeta?: boolean + toolUseResult?: unknown + }, +): UserMessage { + return { + type: 'user', + message: { + role: 'user', + content, + }, + uuid: options?.uuid || crypto.randomUUID(), + timestamp: new Date().toISOString(), + } +} + +/** + * Create an assistant message. + */ +export function createAssistantMessage( + content: Anthropic.ContentBlock[], + usage?: TokenUsage, +): AssistantMessage { + return { + type: 'assistant', + message: { + role: 'assistant', + content, + }, + uuid: crypto.randomUUID(), + timestamp: new Date().toISOString(), + usage, + } +} + +/** + * Normalize messages for the LLM API. + * Ensures proper message format, strips internal metadata, + * and fixes tool result pairing. + */ +export function normalizeMessagesForAPI( + messages: Anthropic.MessageParam[], +): Anthropic.MessageParam[] { + const normalized: Anthropic.MessageParam[] = [] + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + + // Ensure alternating user/assistant messages + if (normalized.length > 0) { + const last = normalized[normalized.length - 1] + if (last.role === msg.role) { + // Merge same-role messages + if (msg.role === 'user') { + // Combine content + const lastContent = typeof last.content === 'string' + ? [{ type: 'text' as const, text: last.content }] + : last.content as any[] + const newContent = typeof msg.content === 'string' + ? [{ type: 'text' as const, text: msg.content }] + : msg.content as any[] + normalized[normalized.length - 1] = { + role: 'user', + content: [...lastContent, ...newContent], + } + continue + } + } + } + + normalized.push({ ...msg }) + } + + // Ensure tool results are properly paired with tool_use + return fixToolResultPairing(normalized) +} + +/** + * Fix tool result pairing: ensure every tool_result has a + * matching tool_use in the previous assistant message. + */ +function fixToolResultPairing( + messages: Anthropic.MessageParam[], +): Anthropic.MessageParam[] { + const result: Anthropic.MessageParam[] = [] + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + + if (msg.role === 'user' && Array.isArray(msg.content)) { + // Check for tool_result blocks + const toolResults = (msg.content as any[]).filter( + (block: any) => block.type === 'tool_result', + ) + + if (toolResults.length > 0 && result.length > 0) { + // Find the previous assistant message + const prevAssistant = result[result.length - 1] + if (prevAssistant.role === 'assistant' && Array.isArray(prevAssistant.content)) { + const toolUseIds = new Set( + (prevAssistant.content as any[]) + .filter((b: any) => b.type === 'tool_use') + .map((b: any) => b.id), + ) + + // Filter out orphaned tool results + const validContent = (msg.content as any[]).filter((block: any) => { + if (block.type === 'tool_result') { + return toolUseIds.has(block.tool_use_id) + } + return true + }) + + if (validContent.length > 0) { + result.push({ ...msg, content: validContent }) + } + continue + } + } + } + + result.push(msg) + } + + return result +} + +/** + * Strip images from messages (for compaction). + */ +export function stripImagesFromMessages( + messages: Anthropic.MessageParam[], +): Anthropic.MessageParam[] { + return messages.map((msg) => { + if (typeof msg.content === 'string') return msg + if (!Array.isArray(msg.content)) return msg + + const filtered = (msg.content as any[]).filter( + (block: any) => block.type !== 'image', + ) + + return { + ...msg, + content: filtered.length > 0 ? filtered : '[content removed]', + } + }) +} + +/** + * Extract text from message content blocks. + */ +export function extractTextFromContent( + content: Anthropic.ContentBlock[] | string, +): string { + if (typeof content === 'string') return content + + return content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text) + .join('') +} + +/** + * Create a system message for compact boundary. + */ +export function createCompactBoundaryMessage(): Anthropic.MessageParam { + return { + role: 'user', + content: '[Previous context has been summarized above. Continuing conversation.]', + } +} + +/** + * Truncate text to max length with ellipsis. + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + const half = Math.floor(maxLength / 2) + return text.slice(0, half) + '\n...(truncated)...\n' + text.slice(-half) +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..458de18 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,140 @@ +/** + * Retry Logic with Exponential Backoff + * + * Handles API retries for rate limits, overloaded servers, + * and transient failures. + */ + +/** + * Retry configuration. + */ +export interface RetryConfig { + maxRetries: number + baseDelayMs: number + maxDelayMs: number + retryableStatusCodes: number[] +} + +/** + * Default retry configuration. + */ +export const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + baseDelayMs: 2000, + maxDelayMs: 30000, + retryableStatusCodes: [429, 500, 502, 503, 529], +} + +/** + * Check if an error is retryable. + */ +export function isRetryableError(err: any, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean { + if (err?.status && config.retryableStatusCodes.includes(err.status)) { + return true + } + + // Network errors + if (err?.code === 'ECONNRESET' || err?.code === 'ETIMEDOUT' || err?.code === 'ECONNREFUSED') { + return true + } + + // API overloaded + if (err?.error?.type === 'overloaded_error') { + return true + } + + return false +} + +/** + * Calculate delay for exponential backoff. + */ +export function getRetryDelay(attempt: number, config: RetryConfig = DEFAULT_RETRY_CONFIG): number { + const delay = config.baseDelayMs * Math.pow(2, attempt) + // Add jitter (±25%) + const jitter = delay * 0.25 * (Math.random() * 2 - 1) + return Math.min(delay + jitter, config.maxDelayMs) +} + +/** + * Execute a function with retries. + */ +export async function withRetry( + fn: () => Promise, + config: RetryConfig = DEFAULT_RETRY_CONFIG, + abortSignal?: AbortSignal, +): Promise { + let lastError: any + + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { + if (abortSignal?.aborted) { + throw new Error('Aborted') + } + + try { + return await fn() + } catch (err: any) { + lastError = err + + if (!isRetryableError(err, config)) { + throw err + } + + if (attempt === config.maxRetries) { + throw err + } + + // Wait before retry + const delay = getRetryDelay(attempt, config) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + + throw lastError +} + +/** + * Check if an error is a "prompt too long" error. + */ +export function isPromptTooLongError(err: any): boolean { + if (err?.status === 400) { + const message = err?.error?.error?.message || err?.message || '' + return message.includes('prompt is too long') || + message.includes('max_tokens') || + message.includes('context length') + } + return false +} + +/** + * Check if error is an auth error. + */ +export function isAuthError(err: any): boolean { + return err?.status === 401 || err?.status === 403 +} + +/** + * Check if error is a rate limit error. + */ +export function isRateLimitError(err: any): boolean { + return err?.status === 429 +} + +/** + * Format an API error for display. + */ +export function formatApiError(err: any): string { + if (isAuthError(err)) { + return 'Authentication failed. Check your CODEANY_API_KEY.' + } + if (isRateLimitError(err)) { + return 'Rate limit exceeded. Please retry after a short wait.' + } + if (err?.status === 529) { + return 'API overloaded. Please retry later.' + } + if (isPromptTooLongError(err)) { + return 'Prompt too long. Auto-compacting conversation...' + } + return `API error: ${err.message || err}` +} diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts new file mode 100644 index 0000000..8b2b697 --- /dev/null +++ b/src/utils/tokens.ts @@ -0,0 +1,122 @@ +/** + * Token Estimation & Counting + * + * Provides rough token estimation (character-based) and + * API-based exact counting when available. + */ + +import Anthropic from '@anthropic-ai/sdk' + +/** + * Rough token estimation: ~4 chars per token (conservative). + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4) +} + +/** + * Estimate tokens for a message array. + */ +export function estimateMessagesTokens( + messages: Anthropic.MessageParam[], +): number { + let total = 0 + for (const msg of messages) { + if (typeof msg.content === 'string') { + total += estimateTokens(msg.content) + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if ('text' in block && typeof block.text === 'string') { + total += estimateTokens(block.text) + } else if ('content' in block && typeof block.content === 'string') { + total += estimateTokens(block.content) + } else { + // tool_use, image, etc - rough estimate + total += estimateTokens(JSON.stringify(block)) + } + } + } + } + return total +} + +/** + * Estimate tokens for a system prompt. + */ +export function estimateSystemPromptTokens(systemPrompt: string): number { + return estimateTokens(systemPrompt) +} + +/** + * Count tokens from API usage response. + */ +export function getTokenCountFromUsage(usage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +}): number { + return ( + usage.input_tokens + + usage.output_tokens + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + ) +} + +/** + * Get the context window size for a model. + */ +export function getContextWindowSize(model: string): number { + // Model context windows + if (model.includes('opus-4') && model.includes('1m')) return 1_000_000 + if (model.includes('opus-4')) return 200_000 + if (model.includes('sonnet-4')) return 200_000 + if (model.includes('haiku-4')) return 200_000 + + + if (model.includes('claude-3')) return 200_000 + + // Default + return 200_000 +} + +/** + * Auto-compact buffer: trigger compaction when within this many tokens of the limit. + */ +export const AUTOCOMPACT_BUFFER_TOKENS = 13_000 + +/** + * Get the auto-compact threshold for a model. + */ +export function getAutoCompactThreshold(model: string): number { + return getContextWindowSize(model) - AUTOCOMPACT_BUFFER_TOKENS +} + +/** + * Model pricing (USD per token). + */ +export const MODEL_PRICING: Record = { + 'claude-opus-4-6': { input: 15 / 1_000_000, output: 75 / 1_000_000 }, + 'claude-opus-4-5': { input: 15 / 1_000_000, output: 75 / 1_000_000 }, + 'claude-sonnet-4-6': { input: 3 / 1_000_000, output: 15 / 1_000_000 }, + 'claude-sonnet-4-5': { input: 3 / 1_000_000, output: 15 / 1_000_000 }, + 'claude-haiku-4-5': { input: 0.8 / 1_000_000, output: 4 / 1_000_000 }, + 'claude-3-5-sonnet': { input: 3 / 1_000_000, output: 15 / 1_000_000 }, + 'claude-3-5-haiku': { input: 0.8 / 1_000_000, output: 4 / 1_000_000 }, + 'claude-3-opus': { input: 15 / 1_000_000, output: 75 / 1_000_000 }, +} + +/** + * Estimate cost from usage and model. + */ +export function estimateCost( + model: string, + usage: { input_tokens: number; output_tokens: number }, +): number { + const pricing = Object.entries(MODEL_PRICING).find(([key]) => + model.includes(key), + )?.[1] ?? { input: 3 / 1_000_000, output: 15 / 1_000_000 } + + return usage.input_tokens * pricing.input + usage.output_tokens * pricing.output +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..89e8dd5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "examples"] +}