diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/tools | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/tools')
20 files changed, 0 insertions, 3107 deletions
diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts deleted file mode 100644 index 1aaba8e..0000000 --- a/packages/core/src/tools/bash-arity.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Hardcoded dictionary of well-known commands and their arity -// (number of tokens that form the "human-understandable" prefix) -const ARITY: Record<string, number> = { - git: 2, // "git checkout", "git commit", etc. - npm: 3, // "npm run dev", "npm install -g" - docker: 2, // "docker compose", "docker build" - kubectl: 2, // "kubectl get", "kubectl apply" - bun: 2, // "bun install", "bun test" - cargo: 2, // "cargo build", "cargo test" - go: 2, // "go build", "go test" - python: 2, // "python -m", "python script.py" - python3: 2, - pip: 2, - pip3: 2, - brew: 2, - apt: 2, - "apt-get": 2, - dnf: 2, - yum: 2, - pacman: 2, - systemctl: 2, - journalctl: 2, - ssh: 2, - scp: 2, - rsync: 2, - curl: 2, // "curl -X", "curl https://" - wget: 2, - tar: 2, // "tar -xzf", "tar -czf" - zip: 2, - unzip: 2, - chown: 2, - chmod: 2, - mount: 2, - umount: 2, - // Default: all other commands are arity 1 -}; - -// Get the normalized prefix for a list of tokens -export function prefix(tokens: string[]): string[] { - if (tokens.length === 0) return []; - const first = tokens[0]; - if (!first) return []; - const arity = ARITY[first.toLowerCase()]; - if (arity === undefined) { - return [first]; - } - return tokens.slice(0, arity); -} diff --git a/packages/core/src/tools/key-usage.ts b/packages/core/src/tools/key-usage.ts deleted file mode 100644 index 0655ad7..0000000 --- a/packages/core/src/tools/key-usage.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { z } from "zod"; -import type { ClaudeAccount, ClaudeUsageReport, ClaudeUsageResult } from "../credentials/claude.js"; -import { getAccountUsageWithSource } from "../credentials/claude.js"; -import type { OpencodeUsageReport } from "../credentials/opencode.js"; -import { fetchOpencodeUsage as defaultFetchOpencodeUsage } from "../credentials/opencode.js"; -import type { KeyState, ToolDefinition } from "../types/index.js"; - -/** - * Collaborators the `key_usage` tool needs from the API layer (which owns the - * live `ModelRegistry` and the discovered Claude accounts). The two `fetch*` - * hooks default to the real credential fetchers but are injectable so tests can - * exercise the tool without network or DB access. - */ -export interface KeyUsageCallbacks { - /** Current key states from the model registry (definition + active/exhausted status). */ - listKeys(): KeyState[]; - /** Discovered Claude accounts, used to resolve `anthropic` keys to credentials. */ - listClaudeAccounts(): ClaudeAccount[]; - /** - * Fetch an anthropic account's usage with provenance (live vs cache). - * Defaults to `getAccountUsageWithSource`. - */ - fetchAnthropicUsage?: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>; - /** - * Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no - * local cache). Defaults to `fetchOpencodeUsage`. - */ - fetchOpencodeUsage?: (keyId: string) => Promise<OpencodeUsageReport | null>; -} - -/** A single normalized usage window (5-hour / week / month). */ -interface UsageWindow { - label: string; - /** Remaining headroom as a 0–100 percentage. Omitted when the source gives no utilization. */ - remainingPercent?: number; - /** Epoch-ms the window resets. Omitted when the source gives no reset time. */ - resetsAt?: number; -} - -/** Fully normalized per-key usage, ready for rendering. */ -interface KeyUsageEntry { - keyId: string; - provider: string; - status: "active" | "exhausted"; - lastError?: string; - exhaustedAt?: number; - /** Provenance of the usage figures: a fresh live fetch or a cached payload. */ - dataSource?: "live" | "cache"; - /** Epoch-ms the cached payload was last fetched from source (only on `dataSource: "cache"`). */ - cachedAt?: number; - windows: UsageWindow[]; - /** Set when no usage figures could be obtained for an otherwise-supported key. */ - unavailableReason?: string; - /** Set when the provider has no usage-reporting support. */ - unsupported?: boolean; -} - -function clampPercent(value: number): number { - if (value < 0) return 0; - if (value > 100) return 100; - return value; -} - -/** Convert a raw `{ utilization, resetsAt }` bucket into a normalized window. */ -function toWindow( - label: string, - bucket?: { utilization?: number; resetsAt?: number }, -): UsageWindow | null { - if (!bucket) return null; - const hasUtil = typeof bucket.utilization === "number"; - const hasReset = typeof bucket.resetsAt === "number"; - if (!hasUtil && !hasReset) return null; - return { - label, - ...(hasUtil - ? { remainingPercent: clampPercent(Math.round((1 - (bucket.utilization as number)) * 100)) } - : {}), - ...(hasReset ? { resetsAt: bucket.resetsAt } : {}), - }; -} - -function anthropicWindows(report: ClaudeUsageReport): UsageWindow[] { - const windows: UsageWindow[] = []; - const fiveHour = toWindow("5-hour", report.fiveHour); - if (fiveHour) windows.push(fiveHour); - const week = toWindow("week", report.sevenDay); - if (week) windows.push(week); - return windows; -} - -function opencodeWindows(report: OpencodeUsageReport): UsageWindow[] { - const windows: UsageWindow[] = []; - const fiveHour = toWindow("5-hour", report.fiveHour); - if (fiveHour) windows.push(fiveHour); - const week = toWindow("week", report.weekly); - if (week) windows.push(week); - const month = toWindow("month", report.monthly); - if (month) windows.push(month); - return windows; -} - -/** - * Resolve which Claude account backs an `anthropic` key. Matches by key id or by - * the account's source file (the key's `credentials_file`), falling back to the - * first available account — mirrors the existing `/models/key-usage` route. - */ -function matchAnthropicAccount( - accounts: ClaudeAccount[], - keyId: string, - credFile?: string, -): ClaudeAccount | undefined { - const matched = accounts.find( - (a) => a.id === keyId || (credFile != null && a.source === credFile), - ); - return matched ?? accounts[0]; -} - -function iso(ms: number): string { - return new Date(ms).toISOString(); -} - -/** Human-readable coarse duration, e.g. "3h 12m", "5d 8h", "0m". */ -function formatDuration(ms: number): string { - const totalSec = Math.round(Math.abs(ms) / 1000); - const days = Math.floor(totalSec / 86400); - const hours = Math.floor((totalSec % 86400) / 3600); - const minutes = Math.floor((totalSec % 3600) / 60); - const parts: string[] = []; - if (days > 0) parts.push(`${days}d`); - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`); - return parts.join(" "); -} - -function formatRelative(targetMs: number, nowMs: number): string { - const delta = targetMs - nowMs; - return delta >= 0 ? `in ${formatDuration(delta)}` : `${formatDuration(delta)} ago`; -} - -function formatWindow(window: UsageWindow, now: number): string { - const parts: string[] = []; - if (typeof window.remainingPercent === "number") { - parts.push(`${window.remainingPercent}% remaining`); - } - if (typeof window.resetsAt === "number") { - parts.push(`resets ${iso(window.resetsAt)} (${formatRelative(window.resetsAt, now)})`); - } - return `${window.label}: ${parts.join(", ")}`; -} - -/** - * Render normalized usage entries into an AI-friendly text block. Pure — `now` - * is injected so relative timestamps are deterministic under test. - */ -export function formatKeyUsage(entries: KeyUsageEntry[], now: number): string { - if (entries.length === 0) return "No API keys matched."; - - const lines: string[] = []; - lines.push(`API key usage — ${entries.length} key${entries.length === 1 ? "" : "s"}:`); - - for (const entry of entries) { - lines.push(""); - lines.push(`[${entry.keyId}] provider: ${entry.provider}`); - - if (entry.status === "exhausted") { - const since = - typeof entry.exhaustedAt === "number" - ? ` (since ${iso(entry.exhaustedAt)}, ${formatRelative(entry.exhaustedAt, now)})` - : ""; - lines.push(`status: EXHAUSTED${since}`); - if (entry.lastError) lines.push(`last error: ${entry.lastError}`); - } else { - lines.push("status: active"); - } - - if (entry.unsupported) { - lines.push( - `usage: not supported for provider "${entry.provider}" (only anthropic and opencode-go report usage)`, - ); - continue; - } - - if (entry.dataSource === "live") { - lines.push("data: live (fetched just now)"); - } else if (entry.dataSource === "cache") { - lines.push( - typeof entry.cachedAt === "number" - ? `data: cached — last fetched from source ${iso(entry.cachedAt)} (${formatRelative(entry.cachedAt, now)})` - : "data: cached (source timestamp unknown)", - ); - } - - for (const window of entry.windows) { - lines.push(formatWindow(window, now)); - } - - if (entry.unavailableReason) { - lines.push(`usage: unavailable — ${entry.unavailableReason}`); - } - } - - return lines.join("\n"); -} - -async function buildEntry( - key: KeyState, - accounts: ClaudeAccount[], - fetchAnthropic: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>, - fetchOpencode: (keyId: string) => Promise<OpencodeUsageReport | null>, -): Promise<KeyUsageEntry> { - const def = key.definition; - const entry: KeyUsageEntry = { - keyId: def.id, - provider: def.provider, - status: key.status, - windows: [], - ...(key.lastError ? { lastError: key.lastError } : {}), - ...(typeof key.exhaustedAt === "number" ? { exhaustedAt: key.exhaustedAt } : {}), - }; - - if (def.provider === "anthropic") { - const account = matchAnthropicAccount(accounts, def.id, def.credentials_file); - if (!account) { - entry.unavailableReason = "no Claude account credentials available for this key"; - return entry; - } - let result: ClaudeUsageResult | null = null; - try { - result = await fetchAnthropic(account); - } catch { - result = null; - } - if (!result) { - entry.unavailableReason = "no live usage data and no cached usage available"; - return entry; - } - entry.dataSource = result.source; - if (typeof result.cachedAt === "number") entry.cachedAt = result.cachedAt; - entry.windows = anthropicWindows(result.report); - if (entry.windows.length === 0) { - entry.unavailableReason = "usage endpoint returned no window data"; - } - return entry; - } - - if (def.provider === "opencode-go") { - let report: OpencodeUsageReport | null = null; - try { - report = await fetchOpencode(def.id); - } catch { - report = null; - } - if (!report) { - entry.unavailableReason = - "live usage unavailable (requires OPENCODE_COOKIE and a workspace id, or the source returned no data; OpenCode keeps no local cache)"; - return entry; - } - entry.dataSource = "live"; - entry.windows = opencodeWindows(report); - if (entry.windows.length === 0) { - entry.unavailableReason = "usage source returned no window data"; - } - return entry; - } - - entry.unsupported = true; - return entry; -} - -export function createKeyUsageTool(callbacks: KeyUsageCallbacks): ToolDefinition { - const fetchAnthropic = callbacks.fetchAnthropicUsage ?? getAccountUsageWithSource; - const fetchOpencode = callbacks.fetchOpencodeUsage ?? defaultFetchOpencodeUsage; - - return { - name: "key_usage", - description: [ - "Report current usage levels for configured API keys so you can pick a key with", - "headroom, warn before hitting a rate limit, or diagnose an exhausted-key failure.", - "", - "For each key it returns: provider, active/exhausted status (with the last error when", - "exhausted), remaining rate-limit headroom per window (5-hour, weekly, and monthly where", - "the provider exposes it), each window's reset timestamp, and whether the figures are", - "live or served from cache (with the cache's last-fetched time).", - "", - "Pass a key_id to inspect one key; omit it to report all keys. Usage reporting is", - "supported for anthropic and opencode-go keys.", - ].join("\n"), - parameters: z.object({ - key_id: z - .string() - .optional() - .describe( - 'The id of a single key to report (as configured in dispatch.toml, e.g. "claude-max"). Omit to report all configured keys.', - ), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const requestedKeyId = (args.key_id as string | undefined)?.trim() || undefined; - - const allKeys = callbacks.listKeys(); - if (allKeys.length === 0) { - return "No API keys are configured."; - } - - let keys = allKeys; - if (requestedKeyId) { - keys = allKeys.filter((k) => k.definition.id === requestedKeyId); - if (keys.length === 0) { - const available = allKeys.map((k) => k.definition.id).join(", "); - return `Error: no key found with id "${requestedKeyId}". Available keys: ${available}.`; - } - } - - const accounts = callbacks.listClaudeAccounts(); - const entries: KeyUsageEntry[] = []; - for (const key of keys) { - entries.push(await buildEntry(key, accounts, fetchAnthropic, fetchOpencode)); - } - - return formatKeyUsage(entries, Date.now()); - }, - }; -} diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts deleted file mode 100644 index e003099..0000000 --- a/packages/core/src/tools/list-files.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { readdir } from "node:fs/promises"; -import { z } from "zod"; -import type { ToolDefinition } from "../types/index.js"; -import { canonicalize } from "./path-utils.js"; - -export function createListFilesTool(workingDirectory: string): ToolDefinition { - return { - name: "list_files", - description: "List files and directories at a path relative to the working directory.", - parameters: z.object({ - path: z - .string() - .optional() - .describe("Path to list, relative to the working directory. Defaults to '.'"), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const relPath = (args.path as string | undefined) ?? "."; - // Canonicalize so a symlink-in-workdir pointing outside is detected. - // See `canonicalize` in ./path-utils.ts for the resolution semantics. - const absolutePath = await canonicalize(workingDirectory, relPath); - const absoluteWorkDir = await canonicalize(workingDirectory); - - if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) { - return `Error: Path "${relPath}" is outside the working directory.`; - } - - try { - const entries = await readdir(absolutePath, { withFileTypes: true }); - if (entries.length === 0) { - return "(empty directory)"; - } - return entries - .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name)) - .join("\n"); - } catch (err) { - return `Error listing files: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }; -} diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts deleted file mode 100644 index 3842cd4..0000000 --- a/packages/core/src/tools/lsp.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { isAbsolute, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { z } from "zod"; -import { report as reportDiagnostics } from "../lsp/diagnostic.js"; -import type { LspManager } from "../lsp/manager.js"; -import type { ResolvedLspServer } from "../lsp/server.js"; -import type { ToolDefinition } from "../types/index.js"; - -const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const; -type Operation = (typeof OPERATIONS)[number]; - -/** - * Context the LSP tool needs from the host: the live manager, the tab's - * effective working directory (used as the LSP `root`), and the servers - * resolved from that directory's `dispatch.toml`. - */ -export interface LspToolContext { - manager: LspManager; - workingDirectory: string; - servers: ResolvedLspServer[]; -} - -/** - * On-demand LSP query tool. Exposes diagnostics plus the navigation - * capabilities (hover/definition/references/documentSymbol) for a file at a - * position. Gated behind `perm_lsp` by the host. - * - * Coordinates are **1-based** in this tool's API (editor-style, matching what - * `read_file` shows); they are converted to the LSP wire's 0-based positions - * before the request. - */ -export function createLspTool(getContext: () => LspToolContext): ToolDefinition { - return { - name: "lsp", - description: - "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " + - "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " + - "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " + - "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " + - "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.", - parameters: z.object({ - operation: z.enum(OPERATIONS).describe("The LSP operation to perform"), - path: z.string().describe("Path to the file, relative to the working directory"), - line: z - .number() - .int() - .min(1) - .optional() - .describe( - "Line number, 1-based (as shown in editors). Required for hover/definition/references.", - ), - character: z - .number() - .int() - .min(1) - .optional() - .describe( - "Character/column, 1-based (as shown in editors). Required for hover/definition/references.", - ), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const { manager, workingDirectory, servers } = getContext(); - const operation = args.operation as Operation; - const pathArg = typeof args.path === "string" ? args.path : ""; - if (!pathArg) return "Error: 'path' is required."; - - const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg); - - if (servers.length === 0) { - return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml."; - } - if (!manager.hasServerForFile(file, servers)) { - return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`; - } - - // Sync the file so the server has current content, then act. - await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" }); - - if (operation === "diagnostics") { - const all = manager.getDiagnostics({ root: workingDirectory, servers, file }); - const block = reportDiagnostics(file, all[file] ?? []); - return block || `No errors reported for ${pathArg}.`; - } - - if (operation === "documentSymbol") { - const uri = pathToFileURL(file).href; - const results = await manager.request({ - file, - root: workingDirectory, - servers, - method: "textDocument/documentSymbol", - params: { textDocument: { uri } }, - }); - const flat = results.flat().filter(Boolean); - return flat.length === 0 - ? `No symbols found in ${pathArg}.` - : JSON.stringify(flat, null, 2); - } - - // Positional operations need line + character. - const line = typeof args.line === "number" ? Math.floor(args.line) : undefined; - const character = typeof args.character === "number" ? Math.floor(args.character) : undefined; - if (line === undefined || character === undefined) { - return `Error: '${operation}' requires both 'line' and 'character' (1-based).`; - } - - const uri = pathToFileURL(file).href; - // Convert editor 1-based → LSP wire 0-based. - const position = { line: line - 1, character: character - 1 }; - const method = - operation === "hover" - ? "textDocument/hover" - : operation === "definition" - ? "textDocument/definition" - : "textDocument/references"; - const params: Record<string, unknown> = { - textDocument: { uri }, - position, - ...(operation === "references" ? { context: { includeDeclaration: true } } : {}), - }; - - const results = await manager.request({ - file, - root: workingDirectory, - servers, - method, - params, - }); - const flat = results.flat().filter(Boolean); - return flat.length === 0 - ? `No results found for ${operation} at ${pathArg}:${line}:${character}.` - : JSON.stringify(flat, null, 2); - }, - }; -} diff --git a/packages/core/src/tools/path-utils.ts b/packages/core/src/tools/path-utils.ts deleted file mode 100644 index 3bba0d3..0000000 --- a/packages/core/src/tools/path-utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { realpath } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; - -/** - * Resolve a path to its canonical absolute form, following symlinks at - * every level. - * - * When the leaf does not exist (common for `write_file` creating a new - * file), walks up to the nearest existing ancestor, canonicalizes that, - * then re-appends the missing trailing segments — ensuring a symlink in - * the *middle* of the path is still resolved. Without this, a - * `workdir/escape-link/new-file.txt` write where `escape-link` points - * outside the workdir would slip the containment check. - * - * Used everywhere we compare a user-supplied path against a trusted - * root (workdir, SPILL_ROOT). Resolving symlinks consistently is the - * only reliable way to detect a symlink-in-workdir-pointing-outside - * escape; lexical-only checks let those through. - * - * Argument semantics match `path.resolve(...paths)`: later absolute - * segments override earlier ones, relative segments are joined. - */ -export async function canonicalize(...paths: string[]): Promise<string> { - const lexical = resolve(...paths); - - // Fast path: full path exists, realpath resolves all symlinks. - try { - return await realpath(lexical); - } catch { - // Path doesn't exist — fall through to ancestor walk. - } - - // Walk up until we hit an existing ancestor we can realpath, then - // re-append the missing trailing segments. This handles cases like - // write_file creating /workdir/symlink/new/dir/file.txt where the - // "symlink" segment exists but the rest doesn't. - let current = lexical; - const trailing: string[] = []; - while (true) { - const parent = dirname(current); - if (parent === current) break; // hit filesystem root - trailing.unshift(basename(current)); - try { - const realParent = await realpath(parent); - return join(realParent, ...trailing); - } catch { - current = parent; - } - } - - // No existing ancestor (pathological — e.g. the entire mount is gone). - // Return the lexical path; downstream containment checks will still - // catch obvious escapes via `..` etc. - return lexical; -} diff --git a/packages/core/src/tools/read-file-slice.ts b/packages/core/src/tools/read-file-slice.ts deleted file mode 100644 index 0f83bcb..0000000 --- a/packages/core/src/tools/read-file-slice.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { z } from "zod"; -import type { ToolDefinition } from "../types/index.js"; -import { canonicalize } from "./path-utils.js"; -import { SPILL_ROOT } from "./truncate.js"; - -const DEFAULT_LENGTH = 5000; - -export function createReadFileSliceTool(workingDirectory: string): ToolDefinition { - return { - name: "read_file_slice", - description: - "Read a character-range slice of a single line in a file. Use this when read_file returns a truncated line marker like '[line N truncated, total X chars; use read_file_slice ...]', or when you need precise byte-ish access into a minified file (huge JSON, base64 blob, etc.). For normal line-oriented reading use read_file with offset/limit instead.", - parameters: z.object({ - path: z.string().describe("Path to the file, relative to the working directory."), - line: z.number().int().min(1).describe("1-indexed line number to slice into."), - charOffset: z - .number() - .int() - .min(0) - .optional() - .describe("0-indexed character offset within the line. Default: 0 (start of line)."), - charLength: z - .number() - .int() - .min(1) - .optional() - .describe( - `Max characters to return from the slice. Default: ${DEFAULT_LENGTH}. The universal tool-output truncator will still spill if the result is huge.`, - ), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const filePath = args.path as string; - const lineNumber = args.line as number; - const charOffset = - typeof args.charOffset === "number" ? Math.max(0, Math.floor(args.charOffset)) : 0; - const charLength = - typeof args.charLength === "number" - ? Math.max(1, Math.floor(args.charLength)) - : DEFAULT_LENGTH; - - // Canonicalize all three so symlink-in-workdir escapes are detected. - // See `canonicalize` in ./path-utils.ts for the resolution semantics. - const absolutePath = await canonicalize(workingDirectory, filePath); - const absoluteWorkDir = await canonicalize(workingDirectory); - const absoluteSpillRoot = await canonicalize(SPILL_ROOT); - const isUnderWorkdir = - absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`); - const isSpillFile = - absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`); - - if (!isUnderWorkdir && !isSpillFile) { - return `Error: Path "${filePath}" is outside the working directory.`; - } - - let raw: string; - try { - raw = await readFile(absolutePath, "utf8"); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return `Error: File "${filePath}" not found.`; - } - return `Error reading file: ${err instanceof Error ? err.message : String(err)}`; - } - - const lines = raw.split("\n"); - const trailingNewline = raw.endsWith("\n"); - const totalLines = trailingNewline ? lines.length - 1 : lines.length; - - if (lineNumber > totalLines) { - return `Error: line ${lineNumber} exceeds file length (${totalLines} lines).`; - } - - const line = lines[lineNumber - 1] ?? ""; - const lineLength = line.length; - - if (charOffset >= lineLength) { - return `Error: charOffset ${charOffset} exceeds line length (${lineLength} chars).`; - } - - const sliceEnd = Math.min(charOffset + charLength, lineLength); - const slice = line.slice(charOffset, sliceEnd); - const remaining = lineLength - sliceEnd; - - const header = `[file: ${filePath} — line ${lineNumber}, chars ${charOffset}-${sliceEnd} of ${lineLength}${remaining > 0 ? ` (${remaining.toLocaleString()} chars remain after this slice)` : ""}]`; - return `${header}\n${slice}`; - }, - }; -} diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts deleted file mode 100644 index fa82dce..0000000 --- a/packages/core/src/tools/read-file.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { z } from "zod"; -import type { ToolDefinition } from "../types/index.js"; -import { canonicalize } from "./path-utils.js"; -import { MAX_LINES, SPILL_ROOT } from "./truncate.js"; - -// Per-line truncation: any single line longer than MAX_LINE_CHARS is cut and -// replaced with a marker indicating the total length. This protects against -// minified files / base64 blobs / massive single-line JSON. The AI can use -// `read_file_slice` to inspect a specific char range within a long line. -const MAX_LINE_CHARS = 2000; - -// Aligned with the universal truncator's MAX_LINES so a default-args read -// returns a response that fits under the truncator's line ceiling. Without -// this alignment, every default read of a >500-line file got returned by -// the tool and then immediately spilled by the truncator — wasted work. -// Char-dense files can still spill via MAX_CHARS, but that's content- -// dependent rather than guaranteed. -const DEFAULT_LIMIT = MAX_LINES; -// Hard cap on lines per request even if `limit` is larger or omitted. -// Prevents a `read_file(huge.log)` with no params from returning a million -// lines. The universal truncator at the agent level will spill anything -// over its own threshold, but this is a tighter first line of defense -// scoped to the read tool itself. -const HARD_LIMIT = 5000; - -export function createReadFileTool(workingDirectory: string): ToolDefinition { - return { - name: "read_file", - description: - "Read a file relative to the working directory. Returns up to `limit` lines starting at line `offset` (1-indexed). Lines longer than 2000 chars are truncated mid-line with a marker showing the total length — use the `read_file_slice` tool to read a specific char range within a long line. If the response is still too large, the dispatch tool-output truncator may spill the full content to /tmp/dispatch/tool-results/.", - parameters: z.object({ - path: z.string().describe("Path to the file, relative to the working directory"), - offset: z - .number() - .int() - .min(1) - .optional() - .describe("1-indexed start line. Default: 1 (start of file)."), - limit: z - .number() - .int() - .min(1) - .optional() - .describe( - `Max lines to return. Default: ${DEFAULT_LIMIT}. Hard cap: ${HARD_LIMIT}. Use a small limit when exploring a large file.`, - ), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const filePath = args.path as string; - const offset = typeof args.offset === "number" ? Math.max(1, Math.floor(args.offset)) : 1; - const requestedLimit = - typeof args.limit === "number" ? Math.max(1, Math.floor(args.limit)) : DEFAULT_LIMIT; - const limit = Math.min(requestedLimit, HARD_LIMIT); - - // Canonicalize all three so symlink-in-workdir escapes are detected: - // a workdir-relative path that resolves through symlinks to /etc must - // fail the containment check below. - const absolutePath = await canonicalize(workingDirectory, filePath); - const absoluteWorkDir = await canonicalize(workingDirectory); - const absoluteSpillRoot = await canonicalize(SPILL_ROOT); - const isUnderWorkdir = - absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`); - const isSpillFile = - absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`); - - if (!isUnderWorkdir && !isSpillFile) { - return `Error: Path "${filePath}" is outside the working directory.`; - } - - let raw: string; - try { - raw = await readFile(absolutePath, "utf8"); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return `Error: File "${filePath}" not found.`; - } - return `Error reading file: ${err instanceof Error ? err.message : String(err)}`; - } - - // A truly empty file (0 bytes) would otherwise slip through the - // line-counting below: `"".split("\n")` is `[""]` and there's no - // trailing newline, yielding a spurious `totalLines === 1`. - if (raw === "") { - return `(empty file: ${filePath})`; - } - - const allLines = raw.split("\n"); - // `split("\n")` produces an extra empty entry when the file ends with a - // newline. The total line count we report to the caller should match - // the human-visible line count (lines that have content or terminate - // with \n). - const trailingNewline = raw.endsWith("\n"); - const totalLines = trailingNewline ? allLines.length - 1 : allLines.length; - - if (totalLines === 0) { - return `(empty file: ${filePath})`; - } - - if (offset > totalLines) { - return `Error: offset ${offset} exceeds file length (${totalLines} lines).`; - } - - const startIdx = offset - 1; // 0-indexed - const endIdx = Math.min(startIdx + limit, totalLines); - const slice = allLines.slice(startIdx, endIdx); - - // Apply per-line truncation. We tag truncated lines with the line - // number and total chars so the AI knows how to call read_file_slice. - const rendered: string[] = []; - for (let i = 0; i < slice.length; i++) { - const lineNumber = startIdx + i + 1; - const line = slice[i] ?? ""; - if (line.length > MAX_LINE_CHARS) { - const visible = line.slice(0, MAX_LINE_CHARS); - rendered.push( - `${visible}...[line ${lineNumber} truncated, total ${line.length.toLocaleString()} chars; use read_file_slice with path="${filePath}" line=${lineNumber} to read more]`, - ); - } else { - rendered.push(line); - } - } - - const header = `[file: ${filePath} — lines ${offset}-${endIdx} of ${totalLines}]`; - return `${header}\n${rendered.join("\n")}`; - }, - }; -} diff --git a/packages/core/src/tools/read-tab.ts b/packages/core/src/tools/read-tab.ts deleted file mode 100644 index e80dbd0..0000000 --- a/packages/core/src/tools/read-tab.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { z } from "zod"; -import type { AgentStatus, ToolDefinition } from "../types/index.js"; -import type { TabResolution } from "./send-to-tab.js"; - -export interface ReadTabCallbacks { - /** Resolve a (possibly short) handle to one open tab. */ - resolveShortId(prefix: string): TabResolution; - /** - * Return the target tab's most recent COMPLETED assistant turn as plain - * text, plus its current status. `text` is null when the tab has no - * completed assistant turn yet. - */ - getLastResponse(tabId: string): { text: string | null; status: AgentStatus }; - /** Snapshot of currently-open tabs, for "available tabs" error hints. */ - listOpenHandles(): Array<{ handle: string; title: string }>; -} - -/** Render the "available tabs" hint shared by the none/ambiguous branches. */ -function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string { - if (handles.length === 0) return "No other tabs are currently open."; - const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`); - return ["Currently open tabs:", ...lines].join("\n"); -} - -export function createReadTabTool(callbacks: ReadTabCallbacks): ToolDefinition { - return { - name: "read_tab", - description: [ - "Read the most recent completed response from another tab (agent) by its short ID.", - "", - "Returns a SNAPSHOT — it does NOT block or wait for the target to finish.", - " - If the target is idle, you get its just-finished turn.", - " - If the target is still running, you get its PREVIOUS completed turn (if any);", - " call read_tab again later to get the newest one.", - "", - "Use this after send_to_tab to collect another agent's reply. IDs are git-style", - "prefixes: pass any length that uniquely identifies the target (min 4 chars).", - ].join("\n"), - parameters: z.object({ - tab_id: z - .string() - .describe( - "The short ID (handle) of the tab to read, as shown in the tab bar. Any unique-length prefix works (min 4 chars).", - ), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const rawId = (args.tab_id as string | undefined)?.trim() ?? ""; - - if (!rawId) { - return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`; - } - - const resolution = callbacks.resolveShortId(rawId); - - if (resolution.status === "none") { - return [ - `Error: no open tab matches the ID "${rawId}".`, - "", - renderOpenHandles(callbacks.listOpenHandles()), - ].join("\n"); - } - if (resolution.status === "ambiguous") { - const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n"); - return [ - `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`, - matches, - "", - "Add one or more characters to disambiguate.", - ].join("\n"); - } - - const target = resolution.tab; - const { text, status } = callbacks.getLastResponse(target.id); - - const runningNote = - status === "running" - ? " (this tab is still running; the response below is its previous completed turn — read again later for the newest)" - : ""; - - if (text === null) { - const reason = - status === "running" - ? "it is still working on its first turn" - : "it has no assistant responses yet"; - return `Tab ${target.handle} (${target.title}) has no completed response — ${reason}.`; - } - - return [ - `<tab_response tab="${target.handle}" status="${status}"${runningNote}>`, - text, - "</tab_response>", - ].join("\n"); - }, - }; -} diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts deleted file mode 100644 index ff6f4d1..0000000 --- a/packages/core/src/tools/registry.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Tool } from "ai"; -import { jsonSchema, tool } from "ai"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import type { ToolDefinition } from "../types/index.js"; - -/** - * Strip JSON Schema fields that Anthropic's API does not accept from a - * `zodToJsonSchema()` output. The Anthropic `/messages` API rejects (or - * silently ignores) tools whose `input_schema` contains `$schema`, - * `additionalProperties`, `default`, or `nullable` — when this happens - * Claude never sees the tool and the model "thinks forever" instead of - * calling it. - * - * The stripped fields are also harmless to remove for OpenAI-compatible - * endpoints, so we apply this unconditionally. - */ -function normalizeForAnthropic(schema: Record<string, unknown>): Record<string, unknown> { - delete schema.$schema; - delete schema.additionalProperties; - delete schema.default; - delete schema.nullable; - - const properties = schema.properties; - if (properties && typeof properties === "object") { - for (const key of Object.keys(properties as Record<string, unknown>)) { - const prop = (properties as Record<string, unknown>)[key]; - if (prop && typeof prop === "object") { - normalizeForAnthropic(prop as Record<string, unknown>); - } - } - } - - const items = schema.items; - if (items && typeof items === "object") { - normalizeForAnthropic(items as Record<string, unknown>); - } - - return schema; -} - -/** - * Convert an internal `ToolDefinition` (Zod-parameterised) to an AI SDK v6 - * `Tool` object. - * - * Critically, NO `execute` function is attached. The agent's manual tool - * loop (see agent.ts) handles execution itself — for permission prompts, - * shell-output streaming, and queued-message injection. Without `execute`, - * the SDK never auto-runs tools; it only surfaces `tool-call` events from - * `fullStream` that agent.ts collects and dispatches. - */ -function toAISDKTool(def: ToolDefinition): Tool { - const raw = zodToJsonSchema(def.parameters) as Record<string, unknown>; - const normalized = normalizeForAnthropic(raw); - return tool({ - description: def.description, - inputSchema: jsonSchema(normalized), - }); -} - -export function createToolRegistry(tools: ToolDefinition[]) { - const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t])); - - return { - getTools(): ToolDefinition[] { - return [...toolMap.values()]; - }, - - getTool(name: string): ToolDefinition | undefined { - return toolMap.get(name); - }, - - /** - * Returns AI SDK v6 `Tool` objects keyed by tool name, for passing - * directly to `streamText({ tools })`. - * - * Each tool has: - * - `description` — forwarded verbatim from the internal definition. - * - `inputSchema` — Zod schema converted to JSONSchema7 via - * `zod-to-json-schema`, then wrapped with the v6 - * `jsonSchema()` helper. - * - NO `execute` — intentional; see `toAISDKTool` above. - */ - getAISDKTools(): Record<string, Tool> { - const result: Record<string, Tool> = {}; - for (const [name, def] of toolMap) { - result[name] = toAISDKTool(def); - } - return result; - }, - }; -} diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts deleted file mode 100644 index 80c3715..0000000 --- a/packages/core/src/tools/retrieve.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition, ToolExecuteContext } from "../types/index.js"; - -export interface RetrieveCallbacks { - getResult( - agentId: string, - ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>; -} - -export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition { - return { - name: "retrieve", - description: [ - "Wait for a child agent or backgrounded shell command to finish and retrieve its result. This tool BLOCKS until completion.", - "", - "Pass the ID returned by summon (agent_id) or by an interrupted run_shell (job_id). Once it finishes, the output is returned.", - "If an error occurred, the error message is returned instead.", - "", - "Typical usage:", - ' 1. summon({ task: "...", tools: [...] }) -> get agent_id', - " 2. ... do other work or summon more agents ...", - ' 3. retrieve({ agent_id: "..." }) -> blocks until done, returns result', - "", - "Also used for backgrounded shell commands:", - " If run_shell is interrupted by a user message, it returns a job_id (run_shell_...).", - ' Use retrieve({ agent_id: "run_shell_..." }) to get the final output when ready.', - ].join("\n"), - parameters: z.object({ - agent_id: z.string().describe("The agent_id returned by a previous summon call."), - }), - execute: async ( - args: Record<string, unknown>, - context?: ToolExecuteContext, - ): Promise<string> => { - const agentId = args.agent_id as string; - const queueCallbacks = context?.queueCallbacks; - - try { - let outcome: { status: "done"; result: string } | { status: "error"; error: string }; - - if (queueCallbacks) { - const childPromise = callbacks.getResult(agentId); - const { promise: queuePromise, cancel: cancelQueueWait } = - queueCallbacks.waitForQueuedMessage(); - const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); - - const raceResult = await Promise.race([childPromise, queueSignal]); - - if (raceResult === "QUEUE_INTERRUPT") { - const queuedMsgs = queueCallbacks.dequeueMessages(); - const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); - return `The subagent (agent_id: ${agentId}) has not completed its task yet. You will need to call retrieve with this agent_id again later to get the result.\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`; - } - - // Child finished first — clean up the queue listener - cancelQueueWait(); - outcome = raceResult; - } else { - outcome = await callbacks.getResult(agentId); - } - - if (outcome.status === "done") { - return ["<agent_result>", outcome.result, "</agent_result>"].join("\n"); - } - return `Agent error: ${outcome.error}`; - } catch (err) { - return `Error retrieving result: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }; -} diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts deleted file mode 100644 index ec2db9c..0000000 --- a/packages/core/src/tools/run-shell.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { z } from "zod"; -import type { ToolDefinition, ToolExecuteContext } from "../types/index.js"; - -const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes - -export interface BackgroundShellJob { - command: string; - stdout: string; - stderr: string; - /** Resolves when the process exits */ - completion: Promise<{ stdout: string; stderr: string; exitCode: number; error?: string }>; -} - -/** Shared store for shell commands that were backgrounded due to user interrupt */ -export class BackgroundShellStore { - private jobs = new Map<string, BackgroundShellJob>(); - - register(job: BackgroundShellJob): string { - const id = `run_shell_${randomUUID()}`; - this.jobs.set(id, job); - // Auto-cleanup after completion + 10 minutes - job.completion.finally(() => { - setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000); - }); - return id; - } - - async getResult( - id: string, - ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> { - const job = this.jobs.get(id); - if (!job) { - return { status: "error", error: `No background shell job found with id '${id}'` }; - } - const result = await job.completion; - return { status: "done", result: JSON.stringify(result) }; - } - - has(id: string): boolean { - return this.jobs.has(id); - } -} - -export function createRunShellTool( - workingDirectory: string, - shellStore?: BackgroundShellStore, -): ToolDefinition { - return { - name: "run_shell", - description: - "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. If the user interrupts while a command is running, the command continues in the background and you receive a job ID. Use the retrieve tool with that ID to get the result later.", - parameters: z.object({ - command: z.string().describe("The shell command to execute"), - timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"), - background: z - .boolean() - .optional() - .describe( - "If true, the command starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the result later.", - ), - }), - execute: async ( - args: Record<string, unknown>, - context?: ToolExecuteContext, - ): Promise<string> => { - const command = args.command as string; - const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT; - const background = (args.background as boolean | undefined) ?? false; - - const [shell, shellArgs] = getShell(); - const child = spawn(shell, [...shellArgs, command], { - cwd: workingDirectory, - env: process.env, - timeout, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - const completionPromise = new Promise<{ - stdout: string; - stderr: string; - exitCode: number; - error?: string; - }>((resolve) => { - child.stdout?.on("data", (data: Buffer) => { - const chunk = data.toString(); - stdout += chunk; - context?.onOutput?.(chunk, "stdout"); - }); - child.stderr?.on("data", (data: Buffer) => { - const chunk = data.toString(); - stderr += chunk; - context?.onOutput?.(chunk, "stderr"); - }); - - child.on("close", (exitCode) => { - resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); - }); - - child.on("error", (err) => { - resolve({ stdout, stderr, exitCode: 1, error: err.message }); - }); - }); - - // If background mode requested, register immediately and return job ID - if (background && shellStore) { - const jobId = shellStore.register({ - command, - stdout, - stderr, - completion: completionPromise, - }); - return [ - `Command started in background.`, - `job_id: ${jobId}`, - `command: ${command}`, - ``, - `Use the retrieve tool with this job_id to get the result when ready.`, - ].join("\n"); - } - - const queueCallbacks = context?.queueCallbacks; - - if (queueCallbacks && shellStore) { - const { promise: queuePromise, cancel: cancelQueueWait } = - queueCallbacks.waitForQueuedMessage(); - const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); - - const raceResult = await Promise.race([completionPromise, queueSignal]); - - if (raceResult === "QUEUE_INTERRUPT") { - // Background the still-running process - const jobId = shellStore.register({ - command, - stdout, - stderr, - completion: completionPromise, - }); - - const queuedMsgs = queueCallbacks.dequeueMessages(); - const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); - - return [ - `Command backgrounded — still running.`, - `job_id: ${jobId}`, - `command: ${command}`, - `stdout so far: ${stdout.slice(-500) || "(none)"}`, - `stderr so far: ${stderr.slice(-500) || "(none)"}`, - ``, - `Use the retrieve tool with this job_id to get the final result when ready.`, - ``, - `[USER INTERRUPT]`, - `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`, - ``, - userMessages, - ].join("\n"); - } - - // Command finished before interrupt — clean up queue listener - cancelQueueWait(); - return JSON.stringify(raceResult); - } - - const result = await completionPromise; - return JSON.stringify(result); - }, - }; -} - -function getShell(): [string, string[]] { - return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]]; -} diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts deleted file mode 100644 index 4350f6a..0000000 --- a/packages/core/src/tools/search-code.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { spawn } from "node:child_process"; -import { stat } from "node:fs/promises"; -import { relative, sep } from "node:path"; -import { z } from "zod"; -import type { ToolDefinition } from "../types/index.js"; -import { canonicalize } from "./path-utils.js"; - -// Resolve the `cs` binary: an explicit override wins, otherwise rely on PATH. -// The deployed images build a patched, statically-linked `cs` into -// /usr/local/bin/cs (see Dockerfile); local dev can point DISPATCH_CS_BIN at a -// custom build. Read at call time so the environment can change at runtime -// (and so tests can point it at a stub or temp build). -function resolveCsBin(): string { - return process.env.DISPATCH_CS_BIN || "cs"; -} - -const DEFAULT_RESULT_LIMIT = 20; -const MAX_RESULT_LIMIT = 100; -const MAX_CONTEXT = 20; -const MIN_SNIPPET_LENGTH = 50; -const MAX_SNIPPET_LENGTH = 2000; -const TIMEOUT_MS = 30_000; -// Hard cap on any single rendered snippet line. Mirrors read-file.ts so a -// matched minified/generated line (e.g. a 2 MB bundle line) can't blow up the -// payload. The universal truncator bounds total output; this bounds per-line. -const MAX_LINE_CHARS = 500; - -/** Maps the `only` enum to the corresponding cs flag. */ -const ONLY_FLAGS: Record<string, string> = { - code: "--only-code", - comments: "--only-comments", - strings: "--only-strings", - declarations: "--only-declarations", - usages: "--only-usages", -}; - -/** One line within a cs JSON match result. */ -interface CsLine { - line_number: number; - content: string; - match_positions?: Array<[number, number]>; -} - -/** A single file result in cs `-f json` output. */ -interface CsResult { - filename: string; - location: string; - score: number; - /** Present in "lines"/"grep" snippet modes. */ - lines?: CsLine[]; - /** - * Present instead of `lines` in cs's "snippet" mode (the default "auto" - * mode selects it for prose). We force a lines-based mode (see buildFlags), - * but this is kept as a defensive fallback so a content-shape result is - * still rendered rather than shown as a bare header. - */ - content?: string; - matchlocations?: Array<[number, number]>; - language?: string; - total_lines?: number; -} - -export function createSearchCodeTool(workingDirectory: string): ToolDefinition { - return { - name: "search_code", - description: - "Search the codebase by query using `cs` (code spelunker) — a fast, relevance-ranked code search engine. " + - "Prefer this over grep/find for EXPLORATORY 'where is X / how does Y work' searches: it ranks the most " + - "relevant files first and returns matching snippets with line numbers, so you spend fewer turns and tokens. " + - "It respects .gitignore and skips hidden/binary files. " + - 'Query syntax: space-separated terms are AND\'d; supports OR, NOT, "exact phrases", fuzzy~1, /regex/, and ' + - "metadata filters like lang:Go, file:test, path:src. " + - "It is a ranked text search, NOT a semantic/LSP index: it won't resolve types or imports. For an EXHAUSTIVE " + - "list of every exact match (e.g. before a rename), use run_shell with ripgrep (rg) instead.", - parameters: z.object({ - query: z - .string() - .describe( - 'The search query. Terms are AND\'d by default. Supports OR, NOT, "phrases", fuzzy~1, /regex/, and filters like lang:Go, file:test, path:src.', - ), - path: z - .string() - .optional() - .describe( - "Subdirectory to scope the search to, relative to the working directory. Defaults to the whole working directory.", - ), - case_sensitive: z - .boolean() - .optional() - .describe("Make the search case-sensitive. Default: false (case-insensitive)."), - include_ext: z - .string() - .optional() - .describe( - 'Comma-separated list of file extensions to limit the search to (case-sensitive), e.g. "go,ts,lua".', - ), - exclude_pattern: z - .string() - .optional() - .describe( - 'Comma-separated list of path patterns to exclude (case-sensitive), e.g. "vendor,_test.go".', - ), - context: z - .number() - .int() - .min(0) - .optional() - .describe( - `Lines of context to show before and after each matching line (0-${MAX_CONTEXT}). When set, switches to a grep-style per-line window.`, - ), - result_limit: z - .number() - .int() - .min(1) - .optional() - .describe( - `Maximum number of file results to return. Default: ${DEFAULT_RESULT_LIMIT}, max: ${MAX_RESULT_LIMIT}.`, - ), - snippet_length: z - .number() - .int() - .min(MIN_SNIPPET_LENGTH) - .optional() - .describe( - `Snippet size in bytes for prose/text files (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Has little effect on code files, which use a fixed line window — use 'context' to widen code snippets.`, - ), - only: z - .enum(["code", "comments", "strings", "declarations", "usages"]) - .optional() - .describe( - "Restrict matches structurally: code, comments, strings, declarations (definitions like func/class/type), " + - "or usages (call sites). Best-effort and language-dependent — strong for Go/TypeScript/Python/Lua/Luau, " + - "unavailable for unsupported languages (which fall back to plain text ranking).", - ), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const query = typeof args.query === "string" ? args.query : ""; - if (query.trim() === "") { - return "Error: query is required (a non-empty string)."; - } - - // Resolve and contain the optional search path within the workdir. - // Canonicalize so a symlink-in-workdir pointing outside is detected, - // matching the containment semantics of list_files / read_file. - const relPath = asString(args.path) ?? "."; - const absoluteWorkDir = await canonicalize(workingDirectory); - const searchDir = await canonicalize(workingDirectory, relPath); - if (searchDir !== absoluteWorkDir && !searchDir.startsWith(`${absoluteWorkDir}/`)) { - return `Error: Path "${relPath}" is outside the working directory.`; - } - - // cs's --dir expects a directory; pointing it at a file silently - // returns no matches. Catch that and give an actionable hint instead - // of a misleading "No matches found". - if (relPath !== ".") { - try { - const st = await stat(searchDir); - if (!st.isDirectory()) { - return `Error: Path "${relPath}" is a file, not a directory. The 'path' parameter scopes the search to a directory; use read_file to read a single file.`; - } - } catch { - return `Error: Path "${relPath}" does not exist in the working directory.`; - } - } - - const flags = buildFlags(args, searchDir); - // `--` terminates cs flag parsing so a query that begins with "-" - // (e.g. "-hello" or "--foo") is treated as the positional search term - // rather than parsed as a (possibly invalid) cs flag. - const spawnArgs = [...flags, "--", query]; - - let stdout = ""; - let stderr = ""; - const result = await new Promise<{ - code: number | null; - signal: NodeJS.Signals | null; - error?: string; - errorCode?: string; - }>((resolve) => { - const child = spawn(resolveCsBin(), spawnArgs, { - cwd: workingDirectory, - env: process.env, - timeout: TIMEOUT_MS, - stdio: ["ignore", "pipe", "pipe"], - }); - child.stdout?.on("data", (d: Buffer) => { - stdout += d.toString(); - }); - child.stderr?.on("data", (d: Buffer) => { - stderr += d.toString(); - }); - child.on("close", (code, signal) => resolve({ code, signal })); - child.on("error", (err) => - resolve({ - code: null, - signal: null, - error: err.message, - errorCode: (err as NodeJS.ErrnoException).code, - }), - ); - }); - - if (result.error) { - // The binary is missing or not executable — give an actionable hint. - if (result.errorCode === "ENOENT" || result.error.includes("ENOENT")) { - return missingBinaryError(); - } - return `Error: failed to run cs: ${result.error}`; - } - - // A signal kill (e.g. SIGTERM from the spawn timeout) or a non-zero - // exit means cs failed — surface it (with stderr) instead of silently - // reporting "No matches found". cs exits 0 even when there are no - // matches, so a clean exit always falls through to the parsing below. - if (result.signal) { - const detail = stderr.trim() ? `\n${stderr.trim()}` : ""; - if (result.signal === "SIGTERM") { - return `Error: cs search timed out after ${TIMEOUT_MS / 1000}s. Try a narrower query or a smaller path.${detail}`; - } - return `Error: cs was terminated by signal ${result.signal}.${detail}`; - } - if (result.code !== 0) { - const detail = stderr.trim() ? `\n${stderr.trim()}` : ""; - return `Error: cs exited with code ${result.code}.${detail}`; - } - - // cs prints `null` (and exit 0) when there are no matches. - const trimmed = stdout.trim(); - if (trimmed === "" || trimmed === "null") { - return "No matches found."; - } - - let parsed: CsResult[]; - try { - parsed = JSON.parse(trimmed) as CsResult[]; - } catch { - // Couldn't parse — surface what cs produced so the caller isn't blind. - const detail = stderr.trim() ? `\nstderr: ${stderr.trim()}` : ""; - return `Error: could not parse cs output as JSON.${detail}\n\nRaw output:\n${trimmed.slice(0, 2000)}`; - } - - if (!Array.isArray(parsed) || parsed.length === 0) { - return "No matches found."; - } - - return formatResults(parsed, absoluteWorkDir); - }, - }; -} - -/** Build the cs CLI flags (everything except the trailing query). */ -function buildFlags(args: Record<string, unknown>, searchDir: string): string[] { - const flags: string[] = ["-f", "json", "--dir", searchDir]; - - if (args.case_sensitive === true) flags.push("-c"); - - const includeExt = asString(args.include_ext); - if (includeExt) flags.push("-i", includeExt); - - const excludePattern = asString(args.exclude_pattern); - if (excludePattern) flags.push("-x", excludePattern); - - // Snippet mode selection. cs's default ("auto") emits a `lines[]` array for - // code but a single `content` string for prose (.md/.html/…), which our - // renderer can't show — so prose results would come back as bare headers. - // It also ignores -C/--context entirely in auto/lines mode. - // - // - No `context` given → force "lines": every file type (code AND prose) - // returns a `lines[]` window, so prose snippets render too. - // - `context` given → use "grep": the only mode where -C actually widens - // the window; it likewise returns `lines[]` for all file types. - if (typeof args.context === "number") { - const context = clamp(Math.floor(args.context), 0, MAX_CONTEXT); - flags.push("--snippet-mode", "grep", "-C", String(context)); - } else { - flags.push("--snippet-mode", "lines"); - } - - const requestedLimit = - typeof args.result_limit === "number" - ? clamp(Math.floor(args.result_limit), 1, MAX_RESULT_LIMIT) - : DEFAULT_RESULT_LIMIT; - flags.push("--result-limit", String(requestedLimit)); - - if (typeof args.snippet_length === "number") { - const snippet = clamp(Math.floor(args.snippet_length), MIN_SNIPPET_LENGTH, MAX_SNIPPET_LENGTH); - flags.push("-n", String(snippet)); - } - - const only = asString(args.only); - if (only && ONLY_FLAGS[only]) flags.push(ONLY_FLAGS[only]); - - return flags; -} - -/** Render cs JSON results into compact, readable per-file blocks. */ -function formatResults(results: CsResult[], absoluteWorkDir: string): string { - const blocks: string[] = []; - // Match the workdir only at a path boundary so a sibling dir that merely - // shares the prefix (e.g. workdir "/app" vs "/app-secrets") isn't treated - // as nested and rendered as a "../app-secrets/..." relative path. - const workdirPrefix = absoluteWorkDir.endsWith(sep) ? absoluteWorkDir : absoluteWorkDir + sep; - for (const r of results) { - // Present paths relative to the workdir so output is portable and compact. - const insideWorkdir = r.location === absoluteWorkDir || r.location.startsWith(workdirPrefix); - const rel = insideWorkdir ? relative(absoluteWorkDir, r.location) || r.filename : r.location; - const lang = r.language ? ` [${r.language}]` : ""; - const score = typeof r.score === "number" ? ` (score ${r.score.toFixed(2)})` : ""; - const header = `${rel}${lang}${score}`; - - let body: string[]; - if (r.lines && r.lines.length > 0) { - body = r.lines.map((l) => { - const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " "; - return ` ${marker} ${l.line_number}: ${truncateLine(l.content)}`; - }); - } else if (r.content && r.content.trim() !== "") { - // Fallback for cs's "snippet"-mode shape (no per-line numbers): show - // the snippet text itself so the result isn't a bare header. - body = r.content.split("\n").map((line) => ` ${truncateLine(line)}`); - } else { - body = [" (match in file; no snippet available)"]; - } - - blocks.push([header, ...body].join("\n")); - } - - const count = results.length; - const heading = `Found matches in ${count} file${count === 1 ? "" : "s"} (ranked by relevance):`; - return [heading, "", blocks.join("\n\n")].join("\n"); -} - -function clamp(n: number, min: number, max: number): number { - return Math.min(max, Math.max(min, n)); -} - -/** Cap an individual snippet line so a minified/generated line can't bloat output. */ -function truncateLine(line: string): string { - if (line.length <= MAX_LINE_CHARS) return line; - return `${line.slice(0, MAX_LINE_CHARS)}… [line truncated, ${line.length.toLocaleString()} chars]`; -} - -/** - * Coerce a tool argument to a trimmed string, or undefined. Guards against a - * model hallucinating a non-string (e.g. an array `["ts","go"]`) for a - * string-typed param: returning undefined makes the flag a no-op instead of - * throwing `x.trim is not a function` and crashing the tool call. - */ -function asString(v: unknown): string | undefined { - if (typeof v !== "string") return undefined; - const t = v.trim(); - return t === "" ? undefined : t; -} - -function missingBinaryError(): string { - return [ - "Error: search_code requires the 'cs' (code spelunker) binary, which was not found.", - "Install it with: go install github.com/boyter/cs/[email protected]", - "or set the DISPATCH_CS_BIN environment variable to the path of a cs binary.", - "(In the official Docker images cs is bundled at /usr/local/bin/cs.)", - ].join("\n"); -} diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts deleted file mode 100644 index eae6bfa..0000000 --- a/packages/core/src/tools/send-to-tab.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "../types/index.js"; - -/** - * A tab reference surfaced to the `send_to_tab` / `read_tab` tools. The tools - * are intentionally decoupled from the DB `TabRow` shape — the AgentManager - * maps `resolveTabPrefix(...)` results down to this minimal projection so the - * tools (and their unit tests) never depend on the persistence layer. - */ -export interface ResolvedTabRef { - /** The tab's canonical full UUID. */ - id: string; - /** The tab's display title (for disambiguation hints). */ - title: string; - /** The tab's current short handle (shortest unique prefix). */ - handle: string; -} - -/** - * Outcome of resolving a short tab handle. Mirrors core's - * `ResolveTabPrefixResult` but over the minimal `ResolvedTabRef` projection. - */ -export type TabResolution = - | { status: "ok"; tab: ResolvedTabRef } - | { status: "none" } - | { status: "ambiguous"; matches: ResolvedTabRef[] }; - -export interface SendToTabCallbacks { - /** Resolve a (possibly short) handle to one open tab. */ - resolveShortId(prefix: string): TabResolution; - /** - * Deliver `message` to `tabId`. If the target is mid-turn the message is - * queued (same path as a user message); if idle/errored it wakes the tab - * and starts a new turn. Returns quickly — does NOT block on the turn. - */ - deliver( - tabId: string, - message: string, - ): - | Promise<{ status: "queued" | "started" | "suppressed" }> - | { status: "queued" | "started" | "suppressed" }; - /** Snapshot of currently-open tabs, for "available tabs" error hints. */ - listOpenHandles(): Array<{ handle: string; title: string }>; - /** The calling tab's own id + handle — used to block self-sends and to - * stamp provenance onto the delivered message. */ - self: { id: string; handle: string }; - /** - * Whether THIS calling tab also has the `read_tab` tool granted. The - * tab-messaging permissions are split, so a tab can hold `send_to_tab` - * without `read_tab`. When false, the tool must NOT tell the agent to use - * `read_tab` (it doesn't have it) — replies only arrive on their own. - */ - canReadTab: boolean; -} - -/** Render the "available tabs" hint shared by the none/ambiguous branches. */ -function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string { - if (handles.length === 0) return "No other tabs are currently open."; - const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`); - return ["Currently open tabs:", ...lines].join("\n"); -} - -export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition { - // The `read_tab` follow-up hint is only truthful when this tab actually - // holds the `read_tab` tool (the permissions are split). When it doesn't, - // the only honest guidance is that a reply will wake it as a new message — never tell - // the agent to call a tool it wasn't granted. - const waitLine = callbacks.canReadTab - ? "money. If the target replies it will WAKE you with a new message in a later turn; you" - : "money. If the target replies it will WAKE you with a new message in a later turn."; - const readTabLine = callbacks.canReadTab - ? ["can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other"] - : []; - const keepGoingLine = callbacks.canReadTab - ? "work to do, keep going; if you are ONLY waiting for the reply, end your turn now." - : "If you have other work to do, keep going; if you are ONLY waiting for the reply, end your turn now."; - return { - name: "send_to_tab", - description: [ - "Send a message to another tab (agent) by its short ID — the handle shown in the tab bar.", - "", - "Behaviour mirrors a user sending a message:", - " - If the target tab is mid-turn (busy), your message is QUEUED and picked up next.", - " - If the target tab is idle, your message WAKES it and starts a new turn.", - "", - "This is fire-and-forget: it returns immediately and does NOT wait for a reply.", - "Do NOT sleep, poll, or run shell commands to wait for a reply — that wastes turns and", - waitLine, - ...readTabLine, - keepGoingLine, - "", - "Your tab ID is auto-added to the top of the message so the recipient knows who to reply", - "to. The recipient must use this same 'send_to_tab' tool (addressed to your ID) to answer;", - "a plain text response reaches only their own user, not you.", - "IDs are git-style prefixes: pass any length that uniquely identifies the target (min 4 chars).", - "If the ID is ambiguous you'll be asked to add a character.", - ].join("\n"), - parameters: z.object({ - tab_id: z - .string() - .describe( - "The short ID (handle) of the target tab, as shown in the tab bar. Any unique-length prefix of the tab's id works (min 4 chars).", - ), - message: z - .string() - .describe("The message to deliver to the target tab, exactly as a user would type it."), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const rawId = (args.tab_id as string | undefined)?.trim() ?? ""; - const message = (args.message as string | undefined) ?? ""; - - if (!rawId) { - return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`; - } - if (!message.trim()) { - return "Error: message must not be empty."; - } - - const resolution = callbacks.resolveShortId(rawId); - - if (resolution.status === "none") { - return [ - `Error: no open tab matches the ID "${rawId}".`, - "", - renderOpenHandles(callbacks.listOpenHandles()), - ].join("\n"); - } - if (resolution.status === "ambiguous") { - const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n"); - return [ - `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`, - matches, - "", - "Add one or more characters to disambiguate.", - ].join("\n"); - } - - const target = resolution.tab; - - if (target.id === callbacks.self.id) { - return "Error: cannot send a message to your own tab."; - } - - // Stamp provenance so the recipient (and the watching user) can see - // which tab the message came from and how to reply. The header makes - // clear this is a PEER AGENT, not the recipient's own user, and the - // footer states the reply contract: a reply (only if warranted) must - // go back through `send_to_tab`, since a plain text answer reaches - // only the recipient's own user — not this sender. - const delivered = [ - `[message from tab ${callbacks.self.handle} — this is another agent, NOT your user]`, - "", - message, - "", - `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". ONLY reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`, - ].join("\n"); - - try { - const result = await callbacks.deliver(target.id, delivered); - if (result.status === "suppressed") { - // The target hit its automatic agent-to-agent wake limit. The - // message was preserved (queued) but did NOT start a turn — a - // human must step in. Tell the sender plainly so it stops - // hammering the target and creating a runaway loop. - return [ - `Message HELD for tab ${target.handle} (${target.title}) — it was NOT delivered as a wake.`, - `That tab has reached its automatic agent-to-agent message limit, so it will not`, - `auto-respond again until a human sends it a message. Do not keep resending:`, - `your message is already queued and will be seen when a human resumes that tab.`, - ].join("\n"); - } - const verb = - result.status === "queued" - ? "queued (target is busy; it will be picked up next turn)" - : "delivered (target was idle; a new turn has started)"; - const tail = callbacks.canReadTab - ? [ - "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it", - `will WAKE you with a new message later; you can also call read_tab with "${target.handle}"`, - "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY", - "waiting for this reply, end your turn now.", - ] - : [ - "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it", - "will WAKE you with a new message later. Keep working if you have other tasks; if", - "you are ONLY waiting for this reply, end your turn now.", - ]; - return [ - `Message ${verb}. Target tab: ${target.handle} (${target.title}).`, - "", - ...tail, - ].join("\n"); - } catch (err) { - return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }; -} diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts deleted file mode 100644 index b70108b..0000000 --- a/packages/core/src/tools/shell-analyze.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; -import * as BashArity from "./bash-arity.js"; - -// Commands that touch files — triggers external_directory check. -// Includes any command that takes file paths as arguments and could leak -// information about external directories. -// -// Known gaps (not currently checked): -// - Redirections: `echo x > /etc/file` — the redirect target is not inspected -// - `cd` state changes: we don't track cwd mutations across pipeline stages -// - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely -const FILE_COMMANDS = new Set([ - "rm", - "cp", - "mv", - "mkdir", - "touch", - "chmod", - "chown", - "cat", - "ls", - "find", - "grep", - "head", - "tail", - "less", - "more", - "wc", - "diff", - "file", - "stat", - "du", - "df", -]); - -// Lazy-initialized parser -let parserPromise: Promise<Parsers> | null = null; - -interface Parsers { - bash: import("web-tree-sitter").Parser; -} - -async function getParser(): Promise<Parsers> { - if (parserPromise) return parserPromise; - parserPromise = initParser(); - return parserPromise; -} - -async function initParser(): Promise<Parsers> { - const { Parser, Language } = await import("web-tree-sitter"); - - // Load the main WASM binary from node_modules - const require = createRequire(import.meta.url); - const webTreeSitterPath = require.resolve("web-tree-sitter/web-tree-sitter.wasm"); - const wasmBinary = await readFile(webTreeSitterPath); - await Parser.init({ wasmBinary }); - - const bashWasmPath = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"); - const bashLang = await Language.load(bashWasmPath); - - const bash = new Parser(); - bash.setLanguage(bashLang); - - return { bash }; -} - -// Analyze a shell command and return permission patterns -export async function analyzeCommand( - command: string, - workingDirectory: string, -): Promise<{ dirs: string[]; patterns: string[]; always: string[] }> { - try { - const parsers = await getParser(); - const tree = parsers.bash.parse(command); - if (!tree) return { dirs: [], patterns: [command], always: [] }; - - return collect(tree.rootNode, command, workingDirectory); - } catch { - // Parse failure — return basic patterns - return { dirs: [], patterns: [command], always: [] }; - } -} - -function collect( - node: import("web-tree-sitter").Node, - _source: string, - wd: string, -): { dirs: string[]; patterns: string[]; always: string[] } { - const dirs: string[] = []; - const patterns: string[] = []; - const always: string[] = []; - - // Walk all command nodes - const commands = node.descendantsOfType("command"); - - for (const cmd of commands) { - const parts = extractParts(cmd); - const name = parts[0]?.toLowerCase(); - if (!name) continue; - - // Get the command source text - const cmdText = cmd.text; - patterns.push(cmdText); - - // Normalize to always pattern - always.push(`${BashArity.prefix(parts).join(" ")} *`); - - // Check if this is a file-touching command - if (FILE_COMMANDS.has(name)) { - // Extract path arguments (skip flags starting with -) - const pathArgs = parts.slice(1).filter((a) => !a.startsWith("-")); - for (const arg of pathArgs) { - const resolved = resolvePath(arg, wd); - if (resolved && !isInsideWorkspace(resolved, wd)) { - const parent = dirname(resolved); - dirs.push(parent); - } - } - } - } - - return { - dirs: [...new Set(dirs)], - patterns: [...new Set(patterns)], - always: [...new Set(always)], - }; -} - -// Helper to extract command parts from a command AST node -function extractParts(cmd: import("web-tree-sitter").Node): string[] { - const parts: string[] = []; - for (const child of cmd.children) { - if ( - child.type === "command_name" || - child.type === "word" || - child.type === "string" || - child.type === "raw_string" - ) { - const text = child.text.replace(/^['"]|['"]$/g, ""); - if (text) parts.push(text); - } - } - return parts; -} - -function resolvePath(arg: string, wd: string): string | null { - try { - if (isAbsolute(arg)) return arg; - return resolve(wd, arg); - } catch { - return null; - } -} - -function isInsideWorkspace(filePath: string, wd: string): boolean { - const normalizedWd = resolve(wd); - const rel = relative(normalizedWd, filePath); - // rel === "" means filePath IS the workspace root — that is inside. - // If relative path starts with "../" or is ".." exactly, or is an absolute path - // (on Windows when drives differ), the file is outside the workspace. - const isOutside = - rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel); - return !isOutside; -} diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts deleted file mode 100644 index 2a076e6..0000000 --- a/packages/core/src/tools/summon.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { z } from "zod"; -import type { AgentDefinition, ToolDefinition } from "../types/index.js"; - -export interface SummonCallbacks { - spawn(options: { - task: string; - tools: string[]; - workingDirectory?: string; - /** - * Optional slug of an `AgentDefinition` (loaded from - * `~/.config/dispatch/agents/` or `<projectDir>/.dispatch/agents/`) - * to use as the basis for the spawned child. When provided, - * the definition's tools, models, and cwd override the - * `tools` and `workingDirectory` parameters passed alongside. - */ - agentSlug?: string; - /** - * When true, spawn the agent as an independent top-level "user - * agent" tab (no parent, persistent, fire-and-forget) instead of - * a subagent child tab. Only honoured when the spawning agent has - * the `perm_user_agent` permission. - */ - topLevel?: boolean; - }): Promise<string>; - getResult( - agentId: string, - ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>; -} - -/** - * Summary of an agent definition surfaced to the calling LLM in the - * summon tool's description. The shape is intentionally minimal — full - * TOML inspection is done by reading the definition file directly, - * which all agents are allowed to do by default. - */ -export interface AvailableAgent { - slug: string; - name: string; - description: string; - /** Filesystem path of the TOML the agent can read for full details. */ - path: string; -} - -/** - * Render a labelled list of agents. Returns an empty array when there - * are no agents so callers can omit the group entirely. - */ -function renderAgentGroup(label: string, agents: AvailableAgent[]): string[] { - if (agents.length === 0) return []; - const lines: string[] = [label]; - for (const a of agents) { - const desc = a.description ? ` — ${a.description}` : ""; - lines.push(` - ${a.slug}: ${a.name}${desc}`); - } - return lines; -} - -/** - * Build the prose paragraph that lists available agent definitions plus - * the disk locations where they live, injected into the summon tool's - * description. - * - * `subagentEnabled` and `userAgentEnabled` independently control which - * groups are shown — they mirror the `perm_summon` and `perm_user_agent` - * permissions respectively: - * - subagents only → generic "Available agents" heading; - * - user agents only → a single user-agent group (top_level is implied); - * - both → two labelled groups so the LLM understands which slugs - * require `top_level=true`. - * - * Returns a compact "no agents defined" notice when nothing is visible. - */ -function buildAgentsCatalog( - subagents: AvailableAgent[], - userAgents: AvailableAgent[], - agentDirs: string[], - userAgentEnabled: boolean, - subagentEnabled: boolean, -): string { - const lines: string[] = []; - lines.push(""); - lines.push("Agent definitions live on disk and can be inspected with read_file/list_files:"); - for (const d of agentDirs) { - lines.push(` - ${d}`); - } - - const visibleSubagents = subagentEnabled ? subagents : []; - const visibleUserAgents = userAgentEnabled ? userAgents : []; - if (visibleSubagents.length === 0 && visibleUserAgents.length === 0) { - lines.push(""); - lines.push("No agent definitions are currently defined."); - return lines.join("\n"); - } - - lines.push(""); - lines.push("To summon a specific agent, pass its slug as the 'agent' parameter."); - lines.push("When 'agent' is set, the child inherits that definition's tools, models,"); - lines.push("and working directory; the 'tools' parameter is ignored."); - lines.push(""); - - // User-agent-only mode: list just the user agents. top_level is implied - // (it is the only thing this grant can spawn), so the heading omits it. - if (!subagentEnabled && userAgentEnabled) { - lines.push( - ...renderAgentGroup( - "User agents (spawned as independent top-level tabs):", - visibleUserAgents, - ), - ); - return lines.join("\n"); - } - - // Subagent-only mode: single generic heading. - if (!userAgentEnabled) { - lines.push(...renderAgentGroup("Available agents:", visibleSubagents)); - return lines.join("\n"); - } - - // Both enabled: two labelled groups. - const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", visibleSubagents); - const userAgentLines = renderAgentGroup( - "User agents (spawned as independent top-level tabs, requires top_level=true):", - visibleUserAgents, - ); - if (subagentLines.length > 0) { - lines.push(...subagentLines); - } - if (userAgentLines.length > 0) { - if (subagentLines.length > 0) lines.push(""); - lines.push(...userAgentLines); - } - return lines.join("\n"); -} - -/** - * Factory for the `summon` tool. Accepts a snapshot of agent definitions - * available at the time the tool is registered so the LLM's view of - * which agents exist matches what `spawnChildAgent` can actually load. - * - * `agentDirs` is the list of filesystem paths the catalog references in - * its description; this is information-only — the runtime resolves - * slugs through `loadAgent` independently. - * - * `userAgentEnabled` mirrors the `perm_user_agent` permission and - * `subagentEnabled` mirrors the `perm_summon` permission. They are - * independent: the tool is registered whenever at least one is granted. - * - subagentEnabled only → spawn ordinary subagents (no `top_level`); - * - userAgentEnabled only → spawn ONLY top-level user agents - * (`top_level` is forced on, the `background` knob is dropped, and - * the catalog lists user agents only); - * - both → full behavior (subagents plus `top_level` user agents). - */ -export function createSummonTool( - _defaultWorkingDirectory: string, - callbacks: SummonCallbacks, - availableSubagents: AvailableAgent[] = [], - availableUserAgents: AvailableAgent[] = [], - agentDirs: string[] = [], - userAgentEnabled = false, - subagentEnabled = true, -): ToolDefinition { - // When only the user-agent permission is granted the tool spawns user - // agents exclusively: `top_level` is implied (and forced), subagent - // mechanics (background, retrieve, parallel work) are irrelevant. - const userAgentOnly = userAgentEnabled && !subagentEnabled; - - const catalog = buildAgentsCatalog( - availableSubagents, - availableUserAgents, - agentDirs, - userAgentEnabled, - subagentEnabled, - ); - const subagentSlugs = availableSubagents.map((a) => a.slug); - const userAgentSlugs = availableUserAgents.map((a) => a.slug); - const allSlugs = userAgentOnly - ? userAgentSlugs - : userAgentEnabled - ? [...subagentSlugs, ...userAgentSlugs] - : subagentSlugs; - - const toolNamesList = [ - "The 'tools' parameter controls what the child can do. Available tool names:", - " - read_file: Read file contents", - " - read_file_slice: Read a character-range slice of a single line", - " - list_files: List files and directories", - " - write_file: Write/edit files", - " - run_shell: Execute shell commands", - " - search_code: Search the codebase with the cs ranked code-search engine", - " - todo: Track work items", - " - summon: Spawn its own child agents (enables nesting)", - " - retrieve: Collect results from its children (required if summon is given)", - " - web_search: Search the web", - " - youtube_transcribe: Fetch YouTube video transcripts", - " - send_to_tab: Send a message to another tab/agent by its ID", - " - read_tab: Read another tab/agent's latest response by its ID", - ]; - - const description = userAgentOnly - ? [ - "Spawn an independent top-level user agent to work on a task.", - "", - "User agents are first-class top-level tabs with no parent. They are", - "fire-and-forget: you get an agent_id back but cannot retrieve their result.", - "The user agent runs in its own tab visible to the user.", - "", - ...toolNamesList, - "", - "The 'agent' parameter is required — every spawned agent must use a definition.", - "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).", - catalog, - ].join("\n") - : [ - "Spawn a new child agent to work on a task independently.", - "", - "By default, blocks until the child agent finishes and returns the result directly.", - "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.", - "", - "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.", - "", - "Pattern for parallel work:", - " 1. Call summon multiple times with background=true to start several agents", - " 2. Do your own work or wait", - " 3. Call retrieve for each agent_id to collect results", - ...(userAgentEnabled - ? [ - "", - "Set top_level=true to spawn an independent user agent — a first-class", - "top-level tab with no parent. User agents are fire-and-forget: you get", - "an agent_id back but cannot retrieve their result. top_level requires an", - "'agent' definition listed under 'User agents' below.", - ] - : []), - "", - ...toolNamesList, - "", - "The 'agent' parameter is required — every spawned agent must use a definition.", - "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).", - catalog, - ].join("\n"); - - const parametersShape = { - task: z - .string() - .describe( - "Detailed instructions for the child agent. Be specific about what it should do and what it should return.", - ), - agent: z - .string() - .describe( - [ - "Slug of an agent definition to use as the basis for the child agent.", - "Required. The child inherits the definition's tools, models, and", - "working directory; the 'tools' parameter only narrows them further.", - "Inspect the agent directories listed above to discover which slugs", - "are available and what each one does.", - allSlugs.length > 0 ? `Available slugs: ${allSlugs.join(", ")}.` : "", - ] - .filter(Boolean) - .join(" "), - ), - // `top_level` is only an explicit choice when BOTH subagents and user - // agents are available. In user-agent-only mode it is implied (forced - // on), so the knob is omitted entirely. - ...(userAgentEnabled && !userAgentOnly - ? { - top_level: z - .boolean() - .optional() - .describe( - [ - "If true, spawn the agent as an independent top-level user agent tab", - "instead of a child subagent. User agents have no parent, persist on", - "their own, and are fire-and-forget (cannot be retrieved). Requires an", - "'agent' definition listed under 'User agents'. The 'background' option", - "is ignored when top_level is true.", - ].join(" "), - ), - } - : {}), - tools: z - .array( - z.enum([ - "read_file", - "read_file_slice", - "list_files", - "write_file", - "run_shell", - "search_code", - "key_usage", - "todo", - "summon", - "retrieve", - "web_search", - "youtube_transcribe", - "send_to_tab", - "read_tab", - ]), - ) - .optional() - .describe( - "Tool names to give the child. Defaults to the agent definition's tools. Intersected with the spawning agent's tools (you can't grant capabilities you don't have).", - ), - working_directory: z - .string() - .optional() - .describe( - "Absolute path for the child to work in. Defaults to the agent definition's cwd (or the spawning agent's directory).", - ), - // `background` is meaningless for fire-and-forget user agents, so the - // knob is omitted in user-agent-only mode. - ...(userAgentOnly - ? {} - : { - background: z - .boolean() - .optional() - .describe( - "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.", - ), - }), - }; - - return { - name: "summon", - description, - parameters: z.object(parametersShape), - execute: async (args: Record<string, unknown>): Promise<string> => { - const task = args.task as string; - const agentSlug = args.agent as string | undefined; - const tools = args.tools as string[] | undefined; - const workingDirectory = args.working_directory as string | undefined; - const background = (args.background as boolean | undefined) ?? false; - // User-agent-only mode always spawns top-level user agents. When both - // capabilities are present the caller chooses via `top_level`. When - // only subagents are available, top-level spawning is unavailable. - const topLevel = userAgentOnly - ? true - : userAgentEnabled - ? ((args.top_level as boolean | undefined) ?? false) - : false; - - try { - const agentId = await callbacks.spawn({ - task, - tools: tools ?? [], - ...(workingDirectory ? { workingDirectory } : {}), - ...(agentSlug ? { agentSlug } : {}), - ...(topLevel ? { topLevel: true } : {}), - }); - - if (topLevel) { - // User agents are always fire-and-forget — never block on a - // result and make it explicit that retrieve won't work. - return [ - `User agent spawned successfully.`, - `agent_id: ${agentId}`, - ``, - `The user agent is now working independently in its own top-level tab.`, - `It is fire-and-forget — you cannot retrieve its result.`, - ].join("\n"); - } - - if (!background) { - // Block until the child agent completes. Always prefix the - // result with `agent_id: <uuid>` so the frontend's - // ToolCallDisplay regex (`agent_id:\s*([a-f0-9-]+)`) can - // surface the "Open Tab" button for foreground summons too — - // not just background ones. The child's tab still exists and - // holds the full conversation, so the user should always be - // able to open it. - const result = await callbacks.getResult(agentId); - if (result.status === "done") { - return `agent_id: ${agentId}\n\n${result.result}`; - } - return `agent_id: ${agentId}\n\nError from child agent: ${result.error}`; - } - - return [ - `Agent spawned successfully.`, - `agent_id: ${agentId}`, - ``, - `The child agent is now working on the task in its own tab.`, - `Use the retrieve tool with this agent_id to get the result when ready.`, - ].join("\n"); - } catch (err) { - return `Error spawning agent: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }; -} - -/** - * Build the `AvailableAgent[]` projection of an `AgentDefinition` list, - * deriving each entry's readable `path` from its scope+slug. - */ -function toAvailableAgents( - defs: AgentDefinition[], - globalDir: string, - projectDir: string | null, -): AvailableAgent[] { - return defs.map((d) => { - const baseDir = - d.scope === "global" - ? globalDir - : projectDir - ? `${projectDir.replace(/\/$/, "")}/.dispatch/agents` - : globalDir; - return { - slug: d.slug, - name: d.name, - description: d.description, - path: `${baseDir}/${d.slug}.toml`, - }; - }); -} - -/** - * Subagent definitions (`is_subagent === true`) — spawned as child tabs. - */ -export function toAvailableSubagents( - defs: AgentDefinition[], - globalDir: string, - projectDir: string | null, -): AvailableAgent[] { - return toAvailableAgents( - defs.filter((d) => d.is_subagent), - globalDir, - projectDir, - ); -} - -/** - * User-agent definitions (`is_subagent !== true`) — spawnable as - * independent top-level tabs when `perm_user_agent` is granted. - */ -export function toAvailableUserAgents( - defs: AgentDefinition[], - globalDir: string, - projectDir: string | null, -): AvailableAgent[] { - return toAvailableAgents( - defs.filter((d) => d.is_subagent !== true), - globalDir, - projectDir, - ); -} diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts deleted file mode 100644 index 98dcf01..0000000 --- a/packages/core/src/tools/task-list.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { z } from "zod"; -import type { TaskItem, TaskStatus, ToolDefinition } from "../types/index.js"; - -/** - * Valid task statuses. Matches opencode's todo lifecycle: - * - pending not started - * - in_progress actively working (exactly ONE at a time) - * - completed finished successfully - * - cancelled no longer needed - */ -const VALID_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([ - "pending", - "in_progress", - "completed", - "cancelled", -]); - -function normalizeStatus(value: unknown): TaskStatus { - return typeof value === "string" && VALID_STATUSES.has(value as TaskStatus) - ? (value as TaskStatus) - : "pending"; -} - -/** - * Declarative, whole-list task store (ported from opencode's `todowrite`). - * - * The model never sees ids and never issues per-item mutations. Instead it - * sends the ENTIRE desired list on every call and {@link setTasks} rebuilds the - * stored list, assigning fresh positional ids. This is idempotent and - * eliminates the id-bookkeeping / "task not found" / delta-reasoning failure - * modes of the old imperative CRUD interface. - */ -export class TaskList { - private tasks: TaskItem[] = []; - private listeners: Array<(tasks: TaskItem[]) => void> = []; - - private notify(): void { - const snapshot = this.getTasks(); - for (const listener of this.listeners) { - listener(snapshot); - } - } - - getTasks(): TaskItem[] { - return this.tasks.map((t) => ({ ...t })); - } - - /** - * Replace the entire list. Each item is assigned a fresh positional id - * (`task-1`, `task-2`, …). Invalid/missing statuses fall back to - * `pending`; an empty array clears the list. Always notifies listeners. - */ - setTasks(items: Array<{ content: string; status?: unknown }>): TaskItem[] { - this.tasks = items.map((item, index) => ({ - id: `task-${index + 1}`, - content: item.content, - status: normalizeStatus(item.status), - })); - this.notify(); - return this.getTasks(); - } - - onChange(callback: (tasks: TaskItem[]) => void): () => void { - this.listeners.push(callback); - return () => { - this.listeners = this.listeners.filter((l) => l !== callback); - }; - } -} - -/** - * Rich tool description adapted from opencode's `todowrite.txt`. Teaches the - * declarative whole-list cadence and the status lifecycle. - */ -export const TODO_DESCRIPTION = `Create and maintain a structured todo list for the current session to track progress and surface your plan to the user. - -This is a DECLARATIVE, whole-list tool. There are no ids and no per-item actions: every call sends the ENTIRE list in the \`todos\` parameter and REPLACES the previous list. To change one item, resend the whole list with that item changed. To clear the list, send an empty array. - -## When to use -- The task requires 3+ distinct steps and benefits from planning -- The user provides multiple tasks (numbered or comma-separated) or asks for a todo list -- New instructions arrive — capture them as todos -- You start a task — mark it in_progress (only one at a time) before working -- You finish a task — mark it completed and add any follow-ups discovered - -## When NOT to use -- A single, straightforward task (or fewer than 3 trivial steps) -- Purely informational or conversational requests -- When tracking adds no organizational value - -## States -- pending — not started -- in_progress — actively working (exactly ONE at a time) -- completed — finished successfully -- cancelled — no longer needed - -## Rules -- Send the full desired list every time; the tool replaces the stored list -- Update status in real time; do not batch completions -- Mark completed only after the work is actually done (including any required verification), never on intent -- Keep exactly one in_progress while work remains -- If blocked or partial, keep it in_progress and add a follow-up todo describing the blocker -- Items should be specific and actionable; break large work into smaller steps`; - -export function createTaskListTool(taskList: TaskList): ToolDefinition { - return { - name: "todo", - description: TODO_DESCRIPTION, - parameters: z.object({ - todos: z - .array( - z.object({ - content: z.string().describe("Brief, actionable description of the task"), - status: z - .enum(["pending", "in_progress", "completed", "cancelled"]) - .describe("Current status of the task"), - }), - ) - .describe("The complete, updated todo list. Replaces the previous list entirely."), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const rawTodos = args.todos; - if (!Array.isArray(rawTodos)) { - return "Error: 'todos' must be an array of { content, status } items (send the whole list)."; - } - - const items: Array<{ content: string; status?: unknown }> = []; - for (const entry of rawTodos) { - if (!entry || typeof entry !== "object") { - return "Error: each todo must be an object with a 'content' string and a 'status'."; - } - const content = (entry as Record<string, unknown>).content; - if (typeof content !== "string" || content.trim() === "") { - return "Error: each todo requires a non-empty 'content' string."; - } - items.push({ - content, - status: (entry as Record<string, unknown>).status, - }); - } - - const stored = taskList.setTasks(items); - // Echo the canonical stored list back WITHOUT ids — the model must - // never start tracking ids; it always resends the whole list. - const echo = stored.map((t) => ({ content: t.content, status: t.status })); - return JSON.stringify(echo); - }, - }; -} diff --git a/packages/core/src/tools/truncate.ts b/packages/core/src/tools/truncate.ts deleted file mode 100644 index 8c62174..0000000 --- a/packages/core/src/tools/truncate.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -// ─── Constants ─────────────────────────────────────────────────── -// -// A tool result that exceeds *either* MAX_CHARS or MAX_LINES is treated -// as oversized: the full content is written to a spill file under -// /tmp/dispatch/tool-results/<tabId>/<callId>.txt and the model receives -// HEAD_CHARS from the start + TAIL_CHARS from the end with a notice -// in between. These are deliberate hardcoded defaults — see the design -// discussion in notes/plan.md for the rationale. - -export const MAX_CHARS = 10_000; -export const MAX_LINES = 500; -export const HEAD_CHARS = 1500; -export const TAIL_CHARS = 1500; - -/** Base directory for all tool-result spill files. Per-tab subdirectories live inside. */ -export const SPILL_ROOT = "/tmp/dispatch/tool-results"; - -// ─── Public API ────────────────────────────────────────────────── - -export interface TruncationContext { - /** Tab the tool call belongs to. Used to scope the spill directory. */ - tabId: string; - /** Tool call ID, used as the spill file basename. */ - callId: string; - /** Tool name, included in the truncation notice for human-readable hints. */ - toolName: string; -} - -export interface TruncationResult { - /** Final string sent to the model. Either the original (when under threshold) or the head+notice+tail excerpt. */ - displayResult: string; - /** When truncation happened, the absolute path the full output was spilled to. Undefined otherwise. */ - spillPath?: string; -} - -/** - * Apply universal truncation to a tool result string. - * - * If the result is under both the character and line caps, returns it - * unchanged with no side effects. - * - * If the result exceeds either cap: - * 1. Writes the full content to `<SPILL_ROOT>/<tabId>/<callId>.txt`. - * 2. Builds a display string consisting of HEAD_CHARS from the start, - * a multi-line truncation notice that includes the spill path, and - * TAIL_CHARS from the end. - * - * The notice instructs the model to use `read_file` (with offset/limit) - * or `read_file_slice` to inspect the full content. Every tool result - * flows through this function via `Agent.executeToolWithStreaming`, so - * any new tool that returns a string automatically gets the protection. - */ -export function applyTruncation(result: string, ctx: TruncationContext): TruncationResult { - const totalChars = result.length; - const totalLines = countLines(result); - - if (totalChars <= MAX_CHARS && totalLines <= MAX_LINES) { - return { displayResult: result }; - } - - const spillPath = join(SPILL_ROOT, ctx.tabId, `${ctx.callId}.txt`); - try { - mkdirSync(dirname(spillPath), { recursive: true, mode: 0o700 }); - writeFileSync(spillPath, result, { encoding: "utf-8", mode: 0o600 }); - } catch (err) { - // If we can't spill (disk full, perms, etc.) fall back to hard-truncating - // the head + tail without a spill path reference. The model loses the - // ability to inspect the middle but won't be blocked outright. - const message = err instanceof Error ? err.message : String(err); - return { - displayResult: buildExcerpt(result, totalChars, totalLines, ctx, { - spillPath: null, - spillError: message, - }), - }; - } - - return { - displayResult: buildExcerpt(result, totalChars, totalLines, ctx, { - spillPath, - spillError: null, - }), - spillPath, - }; -} - -/** Delete the entire spill directory for a tab. Best-effort, errors swallowed. */ -export function clearSpillForTab(tabId: string): void { - const dir = join(SPILL_ROOT, tabId); - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // Ignore — tab close should not fail on cleanup errors - } -} - -// ─── Internal helpers ──────────────────────────────────────────── - -function countLines(s: string): number { - if (s.length === 0) return 0; - let count = 1; - for (let i = 0; i < s.length; i++) { - if (s.charCodeAt(i) === 10 /* \n */) count++; - } - return count; -} - -function buildExcerpt( - result: string, - totalChars: number, - totalLines: number, - ctx: TruncationContext, - spill: { spillPath: string | null; spillError: string | null }, -): string { - // Guard against pathological case where HEAD+TAIL overlap. If the result - // is between MAX_CHARS and HEAD+TAIL (rare), slice cleanly so we don't - // emit overlapping content. - const head = result.slice(0, HEAD_CHARS); - const tailStart = Math.max(HEAD_CHARS, totalChars - TAIL_CHARS); - const tail = result.slice(tailStart); - - const omittedChars = Math.max(0, totalChars - head.length - tail.length); - - const notice: string[] = [ - "", - `[output truncated by dispatch — tool=${ctx.toolName}, total ${totalChars.toLocaleString()} chars / ${totalLines.toLocaleString()} lines; showing first ${head.length.toLocaleString()} and last ${tail.length.toLocaleString()}, ${omittedChars.toLocaleString()} omitted]`, - ]; - if (spill.spillPath) { - notice.push( - `[full output saved to: ${spill.spillPath}]`, - `[use read_file with offset/limit (lines), or read_file_slice (chars within a single line), to inspect specific sections]`, - ); - } else if (spill.spillError) { - notice.push(`[failed to spill full output to disk: ${spill.spillError}]`); - } - notice.push(""); - - return `${head}${notice.join("\n")}${tail}`; -} diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts deleted file mode 100644 index 7f061a5..0000000 --- a/packages/core/src/tools/web-search.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { z } from "zod"; -import type { ToolDefinition } from "../types/index.js"; - -const FIRECRAWL_URL = "http://100.102.55.49:31329/v1/search"; -const MAX_OUTPUT_CHARS = 60000; -const TIMEOUT_MS = 30000; - -export function createWebSearchTool(): ToolDefinition { - return { - name: "web_search", - description: - "Search the web via a self-hosted Firecrawl instance. Returns a list of results with titles, URLs, and descriptions. Optionally scrapes the full markdown content of each result page.", - parameters: z.object({ - query: z.string().describe("The search query"), - limit: z - .number() - .optional() - .default(7) - .describe("Maximum number of results to return (default 7)"), - scrape: z - .boolean() - .optional() - .default(false) - .describe("Whether to also scrape the full markdown content of each result page"), - lang: z.string().optional().describe('Language code to filter results (e.g. "en")'), - country: z.string().optional().describe('Country code to filter results (e.g. "us")'), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const query = args.query as string; - const limit = (args.limit as number | undefined) ?? 7; - const scrape = (args.scrape as boolean | undefined) ?? false; - const lang = args.lang as string | undefined; - const country = args.country as string | undefined; - - const body: Record<string, unknown> = { query, limit }; - if (lang !== undefined) body.lang = lang; - if (country !== undefined) body.country = country; - if (scrape) { - body.scrapeOptions = { formats: ["markdown"], onlyMainContent: true }; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - - let response: Response; - try { - response = await fetch(FIRECRAWL_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - return "Error: Request to Firecrawl timed out after 30 seconds."; - } - if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") { - return `Error: Could not connect to Firecrawl at http://100.102.55.49:31329. Is it running?`; - } - return `Error: ${err instanceof Error ? err.message : String(err)}`; - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return `Error: Firecrawl returned HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`; - } - - let json: { - data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }>; - }; - try { - json = await response.json(); - } catch { - return "Error: Failed to parse Firecrawl response as JSON"; - } - - const results = json.data ?? []; - if (results.length === 0) { - return "No results found."; - } - - const parts: string[] = []; - for (const result of results) { - const title = result.title ?? "(no title)"; - const url = result.url ?? ""; - const description = result.description ?? ""; - let section = `### ${title}\n${url}\n\n${description}`; - if (result.markdown) { - section += `\n\n${result.markdown}`; - } - parts.push(section); - } - - let output = parts.join("\n\n---\n\n"); - if (output.length > MAX_OUTPUT_CHARS) { - output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Output truncated]`; - } - return output; - }, - }; -} diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts deleted file mode 100644 index 8a73352..0000000 --- a/packages/core/src/tools/write-file.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; -import { z } from "zod"; -import type { ToolDefinition } from "../types/index.js"; -import { canonicalize } from "./path-utils.js"; - -/** - * Optional hook invoked AFTER a successful write, with the canonicalized - * absolute path of the file just written. Its returned string (when non-empty) - * is appended to the tool result. This is how LSP diagnostics are surfaced - * back to the model on write without coupling `@dispatch/core`'s tools to the - * API layer or the LSP manager — the host wires an implementation that touches - * the file through the LSP and formats any diagnostics. Errors thrown here are - * swallowed so a flaky LSP never fails the write itself. - */ -export type AfterWriteHook = (absolutePath: string) => Promise<string>; - -export function createWriteFileTool( - workingDirectory: string, - onAfterWrite?: AfterWriteHook, -): ToolDefinition { - return { - name: "write_file", - description: "Write content to a file relative to the working directory.", - parameters: z.object({ - path: z.string().describe("Path to the file, relative to the working directory"), - content: z.string().describe("Content to write to the file"), - }), - execute: async (args: Record<string, unknown>): Promise<string> => { - const filePath = args.path as string; - const content = args.content as string; - // Canonicalize so a workdir-relative path that resolves through - // symlinks to outside the workdir is detected and blocked. The - // canonicalize walks up to the nearest existing ancestor when the - // leaf doesn't exist (typical for write_file), so a path like - // `workdir/escape-link/new-file.txt` where `escape-link` symlinks - // to /etc still resolves through the symlink and is caught here. - const absolutePath = await canonicalize(workingDirectory, filePath); - const absoluteWorkDir = await canonicalize(workingDirectory); - - if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) { - return `Error: Path "${filePath}" is outside the working directory.`; - } - - try { - await mkdir(dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, content, "utf8"); - } catch (err) { - return `Error writing file: ${err instanceof Error ? err.message : String(err)}`; - } - - let result = `Successfully wrote to "${filePath}".`; - // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a - // hook failure turn a successful write into an error. - if (onAfterWrite) { - try { - const extra = await onAfterWrite(absolutePath); - if (extra) result += `\n\n${extra}`; - } catch { - /* ignore — diagnostics are advisory */ - } - } - return result; - }, - }; -} diff --git a/packages/core/src/tools/youtube-transcribe.ts b/packages/core/src/tools/youtube-transcribe.ts deleted file mode 100644 index 3a26d6f..0000000 --- a/packages/core/src/tools/youtube-transcribe.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { z } from "zod"; -import type { ToolDefinition, ToolExecuteContext } from "../types/index.js"; - -const TRANSCRIBER_BASE = "http://100.102.55.49:41090"; -const MAX_OUTPUT_CHARS = 60000; -const REQUEST_TIMEOUT_MS = 30000; -const MAX_WAIT_MS = 10 * 60 * 1000; // give up after 10 minutes of polling - -interface TranscriptResponse { - status: string; - video_id?: string; - full_text?: string; - segments?: Array<{ text: string; start: number; duration: number }>; - position?: number; - estimated_seconds?: number; - error?: string; - error_type?: string; -} - -async function fetchTranscript(url: string): Promise<TranscriptResponse> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const apiUrl = `${TRANSCRIBER_BASE}/api/transcript?url=${encodeURIComponent(url)}`; - const response = await fetch(apiUrl, { signal: controller.signal }); - if (!response.ok) { - throw new Error(`Transcriber returned HTTP ${response.status} ${response.statusText}`); - } - return (await response.json()) as TranscriptResponse; - } finally { - clearTimeout(timeout); - } -} - -function formatTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; -} - -function formatTranscript(data: TranscriptResponse): string { - const segments = data.segments ?? []; - const segmentsText = segments.map((seg) => `[${formatTime(seg.start)}] ${seg.text}`).join("\n"); - - const output = [ - `Video ID: ${data.video_id}`, - "", - "## Transcript", - "", - data.full_text ?? "", - "", - "## Timestamped Segments", - "", - segmentsText, - ].join("\n"); - - return output.length > MAX_OUTPUT_CHARS - ? `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Transcript truncated]` - : output; -} - -function sleep(ms: number): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** Polls until the transcript is ready, fails, or times out. */ -async function pollUntilReady(url: string): Promise<string> { - const startTime = Date.now(); - - while (Date.now() - startTime < MAX_WAIT_MS) { - const data = await fetchTranscript(url); - - if (data.status === "completed") { - return formatTranscript(data); - } - - if (data.status === "failed") { - return `Error: Transcription failed for video ${data.video_id ?? "unknown"}: [${data.error_type ?? "unknown"}] ${data.error ?? "no details"}`; - } - - if (data.status === "queued" || data.status === "processing") { - const estimate = data.estimated_seconds ?? 30; - const waitMs = Math.max((estimate - 2) * 1000, 2000); - await sleep(waitMs); - continue; - } - - return `Error: Unexpected transcriber response status: ${data.status}`; - } - - return "Error: Timed out waiting for transcript after 10 minutes."; -} - -/** Store for transcript polls backgrounded due to user interrupt. */ -export class BackgroundTranscriptStore { - private jobs = new Map<string, { url: string; completion: Promise<string> }>(); - - register(url: string, completion: Promise<string>): string { - const id = `youtube_transcribe_${randomUUID()}`; - this.jobs.set(id, { url, completion }); - // Auto-cleanup 10 minutes after completion - completion.finally(() => { - setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000); - }); - return id; - } - - async getResult( - id: string, - ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> { - const job = this.jobs.get(id); - if (!job) { - return { status: "error", error: `No background transcript job found with id '${id}'` }; - } - const result = await job.completion; - return { status: "done", result }; - } - - has(id: string): boolean { - return this.jobs.has(id); - } -} - -export function createYoutubeTranscribeTool( - transcriptStore?: BackgroundTranscriptStore, -): ToolDefinition { - return { - name: "youtube_transcribe", - description: [ - "Fetch the transcript/subtitles for a YouTube video. This tool blocks until the transcript is ready.", - "If the video hasn't been transcribed yet, it will be queued and this tool waits for it automatically.", - "If the user interrupts while waiting, the request continues in the background and you receive a job ID.", - "Use the retrieve tool with that ID to get the transcript later.", - "", - "Accepted URL formats:", - " - youtube.com/watch?v=", - " - youtu.be/", - " - youtube.com/embed/", - " - youtube.com/shorts/", - ].join("\n"), - parameters: z.object({ - url: z.string().describe("The YouTube video URL to fetch the transcript for."), - background: z - .boolean() - .optional() - .describe( - "If true, the transcription request starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the transcript later.", - ), - }), - execute: async ( - args: Record<string, unknown>, - context?: ToolExecuteContext, - ): Promise<string> => { - const url = args.url as string; - const background = (args.background as boolean | undefined) ?? false; - const queueCallbacks = context?.queueCallbacks; - - try { - const pollPromise = pollUntilReady(url); - - // If background mode requested, register immediately and return job ID - if (background && transcriptStore) { - const jobId = transcriptStore.register(url, pollPromise); - return [ - `Transcript request started in background.`, - `job_id: ${jobId}`, - `url: ${url}`, - ``, - `Use the retrieve tool with this job_id to get the transcript when ready.`, - ].join("\n"); - } - - if (queueCallbacks && transcriptStore) { - const { promise: queuePromise, cancel: cancelQueueWait } = - queueCallbacks.waitForQueuedMessage(); - const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); - - const raceResult = await Promise.race([pollPromise, queueSignal]); - - if (raceResult === "QUEUE_INTERRUPT") { - // Background the still-polling request - const jobId = transcriptStore.register(url, pollPromise); - - const queuedMsgs = queueCallbacks.dequeueMessages(); - const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); - - return [ - `Transcript request backgrounded — still waiting for transcription.`, - `job_id: ${jobId}`, - `url: ${url}`, - ``, - `Use the retrieve tool with this job_id to get the transcript when ready.`, - ``, - `[USER INTERRUPT]`, - `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`, - ``, - userMessages, - ].join("\n"); - } - - // Poll finished before interrupt - cancelQueueWait(); - return raceResult; - } - - return await pollPromise; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - return "Error: Request to YouTube transcriber timed out."; - } - if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") { - return `Error: Could not connect to YouTube transcriber at ${TRANSCRIBER_BASE}. Is it running?`; - } - return `Error: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }; -} |
