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 | |
| 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')
69 files changed, 0 insertions, 11234 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts Binary files differdeleted file mode 100644 index 2e2dbb2..0000000 --- a/packages/core/src/agent/agent.ts +++ /dev/null diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts deleted file mode 100644 index 4931162..0000000 --- a/packages/core/src/agents/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - deleteAgent, - expandAgentToolNames, - GLOBAL_AGENTS_DIR, - getAgentDirPaths, - getAgentDirs, - getProjectAgentsDir, - loadAgent, - loadAgents, - saveAgent, -} from "./loader.js"; diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts deleted file mode 100644 index 9971803..0000000 --- a/packages/core/src/agents/loader.ts +++ /dev/null @@ -1,294 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml"; -import { type AgentDefinition, type AgentModelEntry, isReasoningEffort } from "../types/index.js"; - -// ─── Helpers ───────────────────────────────────────────────────── - -/** Sanitize a slug to prevent path traversal */ -function sanitizeSlug(slug: string): string { - // Strip directory components and ensure only safe characters - const base = path.basename(slug); - const clean = base - .replace(/[^a-zA-Z0-9_-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); - if (!clean) throw new Error("Invalid agent slug"); - return clean; -} - -// ─── Constants ─────────────────────────────────────────────────── - -export const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents"); - -export function getProjectAgentsDir(projectDir: string): string { - return path.join(projectDir, ".dispatch", "agents"); -} - -// ─── Public API ────────────────────────────────────────────────── - -/** - * Returns the agent directories that exist or could exist. - * Always includes global. Includes project dir if projectDir is provided. - */ -export function getAgentDirs( - projectDir?: string, -): Array<{ label: string; path: string; scope: string }> { - const dirs: Array<{ label: string; path: string; scope: string }> = [ - { label: "Global (~/.config/dispatch/agents)", path: GLOBAL_AGENTS_DIR, scope: "global" }, - ]; - if (projectDir) { - dirs.push({ - label: `.dispatch/agents (${path.basename(projectDir)})`, - path: getProjectAgentsDir(projectDir), - scope: projectDir, - }); - } - return dirs; -} - -/** - * Return just the absolute filesystem paths of the agent directories. - * Used by the agent's permission gate to grant read-only access to - * these locations by default (so any agent can list/read agent - * definitions without prompting the user). - */ -export function getAgentDirPaths(projectDir?: string): string[] { - const paths = [GLOBAL_AGENTS_DIR]; - if (projectDir) paths.push(getProjectAgentsDir(projectDir)); - return paths; -} - -/** - * Load a single agent definition by slug. Searches the project-scoped - * directory first (if `projectDir` is provided), then falls back to - * the global directory. Returns `null` if no match is found. - * - * Slug matching is exact and case-sensitive; sanitization mirrors - * `saveAgent` to keep loader and writer symmetric. - */ -export function loadAgent(slug: string, projectDir?: string): AgentDefinition | null { - const safeSlug = sanitizeSlug(slug); - const agents = loadAgents(projectDir); - return agents.find((a) => a.slug === safeSlug) ?? null; -} - -/** - * Translate the short permission-group names used by `AgentDefinition.tools` - * (e.g. `"read"`, `"edit"`, `"bash"`) into the concrete tool-implementation - * names registered with the agent runtime (e.g. `"read_file"`, - * `"list_files"`, `"write_file"`, `"run_shell"`). - * - * The mapping mirrors the per-permission tool-creation paths in - * `AgentManager.getOrCreateAgentForTab` so a subagent summoned with a - * given agent definition ends up with the exact same set of registered - * tools as a top-level tab using that definition. Tool names that aren't - * group aliases (`summon`, `retrieve`, `web_search`, `youtube_transcribe`, - * `todo`) are passed through unchanged. - * - * `"todo"` is auto-included so the summoned agent always has its task list - * available, matching the parent-agent path which always registers `todo`. - */ -export function expandAgentToolNames(tools: string[]): string[] { - const expanded = new Set<string>(); - for (const t of tools) { - switch (t) { - case "read": - expanded.add("read_file"); - expanded.add("read_file_slice"); - expanded.add("list_files"); - break; - case "edit": - expanded.add("write_file"); - break; - case "bash": - expanded.add("run_shell"); - break; - default: - // Pass through tool names that aren't permission-group - // aliases (summon, retrieve, web_search, youtube_transcribe, - // send_to_tab, read_tab, todo, and the granular file tools - // themselves if a user hand-wrote them in a TOML). - expanded.add(t); - } - } - // Always include `todo` — every agent should be able to track its work, - // and the parent-agent path adds it unconditionally. - expanded.add("todo"); - return Array.from(expanded); -} - -/** - * Ensure the default global agent exists. Creates it if missing. - */ -function ensureDefaultAgent(): void { - const filePath = path.join(GLOBAL_AGENTS_DIR, "default.toml"); - if (fs.existsSync(filePath)) return; - - const defaultAgent: AgentDefinition = { - name: "Default", - description: "Default agent with all tools enabled", - skills: [], - tools: ["read", "edit", "bash", "summon"], - models: [], - scope: "global", - slug: "default", - }; - saveAgent(defaultAgent); -} - -/** - * Load all agent definitions from global + project directories. - * Auto-generates the default global agent if it doesn't exist. - */ -export function loadAgents(projectDir?: string): AgentDefinition[] { - ensureDefaultAgent(); - - const agents: AgentDefinition[] = []; - - // Global agents - agents.push(...loadAgentsFromDir(GLOBAL_AGENTS_DIR, "global")); - - // Project-scoped agents - if (projectDir) { - agents.push(...loadAgentsFromDir(getProjectAgentsDir(projectDir), projectDir)); - } - - return agents; -} - -/** - * Save (create or update) an agent definition to a TOML file. - * The scope determines which directory: - * - "global" -> ~/.config/dispatch/agents/ - * - any other string -> that directory path + /.dispatch/agents/ - */ -export function saveAgent(agent: AgentDefinition): void { - if (agent.scope !== "global" && agent.scope.includes("..")) { - throw new Error("Invalid agent scope"); - } - const dir = agent.scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(agent.scope); - - fs.mkdirSync(dir, { recursive: true }); - - const tomlContent: Record<string, unknown> = { - name: agent.name, - description: agent.description, - skills: agent.skills, - tools: agent.tools, - }; - - if (agent.cwd) { - tomlContent.cwd = agent.cwd; - } - - if (agent.is_subagent) { - tomlContent.is_subagent = true; - } - - // smol-toml handles [[models]] array-of-tables - if (agent.models.length > 0) { - tomlContent.models = agent.models.map((m) => ({ - key_id: m.key_id, - model_id: m.model_id, - ...(m.effort ? { effort: m.effort } : {}), - })); - } - - const content = stringifyTOML(tomlContent); - const safeSlug = sanitizeSlug(agent.slug); - const filePath = path.join(dir, `${safeSlug}.toml`); - fs.writeFileSync(filePath, content, "utf-8"); -} - -/** - * Delete an agent TOML file. - */ -export function deleteAgent(slug: string, scope: string): boolean { - if (scope !== "global" && scope.includes("..")) { - throw new Error("Invalid agent scope"); - } - const dir = scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(scope); - - const safeSlug = sanitizeSlug(slug); - const filePath = path.join(dir, `${safeSlug}.toml`); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - return true; - } - return false; -} - -// ─── Internal ──────────────────────────────────────────────────── - -function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] { - if (!fs.existsSync(dir)) return []; - - const results: AgentDefinition[] = []; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".toml")) continue; - - const filePath = path.join(dir, entry.name); - const slug = entry.name.slice(0, -5); // remove .toml - - try { - const raw = fs.readFileSync(filePath, "utf-8"); - const parsed = parseTOML(raw); - - const models: AgentModelEntry[] = []; - if (Array.isArray(parsed.models)) { - for (const m of parsed.models) { - if (m && typeof m === "object" && "key_id" in m && "model_id" in m) { - const rawEffort = (m as Record<string, unknown>).effort; - models.push({ - key_id: String((m as Record<string, unknown>).key_id), - model_id: String((m as Record<string, unknown>).model_id), - // Only carry `effort` when it's a recognised level; an - // unset or invalid value falls back to the per-tab / - // default effort at the call site. - ...(isReasoningEffort(rawEffort) ? { effort: rawEffort } : {}), - }); - } - } - } - - const skills: string[] = []; - if (Array.isArray(parsed.skills)) { - for (const s of parsed.skills) { - if (typeof s === "string") skills.push(s); - } - } - - const tools: string[] = []; - if (Array.isArray(parsed.tools)) { - for (const t of parsed.tools) { - if (typeof t === "string") tools.push(t); - } - } - - results.push({ - name: typeof parsed.name === "string" ? parsed.name : slug, - description: typeof parsed.description === "string" ? parsed.description : "", - skills, - tools, - models, - scope, - slug, - ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}), - ...(parsed.is_subagent === true ? { is_subagent: true } : {}), - }); - } catch { - // Skip unparseable files - } - } - - return results; -} diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts deleted file mode 100644 index 4ca6fe1..0000000 --- a/packages/core/src/chunks/append.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Chunk-builder helper. - * - * `appendEventToChunks` is the single source of truth for how a stream of - * `AgentEvent`s collapses into an ordered `Chunk[]` on a message. Both the - * backend (agent + agent-manager) and the frontend store call this helper - * so the wire format stays in lockstep across the boundary. - * - * Open/close rules — see notes/plan-chunk-refactor.md for the full table. - * - * | Chunk | Opens on | Coalesces | - * |---------------|-----------------------------------------------------------------|------------------------------------------------------------| - * | `text` | first `text-delta` after a non-text chunk | consecutive `text-delta` events append to `.text` | - * | `thinking` | first `reasoning-delta` after a non-thinking chunk | consecutive `reasoning-delta` events append to `.text` | - * | | OR after the last thinking chunk was sealed by `reasoning-end` | (only into the most recent UNSEALED thinking chunk) | - * | `tool-batch` | first `tool-call` after a non-tool-batch chunk | consecutive `tool-call` events push a new entry to `.calls`| - * | `error` | every `error` event | NEVER (always single-event) | - * | `system` | every `notice`/`model-changed`/`config-reload`/... | NEVER (two consecutive system events → two chunks) | - * - * Side-effect events (no new chunk): - * - `tool-result` → finds the call by `id` across all `tool-batch` - * chunks (most-recent first) and updates its - * `result` / `isError`. - * - `shell-output` → appends to the most recent entry of the most - * recent `tool-batch` chunk. - * - `reasoning-end` → attaches `metadata` (the AI SDK v6 - * `providerMetadata` blob) to the most recent - * UNSEALED `thinking` chunk. The metadata is also - * the "sealed" marker — subsequent - * `reasoning-delta`s will open a new chunk rather - * than extending this one. Anthropic's signature - * lives inside this blob; round-tripping it on the - * next turn is mandatory for Anthropic to accept - * the conversation. Orphan `reasoning-end` events - * (no unsealed thinking chunk) are dropped. - * - * Ignored events: - * - `status`, `turn-start`, `turn-sealed`, `done`, `usage`, - * `task-list-update`, `tab-created`, `message-queued`, `message-consumed`, - * `message-cancelled` — these are control / lifecycle events, not message - * content. - */ - -import type { - AgentEvent, - ChatMessage, - Chunk, - MessageRole, - SystemChunk, - SystemChunkKind, - ToolBatchChunk, -} from "../types/index.js"; - -/** - * Mutates `chunks` in place based on `event`. - * - * Returns void; the array is the output channel. - */ -export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void { - switch (event.type) { - case "text-delta": { - // Open or extend the current text chunk. - const last = chunks[chunks.length - 1]; - if (last && last.type === "text") { - last.text += event.delta; - } else { - chunks.push({ type: "text", text: event.delta }); - } - return; - } - - case "reasoning-delta": { - // Open a new thinking chunk if the last chunk is not a thinking - // chunk OR if it's already sealed by metadata. Anthropic emits - // each thinking content block with its own metadata; a fresh - // reasoning-delta after a sealed thinking chunk is the start of - // a new block, not a continuation — extending the sealed chunk - // would corrupt the metadata/text mapping. - const last = chunks[chunks.length - 1]; - if (last && last.type === "thinking" && last.metadata === undefined) { - last.text += event.delta; - } else { - chunks.push({ type: "thinking", text: event.delta }); - } - return; - } - - case "reasoning-end": { - // Attach `providerMetadata` to the most recent unsealed - // thinking chunk. Anthropic's signature lives inside this - // blob; without it on the next request, Anthropic rejects the - // thinking block. The walk-back is a defensive backstop — - // Anthropic's SSE delivers a content block's deltas strictly - // in order and `appendEventToChunks` runs synchronously per - // event, so the most recent thinking chunk is normally the - // last chunk in the array. - if (event.metadata === undefined) return; - for (let i = chunks.length - 1; i >= 0; i--) { - const c = chunks[i]; - if (!c || c.type !== "thinking") continue; - if (c.metadata !== undefined) { - // Already sealed; the orphan metadata has no home. - return; - } - c.metadata = event.metadata; - return; - } - // No thinking chunk found at all — drop silently. - return; - } - - case "tool-call": { - // Open or extend the current tool-batch chunk. - const last = chunks[chunks.length - 1]; - const entry = { - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - }; - if (last && last.type === "tool-batch") { - last.calls.push(entry); - } else { - chunks.push({ type: "tool-batch", calls: [entry] }); - } - return; - } - - case "tool-result": { - // Find the matching call (by id) across all tool-batch chunks, - // most-recent first. Tool results can arrive after subsequent - // text-deltas, so we cannot rely on the *last* chunk being the - // tool-batch — we have to search. - for (let i = chunks.length - 1; i >= 0; i--) { - const c = chunks[i]; - if (!c || c.type !== "tool-batch") continue; - const call = c.calls.find((e) => e.id === event.toolResult.toolCallId); - if (call) { - call.result = event.toolResult.result; - call.isError = event.toolResult.isError; - return; - } - } - // Orphan result with no matching call — drop silently. - return; - } - - case "shell-output": { - // Append to the most recent entry of the most recent tool-batch. - // Walk back through chunks to find the latest tool-batch; if there - // are intervening text/thinking/etc chunks (which can happen if - // the model streams text while a shell tool is still running), - // we still want the most recent tool-batch. - for (let i = chunks.length - 1; i >= 0; i--) { - const c = chunks[i]; - if (!c || c.type !== "tool-batch") continue; - const entry = c.calls[c.calls.length - 1]; - if (!entry) return; - const prev = entry.shellOutput ?? { stdout: "", stderr: "" }; - entry.shellOutput = { - stdout: prev.stdout + (event.stream === "stdout" ? event.data : ""), - stderr: prev.stderr + (event.stream === "stderr" ? event.data : ""), - }; - return; - } - // Orphan shell-output with no tool-batch in scope — drop silently. - return; - } - - case "error": { - // Always a fresh single-event chunk — no coalescing. - chunks.push({ - type: "error", - message: event.error, - ...(event.statusCode !== undefined ? { statusCode: event.statusCode } : {}), - }); - return; - } - - case "notice": { - chunks.push({ type: "system", kind: "notice", text: event.message }); - return; - } - - case "model-changed": { - chunks.push({ - type: "system", - kind: "model-changed", - text: `Switched to ${event.modelId} (${event.keyId})`, - }); - return; - } - - case "config-reload": { - chunks.push({ - type: "system", - kind: "config-reload", - text: "Configuration reloaded", - }); - return; - } - - // Lifecycle / control events — no chunk emitted. - case "status": - case "turn-start": - case "turn-sealed": - case "done": - case "usage": - case "task-list-update": - case "tab-created": - case "message-queued": - case "message-consumed": - case "message-cancelled": - case "compaction-started": - case "compaction-complete": - case "compaction-error": - return; - - default: { - // Exhaustiveness check — if a new event variant is added to - // AgentEvent, TypeScript will complain here. - const _exhaustive: never = event; - void _exhaustive; - return; - } - } -} - -// ─── System event routing across messages ──────────────────────── - -/** - * Minimal shape needed by `applySystemEvent`. - * - * The caller (agent-manager / persistence layer) typically tracks message - * id alongside the wire-format `ChatMessage`. This generic constraint - * lets us keep core `ChatMessage` clean while still letting downstream - * pass anything with an `id`. - */ -export interface IdentifiedMessage { - id: string; - role: MessageRole; - chunks: Chunk[]; -} - -/** - * Describes the system event in caller-controlled terms. We let the caller - * decide both the `kind` (so the same helper can record cancellations, - * notices, model swaps, etc.) and the `text` (so the caller controls - * formatting / localization). - */ -export interface SystemEventLike { - kind: SystemChunkKind; - text: string; -} - -/** - * Routes a system event to the right message when *no assistant turn is - * in flight*. (When a turn IS in flight, the caller should instead use - * `appendEventToChunks` against the in-flight message's chunks directly.) - * - * Routing rules (from notes/plan-chunk-refactor.md): - * - * 1. Most recent message is `role: "system"` → append a `system` chunk - * to it. (Note: a second consecutive system event creates a second - * system chunk inside the same system message — chunks themselves - * never coalesce.) - * 2. Otherwise → create a fresh `role: "system"` message containing one - * `system` chunk and push it. - * - * Returns the `messageId` that was used (either the existing system - * message's id or the newly-created one) so the caller can persist / - * emit a diff to subscribers. - * - * `idFactory` defaults to `crypto.randomUUID()`; tests inject a - * deterministic factory. - */ -export function applySystemEvent<M extends IdentifiedMessage>( - messages: M[], - event: SystemEventLike, - idFactory: () => string = defaultIdFactory, -): { messageId: string } { - const chunk: SystemChunk = { type: "system", kind: event.kind, text: event.text }; - - const last = messages[messages.length - 1]; - if (last && last.role === "system") { - last.chunks.push(chunk); - return { messageId: last.id }; - } - - const id = idFactory(); - // We can't fabricate the full `M` shape without knowing its extra - // fields, but `IdentifiedMessage` is the minimum we need to push. - // Callers that extend the shape with extra fields are responsible for - // initializing them via post-hoc patching, or by passing in their own - // message-creation logic. In practice callers either: - // (a) use `ChatMessage` itself (no extra fields beyond IdentifiedMessage), or - // (b) construct messages and look them up by id after this call returns. - const newMessage = { id, role: "system" as const, chunks: [chunk] } as unknown as M; - messages.push(newMessage); - return { messageId: id }; -} - -function defaultIdFactory(): string { - // In Node 19+ / modern browsers, `crypto.randomUUID` is available globally. - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - // Fallback: pseudo-random; not cryptographically secure, but adequate for - // in-memory message identifiers when randomUUID is unavailable. - return `sysmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -// ─── Re-exports for convenience ────────────────────────────────── - -export type { ChatMessage, Chunk, SystemChunk, SystemChunkKind, ToolBatchChunk }; diff --git a/packages/core/src/chunks/transform.ts b/packages/core/src/chunks/transform.ts deleted file mode 100644 index e8f4a18..0000000 --- a/packages/core/src/chunks/transform.ts +++ /dev/null @@ -1,271 +0,0 @@ -// Pure, dependency-free transforms between the render-shaped `Chunk[]` / -// `ChatMessage` model and the flat append-only `ChunkRow` log. Kept free of any -// DB (`bun:sqlite`) import so BOTH the backend persistence layer -// (`db/chunks.ts`) and the browser frontend store can share the exact same -// explode/group logic. - -import type { - Chunk, - ChunkRow, - ChunkRowDraft, - ErrorData, - MessageRole, - SystemData, - TextData, - ThinkingData, - ToolBatchChunk, - ToolCallData, - ToolResultData, -} from "../types/index.js"; - -/** - * A DERIVED message — a grouping of contiguous chunk rows reconstructed for the - * agent's in-memory history and for the frontend's render bubbles. NOT a stored - * shape: the source of truth is the flat chunk log. - */ -export interface MessageRow { - id: string; - tabId: string; - seq: number; - /** turn_id of the chunk rows this message was grouped from. */ - turnId: string; - role: MessageRole; - chunks: Chunk[]; - createdAt: number; -} - -// ─── Explode: in-memory turn → flat chunk-row drafts ───────────── -// -// A turn's render-shaped `Chunk[]` is flattened into append-only rows. -// `tool-batch` chunks are split into SEPARATE `tool_call` (role=assistant) and -// `tool_result` (role=tool) rows linked by `callId`, mapping 1:1 to the -// Anthropic wire format. `step` increments after each tool-batch: every LLM -// round-trip emits text/thinking then (optionally) one tool-batch, so a -// tool-batch boundary is exactly a step boundary. - -/** Explode a single user message's text into one row draft. */ -export function explodeUserText(turnId: string, text: string): ChunkRowDraft[] { - return [{ turnId, step: 0, role: "user", type: "text", data: { text } }]; -} - -/** Explode an assistant turn's accumulated chunks into ordered row drafts. */ -export function explodeTurn(turnId: string, chunks: Chunk[]): ChunkRowDraft[] { - const drafts: ChunkRowDraft[] = []; - let step = 0; - for (const chunk of chunks) { - switch (chunk.type) { - case "text": - drafts.push({ turnId, step, role: "assistant", type: "text", data: { text: chunk.text } }); - break; - case "thinking": - drafts.push({ - turnId, - step, - role: "assistant", - type: "thinking", - data: { - text: chunk.text, - ...(chunk.metadata !== undefined ? { metadata: chunk.metadata } : {}), - }, - }); - break; - case "tool-batch": { - for (const call of chunk.calls) { - drafts.push({ - turnId, - step, - role: "assistant", - type: "tool_call", - data: { callId: call.id, name: call.name, arguments: call.arguments }, - }); - } - for (const call of chunk.calls) { - if (call.result === undefined) continue; - drafts.push({ - turnId, - step, - role: "tool", - type: "tool_result", - data: { - callId: call.id, - name: call.name, - result: call.result, - isError: call.isError ?? false, - ...(call.shellOutput !== undefined ? { shellOutput: call.shellOutput } : {}), - }, - }); - } - // A tool-batch ends the current LLM step; subsequent chunks belong - // to the next round-trip. - step++; - break; - } - case "error": - drafts.push({ - turnId, - step, - role: "assistant", - type: "error", - data: { - message: chunk.message, - ...(chunk.statusCode !== undefined ? { statusCode: chunk.statusCode } : {}), - }, - }); - break; - case "system": - drafts.push({ - turnId, - step, - role: "system", - type: "system", - data: { kind: chunk.kind, text: chunk.text }, - }); - break; - } - } - return drafts; -} - -// ─── Group: flat chunk rows → derived render messages ──────────── -// -// The inverse of explode (best-effort over an arbitrary window, so it tolerates -// orphan tool-results whose tool-call was paged out). `tool_result` rows -// (role=tool) merge back into the preceding assistant message's per-step -// `tool-batch` chunk by `callId` rather than forming their own message. - -export function groupRowsToMessages(rows: ChunkRow[]): MessageRow[] { - const messages: MessageRow[] = []; - - let current: { msg: MessageRow; batches: Map<number, ToolBatchChunk> } | null = null; - const flush = () => { - if (current) { - messages.push(current.msg); - current = null; - } - }; - const ensureAssistant = (row: ChunkRow) => { - if (!current) { - current = { - msg: { - id: row.id, - tabId: row.tabId, - seq: row.seq, - turnId: row.turnId, - role: "assistant", - chunks: [], - createdAt: row.createdAt, - }, - batches: new Map(), - }; - } - return current; - }; - const ensureBatch = (step: number): ToolBatchChunk => { - const c = current; - if (!c) throw new Error("ensureBatch called without an assistant message"); - let batch = c.batches.get(step); - if (!batch) { - batch = { type: "tool-batch", calls: [] }; - c.batches.set(step, batch); - c.msg.chunks.push(batch); - } - return batch; - }; - - for (const row of rows) { - if (row.role === "user") { - flush(); - const d = row.data as TextData; - messages.push({ - id: row.id, - tabId: row.tabId, - seq: row.seq, - turnId: row.turnId, - role: "user", - chunks: [{ type: "text", text: d.text }], - createdAt: row.createdAt, - }); - continue; - } - if (row.role === "system") { - // Coalesce consecutive system rows into one system message (multiple - // system chunks), matching the old applySystemEvent behaviour. - const prev = messages[messages.length - 1]; - const d = row.data as SystemData; - if (current === null && prev && prev.role === "system") { - prev.chunks.push({ type: "system", kind: d.kind, text: d.text }); - continue; - } - flush(); - messages.push({ - id: row.id, - tabId: row.tabId, - seq: row.seq, - turnId: row.turnId, - role: "system", - chunks: [{ type: "system", kind: d.kind, text: d.text }], - createdAt: row.createdAt, - }); - continue; - } - - // Usage rows are an invisible side channel (persisted for the backend - // aggregate only). They're already query-excluded from getChunksForTab, - // so this is defensive insurance: never let one leak into render grouping. - if (row.type === "usage") continue; - - // assistant / tool rows → part of the current assistant message - const c = ensureAssistant(row); - switch (row.type) { - case "text": - c.msg.chunks.push({ type: "text", text: (row.data as TextData).text }); - break; - case "thinking": { - const d = row.data as ThinkingData; - c.msg.chunks.push({ - type: "thinking", - text: d.text, - ...(d.metadata !== undefined ? { metadata: d.metadata } : {}), - }); - break; - } - case "error": { - const d = row.data as ErrorData; - c.msg.chunks.push({ - type: "error", - message: d.message, - ...(d.statusCode !== undefined ? { statusCode: d.statusCode } : {}), - }); - break; - } - case "tool_call": { - const d = row.data as ToolCallData; - ensureBatch(row.step).calls.push({ id: d.callId, name: d.name, arguments: d.arguments }); - break; - } - case "tool_result": { - const d = row.data as ToolResultData; - const batch = ensureBatch(row.step); - const entry = batch.calls.find((e) => e.id === d.callId); - if (entry) { - entry.result = d.result; - entry.isError = d.isError; - if (d.shellOutput !== undefined) entry.shellOutput = d.shellOutput; - } else { - // Orphan result (its tool_call was paged out of this window). - batch.calls.push({ - id: d.callId, - name: d.name, - arguments: {}, - result: d.result, - isError: d.isError, - ...(d.shellOutput !== undefined ? { shellOutput: d.shellOutput } : {}), - }); - } - break; - } - } - } - flush(); - return messages; -} diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts deleted file mode 100644 index 663ccb2..0000000 --- a/packages/core/src/compaction/index.ts +++ /dev/null @@ -1,245 +0,0 @@ -// Conversation compaction — summarize the older "head" of a conversation into -// a structured anchor while preserving the most recent turns verbatim. -// -// Ported from opencode's `session/compaction.ts` (SUMMARY_TEMPLATE, the -// anchored-summary `buildPrompt`, and the `TOOL_OUTPUT_MAX_CHARS` cap). The -// turn-budget `splitTurn` tail selection is intentionally simplified to a fixed -// recent-turn count (`DEFAULT_TAIL_TURNS`) — Dispatch keeps the last N turns -// verbatim rather than computing a token budget. -// -// This module is pure and DB-free so it can be unit-tested in isolation and -// shared by the API orchestrator. - -import type { ChatMessage } from "../types/index.js"; - -/** Number of trailing turns kept verbatim (opencode's DEFAULT_TAIL_TURNS). */ -export const DEFAULT_TAIL_TURNS = 2; - -/** Max characters of a single tool result fed into the summary request. */ -export const TOOL_OUTPUT_MAX_CHARS = 2_000; - -/** - * Marker prefixing the seeded summary turn in a compacted conversation. Lets a - * subsequent compaction detect a prior summary and re-summarize (anchor) it - * instead of treating it as ordinary conversation. - */ -export const SUMMARY_MARKER = "[CONVERSATION SUMMARY]"; - -/** - * Structured Markdown template the summary must follow. Ported verbatim from - * opencode's `SUMMARY_TEMPLATE`. - */ -export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response. -<template> -## Goal -- [single-sentence task summary] - -## Constraints & Preferences -- [user constraints, preferences, specs, or "(none)"] - -## Progress -### Done -- [completed work or "(none)"] - -### In Progress -- [current work or "(none)"] - -### Blocked -- [blockers or "(none)"] - -## Key Decisions -- [decision and why, or "(none)"] - -## Next Steps -- [ordered next actions or "(none)"] - -## Critical Context -- [important technical facts, errors, open questions, or "(none)"] - -## Relevant Files -- [file or directory path: why it matters, or "(none)"] -</template> - -Rules: -- Keep every section, even when empty. -- Use terse bullets, not prose paragraphs. -- Preserve exact file paths, commands, error strings, and identifiers when known. -- Do not mention the summary process or that context was compacted.`; - -/** - * Build the compaction instruction. When `previousSummary` is provided, the - * model is asked to UPDATE the anchored summary rather than create a fresh one - * (opencode's `buildPrompt` anchor behaviour). - */ -export function buildCompactionPrompt(input: { previousSummary?: string }): string { - const anchor = input.previousSummary - ? [ - "Update the anchored summary below using the conversation history above.", - "Preserve still-true details, remove stale details, and merge in the new facts.", - "<previous-summary>", - input.previousSummary, - "</previous-summary>", - ].join("\n") - : "Create a new anchored summary from the conversation history above."; - return `${anchor}\n\n${SUMMARY_TEMPLATE}`; -} - -/** - * The first text chunk of a message, trimmed (empty → undefined). Used to read - * the seeded summary out of a compacted conversation's first user turn. - */ -function firstText(message: ChatMessage): string | undefined { - for (const chunk of message.chunks) { - if (chunk.type === "text") { - const t = chunk.text.trim(); - if (t) return t; - } - } - return undefined; -} - -/** - * Extract a prior summary from the conversation head. If the first user message - * is a seeded summary (starts with {@link SUMMARY_MARKER}), return its body - * (marker stripped) so the next compaction can anchor on it. - */ -export function extractPreviousSummary(messages: ChatMessage[]): string | undefined { - const first = messages.find((m) => m.role === "user"); - if (!first) return undefined; - const text = firstText(first); - if (!text?.startsWith(SUMMARY_MARKER)) return undefined; - const body = text.slice(SUMMARY_MARKER.length).trim(); - return body || undefined; -} - -export interface HeadTailSelection<T extends ChatMessage = ChatMessage> { - /** Older messages to be summarized away. */ - head: T[]; - /** Recent messages preserved verbatim in the continuation. */ - tail: T[]; -} - -/** - * Split a conversation into a summarizable `head` and a preserved `tail` of the - * last `tailTurns` turns. A "turn" begins at a user message and runs until the - * next user message. - * - * When the conversation has `tailTurns` or fewer turns, `head` is empty: there - * is nothing to compact (the caller should refuse). - */ -export function selectHeadTail<T extends ChatMessage>( - messages: T[], - tailTurns: number = DEFAULT_TAIL_TURNS, -): HeadTailSelection<T> { - if (tailTurns <= 0) return { head: messages, tail: [] }; - const userIndices: number[] = []; - for (let i = 0; i < messages.length; i++) { - if (messages[i]?.role === "user") userIndices.push(i); - } - if (userIndices.length <= tailTurns) return { head: [], tail: messages }; - const tailStart = userIndices[userIndices.length - tailTurns]; - if (tailStart === undefined || tailStart <= 0) return { head: [], tail: messages }; - return { head: messages.slice(0, tailStart), tail: messages.slice(tailStart) }; -} - -/** Cap a tool result to `max` chars with a truncation marker. */ -function capToolOutput(result: string, max: number): string { - if (result.length <= max) return result; - const omitted = result.length - max; - return `${result.slice(0, max)}\n…[${omitted} chars truncated for summary]`; -} - -/** - * Render conversation messages into a compact, provider-agnostic plain-text - * transcript suitable as summary-request context. Tool results are capped at - * `toolOutputMaxChars` (opencode's `TOOL_OUTPUT_MAX_CHARS`), and a seeded prior - * summary message is skipped (its content is carried by the prompt anchor). - * Thinking/error/system chunks are omitted as summary noise. - */ -export function renderTranscript( - messages: ChatMessage[], - toolOutputMaxChars: number = TOOL_OUTPUT_MAX_CHARS, -): string { - const blocks: string[] = []; - for (const message of messages) { - // Skip a seeded prior-summary user turn — it's represented via the anchor. - if (message.role === "user") { - const t = firstText(message); - if (t?.startsWith(SUMMARY_MARKER)) continue; - } - - const lines: string[] = []; - for (const chunk of message.chunks) { - if (chunk.type === "text") { - const t = chunk.text.trim(); - if (t) lines.push(t); - } else if (chunk.type === "tool-batch") { - for (const call of chunk.calls) { - let args = ""; - try { - args = JSON.stringify(call.arguments ?? {}); - } catch { - args = "{}"; - } - lines.push(`[tool ${call.name} ${args}]`); - if (call.result !== undefined) { - const tag = call.isError ? "tool-error" : "tool-result"; - lines.push(`[${tag}] ${capToolOutput(call.result, toolOutputMaxChars)}`); - } - } - } - } - if (lines.length === 0) continue; - const role = - message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : "System"; - blocks.push(`## ${role}\n${lines.join("\n")}`); - } - return blocks.join("\n\n"); -} - -export interface CompactionRequest<T extends ChatMessage = ChatMessage> { - /** Messages selected for summarization (older head). */ - head: T[]; - /** Recent messages preserved verbatim (last N turns). */ - tail: T[]; - /** Prior summary anchored on, if the conversation was compacted before. */ - previousSummary?: string; - /** - * The full user-message content for the summary request: rendered head - * transcript followed by the compaction prompt/template. `undefined` when - * there is nothing to compact (`head` empty). - */ - prompt?: string; -} - -/** - * Assemble everything needed to run a compaction: head/tail split, prior-summary - * extraction, and the combined summary-request prompt. Returns `prompt: - * undefined` when the conversation is too short to compact. - */ -export function buildCompactionRequest<T extends ChatMessage>(input: { - messages: T[]; - tailTurns?: number; - toolOutputMaxChars?: number; -}): CompactionRequest<T> { - const tailTurns = input.tailTurns ?? DEFAULT_TAIL_TURNS; - const toolMax = input.toolOutputMaxChars ?? TOOL_OUTPUT_MAX_CHARS; - const { head, tail } = selectHeadTail(input.messages, tailTurns); - const previousSummary = extractPreviousSummary(input.messages); - if (head.length === 0) { - return { head, tail, previousSummary }; - } - const transcript = renderTranscript(head, toolMax); - const instruction = buildCompactionPrompt({ previousSummary }); - const prompt = `${transcript}\n\n${instruction}`; - return { head, tail, previousSummary, prompt }; -} - -/** - * Wrap a generated summary as the seeded user-turn text for the continuation - * conversation. Prefixed with {@link SUMMARY_MARKER} so a later compaction can - * anchor on it. - */ -export function buildSummaryTurnText(summary: string): string { - return `${SUMMARY_MARKER}\n\n${summary.trim()}`; -} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts deleted file mode 100644 index 7f76dd7..0000000 --- a/packages/core/src/config/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - configToRuleset, - getGlobalConfigPath, - loadConfig, - loadGlobalConfig, - mergeConfigs, -} from "./loader.js"; -export { validateConfig } from "./schema.js"; -export { createConfigWatcher, watchDirConfig } from "./watcher.js"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts deleted file mode 100644 index 66f798b..0000000 --- a/packages/core/src/config/loader.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { parse } from "smol-toml"; -import type { PermissionRule, Ruleset } from "../permission/index.js"; -import type { DispatchConfig, KeyDefinition, LspServerConfig } from "../types/index.js"; -import { validateConfig } from "./schema.js"; - -const DEFAULT_CONFIG: DispatchConfig = { permissions: {} }; - -const VALID_ACTIONS = new Set(["allow", "deny", "ask"]); - -function validateAction(raw: string): "allow" | "deny" | "ask" { - if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask"; - console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`); - return "ask"; -} - -/** - * Absolute path to the HOME-directory (global) `dispatch.toml`. - * - * Follows the same `~/.config/dispatch/` convention as global agents - * (`~/.config/dispatch/agents`). This file is OPTIONAL; when present its - * contents are merged underneath every project/working-directory config so - * machine-wide settings (e.g. globally available LSP servers) work in any - * repository without per-repo configuration. - * - * The path can be overridden with the `DISPATCH_GLOBAL_CONFIG` environment - * variable, which is primarily useful for tests (point it at a temp file) but - * also lets a user relocate the global config. - */ -export function getGlobalConfigPath(): string { - return ( - process.env.DISPATCH_GLOBAL_CONFIG ?? join(homedir(), ".config", "dispatch", "dispatch.toml") - ); -} - -// Parse + validate a single dispatch.toml. Returns null when the file does not -// exist. Re-throws TOML parse errors so a corrupt LOCAL config surfaces loudly -// (callers that must stay resilient, e.g. the global loader, catch it). -function readConfigFile(tomlPath: string): DispatchConfig | null { - let raw: unknown; - try { - const content = readFileSync(tomlPath, "utf-8"); - raw = parse(content); - } catch (err: unknown) { - if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") { - // File doesn't exist — signal "no config here". - return null; - } - console.warn( - `dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`, - ); - throw err; - } - - const { config, errors } = validateConfig(raw); - for (const e of errors) { - console.warn(`dispatch: config warning at ${e.path}: ${e.message}`); - } - return config; -} - -/** - * Load the HOME-directory global `dispatch.toml` (see {@link getGlobalConfigPath}). - * - * Always resilient: a missing file yields the empty default and a malformed - * file is logged but downgraded to the empty default rather than thrown. A - * broken global config must never break config loading for every repository on - * the machine. - */ -export function loadGlobalConfig(): DispatchConfig { - try { - return readConfigFile(getGlobalConfigPath()) ?? DEFAULT_CONFIG; - } catch (err) { - console.warn( - `dispatch: ignoring global config due to parse error: ${err instanceof Error ? err.message : String(err)}`, - ); - return DEFAULT_CONFIG; - } -} - -/** - * Load the effective config for `dir`: the global config MERGED with the - * project/working-directory `dispatch.toml`, where the LOCAL config takes - * precedence on conflicts (see {@link mergeConfigs}). A missing local file - * yields the global config as-is; a missing global file yields the local - * config as-is. - * - * Note: a malformed LOCAL config still throws (callers may surface it), while a - * malformed GLOBAL config is downgraded to empty by {@link loadGlobalConfig}. - */ -export function loadConfig(dir: string): DispatchConfig { - const global = loadGlobalConfig(); - const local = readConfigFile(join(dir, "dispatch.toml")); - if (local === null) return global; - return mergeConfigs(global, local); -} - -// ─── Merge ─────────────────────────────────────────────────────── - -/** - * Merge two permission blocks. Local takes precedence on conflicts. - * - * - A key present only in one side is carried over verbatim. - * - A key whose value is a string on either side: local replaces global. - * - A key that is a nested `{ pattern -> action }` object on BOTH sides is - * merged pattern-by-pattern: global patterns the local block does NOT also - * define come first (original order), then EVERY local pattern is appended - * last (overriding any same-named global pattern). - * - * Emitting all local patterns after the global ones is essential, not - * cosmetic: `configToRuleset` flattens patterns in iteration order and - * `evaluate` uses `findLast` (last match wins). If an overridden pattern were - * updated in place, a more-general global pattern (e.g. "*") could remain AFTER - * it and silently shadow the local override. Appending local patterns last - * reproduces a clean "global rules then local rules" concatenation so local - * always wins. - */ -function mergePermissions( - global: DispatchConfig["permissions"], - local: DispatchConfig["permissions"], -): DispatchConfig["permissions"] { - const result: DispatchConfig["permissions"] = {}; - for (const [key, value] of Object.entries(global)) { - result[key] = value; - } - for (const [key, value] of Object.entries(local)) { - const existing = result[key]; - if (existing !== undefined && typeof existing !== "string" && typeof value !== "string") { - // Both nested objects — merge patterns so that ALL local patterns - // are emitted AFTER the global ones. This matters because - // `configToRuleset` flattens patterns in insertion order and - // `evaluate` uses `findLast` (last match wins): a naive - // `{ ...existing, ...value }` would update an overridden pattern - // IN PLACE, leaving a more-general global pattern (e.g. "*") sitting - // AFTER it and silently shadowing the local override. We therefore - // drop any global pattern that the local block also defines, keep the - // remaining global patterns in their original order, then append every - // local pattern last — reproducing a clean "global rules then local - // rules" concatenation where local always wins. - const merged: Record<string, string> = {}; - for (const [pattern, action] of Object.entries(existing)) { - if (!(pattern in value)) merged[pattern] = action; - } - for (const [pattern, action] of Object.entries(value)) { - merged[pattern] = action; - } - result[key] = merged; - } else { - // Local string, brand-new key, or a string/object type mismatch: - // local replaces global wholesale. - result[key] = value; - } - } - return result; -} - -/** - * Merge two key lists by `id`. Local keys override global keys sharing the same - * id; non-conflicting ids from both lists survive. Global keys keep their - * relative order (overridden in place) followed by local-only keys. - */ -function mergeKeys(global: KeyDefinition[], local: KeyDefinition[]): KeyDefinition[] { - const byId = new Map<string, KeyDefinition>(); - for (const key of global) byId.set(key.id, key); - for (const key of local) byId.set(key.id, key); - return Array.from(byId.values()); -} - -/** - * Merge two `[lsp]` blocks by server id. Local servers override global servers - * sharing the same id; non-conflicting ids from both sides remain active. This - * is what lets a global config provide LSP servers to every repository while a - * project can still override or add its own. - */ -function mergeLsp( - global: Record<string, LspServerConfig>, - local: Record<string, LspServerConfig>, -): Record<string, LspServerConfig> { - return { ...global, ...local }; -} - -/** - * Deep-merge a `global` config with a `local` (project/working-directory) - * config, with LOCAL taking precedence on every conflict. Pure function — does - * not touch the filesystem and never mutates its inputs. - */ -export function mergeConfigs(global: DispatchConfig, local: DispatchConfig): DispatchConfig { - const merged: DispatchConfig = { - permissions: mergePermissions(global.permissions, local.permissions), - }; - - if (global.keys !== undefined || local.keys !== undefined) { - merged.keys = mergeKeys(global.keys ?? [], local.keys ?? []); - } - - if (global.lsp !== undefined || local.lsp !== undefined) { - merged.lsp = mergeLsp(global.lsp ?? {}, local.lsp ?? {}); - } - - return merged; -} - -// Convert the config's permission block to a Ruleset -export function configToRuleset(config: DispatchConfig): Ruleset { - const home = homedir(); - const rules: PermissionRule[] = []; - - for (const [permission, value] of Object.entries(config.permissions)) { - if (typeof value === "string") { - const action = validateAction(value); - rules.push({ permission, pattern: "*", action }); - } else { - for (const [rawPattern, rawAction] of Object.entries(value)) { - const pattern = rawPattern - .replace(/^\$HOME(?=[/\\]|$)/, home) - .replace(/^~(?=[/\\]|$)/, home); - const action = validateAction(rawAction); - rules.push({ permission, pattern, action }); - } - } - } - - return rules; -} diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts deleted file mode 100644 index 304ee10..0000000 --- a/packages/core/src/config/schema.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { - ConfigError, - DispatchConfig, - KeyDefinition, - LspServerConfig, -} from "../types/index.js"; - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isStringRecord(value: unknown): value is Record<string, string> { - if (!isRecord(value)) return false; - return Object.values(value).every((v) => typeof v === "string"); -} - -function isValidAction(value: string): boolean { - return value === "allow" || value === "deny" || value === "ask"; -} - -function isPermissionsValue(value: unknown): value is string | Record<string, string> { - return typeof value === "string" || isStringRecord(value); -} - -function validatePermissions( - raw: unknown, - path: string, - errors: ConfigError[], -): Record<string, string | Record<string, string>> { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return {}; - } - const result: Record<string, string | Record<string, string>> = {}; - for (const [key, value] of Object.entries(raw)) { - if (!isPermissionsValue(value)) { - errors.push({ - path: `${path}.${key}`, - message: "must be a string or a flat string-keyed object", - }); - continue; - } - if (typeof value === "string") { - if (!isValidAction(value)) { - errors.push({ - path: `${path}.${key}`, - message: `invalid action "${value}"; must be "allow", "deny", or "ask"`, - }); - continue; - } - } else { - let hasError = false; - for (const [pattern, action] of Object.entries(value)) { - if (!isValidAction(action)) { - errors.push({ - path: `${path}.${key}.${pattern}`, - message: `invalid action "${action}"; must be "allow", "deny", or "ask"`, - }); - hasError = true; - } - } - if (hasError) continue; - } - result[key] = value; - } - return result; -} - -function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return null; - } - if (typeof raw.id !== "string") { - errors.push({ path: `${path}.id`, message: "must be a string" }); - return null; - } - if (typeof raw.provider !== "string") { - errors.push({ path: `${path}.provider`, message: "must be a string" }); - return null; - } - if (typeof raw.base_url !== "string") { - errors.push({ path: `${path}.base_url`, message: "must be a string" }); - return null; - } - - // "anthropic" provider uses credentials_file instead of env - if (raw.provider === "anthropic") { - return { - id: raw.id as string, - provider: raw.provider as string, - base_url: raw.base_url as string, - ...(typeof raw.credentials_file === "string" - ? ({ credentials_file: raw.credentials_file } as Pick<KeyDefinition, "credentials_file">) - : {}), - }; - } - - // Other providers: env is optional (keys can be stored in DB) - return { - id: raw.id as string, - provider: raw.provider as string, - base_url: raw.base_url as string, - ...(typeof raw.env === "string" ? { env: raw.env } : {}), - }; -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((v) => typeof v === "string"); -} - -function validateLspServer( - raw: unknown, - path: string, - errors: ConfigError[], -): LspServerConfig | null { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return null; - } - - const disabled = raw.disabled === true; - - // `command` is required and must be a non-empty string array unless the - // entry is explicitly disabled (a disabled entry is skipped wholesale). - if (!disabled) { - if (!isStringArray(raw.command) || raw.command.length === 0) { - errors.push({ - path: `${path}.command`, - message: "must be a non-empty array of strings", - }); - return null; - } - // `extensions` is required for custom servers — without it the client - // cannot know which files should activate the server. - if (!isStringArray(raw.extensions) || raw.extensions.length === 0) { - errors.push({ - path: `${path}.extensions`, - message: 'must be a non-empty array of strings (e.g. [".luau"])', - }); - return null; - } - } else { - // Disabled entries still must not carry a malformed command/extensions - // if present, but we do not require them. - if (raw.command !== undefined && !isStringArray(raw.command)) { - errors.push({ path: `${path}.command`, message: "must be an array of strings" }); - return null; - } - if (raw.extensions !== undefined && !isStringArray(raw.extensions)) { - errors.push({ path: `${path}.extensions`, message: "must be an array of strings" }); - return null; - } - } - - if (raw.env !== undefined && !isStringRecord(raw.env)) { - errors.push({ - path: `${path}.env`, - message: "must be a flat string-keyed object", - }); - return null; - } - - if (raw.initialization !== undefined && !isRecord(raw.initialization)) { - errors.push({ - path: `${path}.initialization`, - message: "must be an object", - }); - return null; - } - - const server: LspServerConfig = { - command: (raw.command as string[] | undefined) ?? [], - extensions: (raw.extensions as string[] | undefined) ?? [], - ...(isStringRecord(raw.env) ? { env: raw.env } : {}), - ...(isRecord(raw.initialization) - ? { initialization: raw.initialization as Record<string, unknown> } - : {}), - ...(disabled ? { disabled: true } : {}), - }; - return server; -} - -function validateLsp( - raw: unknown, - path: string, - errors: ConfigError[], -): Record<string, LspServerConfig> | undefined { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return undefined; - } - const result: Record<string, LspServerConfig> = {}; - for (const [id, value] of Object.entries(raw)) { - const server = validateLspServer(value, `${path}.${id}`, errors); - if (server) result[id] = server; - } - return Object.keys(result).length > 0 ? result : undefined; -} - -export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } { - const errors: ConfigError[] = []; - - if (!isRecord(raw)) { - errors.push({ path: "", message: "config must be an object" }); - return { config: { permissions: {} }, errors }; - } - - // permissions (required, but can be empty) - const permissions = validatePermissions(raw.permissions ?? {}, "permissions", errors); - - // keys (optional) - let keys: KeyDefinition[] | undefined; - if (raw.keys !== undefined) { - if (!Array.isArray(raw.keys)) { - errors.push({ path: "keys", message: "must be an array" }); - } else { - keys = []; - for (let i = 0; i < raw.keys.length; i++) { - const key = validateKey(raw.keys[i], `keys[${i}]`, errors); - if (key) keys.push(key); - } - } - } - - // lsp (optional) - let lsp: Record<string, LspServerConfig> | undefined; - if (raw.lsp !== undefined) { - lsp = validateLsp(raw.lsp, "lsp", errors); - } - - const config: DispatchConfig = { - permissions, - ...(keys !== undefined && { keys }), - ...(lsp !== undefined && { lsp }), - }; - - return { config, errors }; -} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts deleted file mode 100644 index ad55804..0000000 --- a/packages/core/src/config/watcher.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { join } from "node:path"; -import { watch } from "chokidar"; -import type { DispatchConfig } from "../types/index.js"; -import { getGlobalConfigPath, loadConfig } from "./loader.js"; - -/** - * Watch BOTH the HOME-directory global `dispatch.toml` and the project/working- - * directory `dispatch.toml`. Either file changing triggers a reload that - * re-merges global + local (via {@link loadConfig}), so hot-reload works for - * global defaults and per-project overrides alike. - * - * When the global and local paths coincide (e.g. the working directory IS - * `~/.config/dispatch`, or `DISPATCH_GLOBAL_CONFIG` points at the local file) - * the duplicate is collapsed so chokidar only watches it once. - */ -export function createConfigWatcher( - dir: string, - onChange: (config: DispatchConfig) => void, -): { close(): void } { - const localPath = join(dir, "dispatch.toml"); - const globalPath = getGlobalConfigPath(); - const paths = globalPath === localPath ? [localPath] : [globalPath, localPath]; - let debounceTimer: ReturnType<typeof setTimeout> | null = null; - - const watcher = watch(paths, { - ignoreInitial: true, - persistent: false, - }); - - const handleChange = () => { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - } - debounceTimer = setTimeout(() => { - debounceTimer = null; - console.log(`dispatch: reloading config (global + ${localPath})`); - try { - const config = loadConfig(dir); - onChange(config); - } catch (err) { - console.warn( - `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }, 300); - }; - - watcher.on("change", handleChange); - watcher.on("add", handleChange); - watcher.on("unlink", handleChange); - - watcher.on("error", (err) => { - console.warn( - `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - - return { - close() { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - watcher.close().catch((err) => { - console.warn( - `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - }, - }; -} - -/** - * Watch a SINGLE directory's `dispatch.toml` (no global merge, no reload — just - * a debounced change signal). Used by the agent manager to invalidate its - * per-directory LSP cache when a tab's effective working directory is a - * SUBDIRECTORY with its own `dispatch.toml`: the main `createConfigWatcher` - * only watches the root + global configs, so without this a nested config edit - * would never clear `lspServersByDir[subdir]` and agents there would keep using - * stale LSP servers until a root-config change or restart. - * - * `onChange` fires (debounced) on add/change/unlink of `<dir>/dispatch.toml`. - */ -export function watchDirConfig(dir: string, onChange: () => void): { close(): void } { - const tomlPath = join(dir, "dispatch.toml"); - let debounceTimer: ReturnType<typeof setTimeout> | null = null; - - const watcher = watch(tomlPath, { - ignoreInitial: true, - persistent: false, - }); - - const handleChange = () => { - if (debounceTimer !== null) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = null; - try { - onChange(); - } catch (err) { - console.warn( - `dispatch: dir config watcher onChange error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }, 300); - }; - - watcher.on("change", handleChange); - watcher.on("add", handleChange); - watcher.on("unlink", handleChange); - watcher.on("error", (err) => { - console.warn( - `dispatch: dir config watcher error: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - - return { - close() { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - watcher.close().catch((err) => { - console.warn( - `dispatch: error closing dir config watcher: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - }, - }; -} diff --git a/packages/core/src/credentials/anthropic-betas.ts b/packages/core/src/credentials/anthropic-betas.ts deleted file mode 100644 index 802ebbd..0000000 --- a/packages/core/src/credentials/anthropic-betas.ts +++ /dev/null @@ -1,24 +0,0 @@ -// ─── Anthropic Beta Headers ─────────────────────────────────── -// -// The set of `anthropic-beta` features the official Claude Code CLI sends on -// every request. Kept in a dependency-free module (no DB / `bun:sqlite` -// import) so the LLM provider layer (`llm/provider.ts`) can pull the list in -// without dragging the whole credentials/DB stack into its import graph. -// -// `prompt-caching-scope-2026-01-05` is load-bearing for cost: without it the -// Anthropic API silently ignores every `cache_control` breakpoint we place, -// producing a 0% cache hit rate (see notes/claude-report.md). `oauth-2025-04-20` -// gates the Bearer/OAuth flow used by Claude Pro/Max subscriptions. - -const BASE_BETAS = [ - "claude-code-20250219", - "oauth-2025-04-20", - "interleaved-thinking-2025-05-14", - "prompt-caching-scope-2026-01-05", - "context-management-2025-06-27", - "advisor-tool-2026-03-01", -]; - -export function getAnthropicBetas(): string[] { - return [...BASE_BETAS]; -} diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts deleted file mode 100644 index ef30400..0000000 --- a/packages/core/src/credentials/api-keys.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { getDatabase } from "../db/index.js"; - -export interface StoredApiKey { - keyId: string; - provider: string; - apiKey: string; - importedAt: number; - updatedAt: number; -} - -/** - * Store or update an API key in the database. - */ -export function setApiKey(keyId: string, provider: string, apiKey: string): void { - const db = getDatabase(); - const now = Date.now(); - db.query( - `INSERT INTO api_keys (key_id, provider, api_key, imported_at, updated_at) - VALUES ($keyId, $provider, $apiKey, $now, $now) - ON CONFLICT(key_id) DO UPDATE SET - api_key = $apiKey, - updated_at = $now`, - ).run({ - $keyId: keyId, - $provider: provider, - $apiKey: apiKey, - $now: now, - }); -} - -/** - * Get a stored API key by key ID. Returns the key string or null. - */ -export function getApiKey(keyId: string): string | null { - const db = getDatabase(); - const row = db - .query("SELECT api_key FROM api_keys WHERE key_id = $keyId") - .get({ $keyId: keyId }) as { api_key: string } | null; - return row?.api_key ?? null; -} - -/** - * Resolve an API key from the database, with env var fallback. - * Pass the env var name (e.g. "GOOGLE_API_KEY") to check process.env as well. - */ -export function resolveApiKey(keyId: string, envVar?: string): string | null { - const dbKey = getApiKey(keyId); - if (dbKey) return dbKey; - if (envVar) return process.env[envVar] ?? null; - return null; -} - -/** - * Delete a stored API key. - */ -export function deleteApiKey(keyId: string): void { - const db = getDatabase(); - db.query("DELETE FROM api_keys WHERE key_id = $keyId").run({ $keyId: keyId }); -} - -/** - * List all stored API keys with metadata (key value excluded for security). - */ -export function listApiKeys(): Array<{ - keyId: string; - provider: string; - importedAt: number; - updatedAt: number; -}> { - const db = getDatabase(); - const rows = db - .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id") - .all() as Array<Record<string, unknown>>; - return rows.map((row) => ({ - keyId: row.key_id as string, - provider: row.provider as string, - importedAt: row.imported_at as number, - updatedAt: row.updated_at as number, - })); -} diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts deleted file mode 100644 index 050a0fc..0000000 --- a/packages/core/src/credentials/claude.ts +++ /dev/null @@ -1,694 +0,0 @@ -import { createHash } from "node:crypto"; -import { - chmodSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import { getDatabase } from "../db/index.js"; -import { getAnthropicBetas } from "./anthropic-betas.js"; -import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js"; - -// Re-exported for backward compatibility — `getAnthropicBetas` historically -// lived here and is surfaced through `credentials/index.ts`. The definition -// now lives in the dependency-free `anthropic-betas.ts` module. -export { getAnthropicBetas }; - -export interface ClaudeCredentials { - accessToken: string; - refreshToken: string; - expiresAt: number; - subscriptionType?: string; -} - -export interface ClaudeAccount { - id: string; - label: string; - source: string; - credentials: ClaudeCredentials; -} - -const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token"; -const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const CREDENTIAL_CACHE_TTL_MS = 30_000; - -const CREDENTIALS_DIR = join(homedir(), ".claude"); -const PRIMARY_CREDENTIALS_FILE = join(CREDENTIALS_DIR, ".credentials.json"); - -const accountCacheMap = new Map<string, { creds: ClaudeCredentials; cachedAt: number }>(); - -function parseCredentialsFile(raw: string): ClaudeCredentials | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - - const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed; - const creds = data as Record<string, unknown>; - - if ( - (creds as Record<string, unknown>).mcpOAuth && - !(creds as Record<string, unknown>).accessToken - ) { - return null; - } - - if ( - typeof creds.accessToken !== "string" || - typeof creds.refreshToken !== "string" || - typeof creds.expiresAt !== "number" - ) { - return null; - } - - return { - accessToken: creds.accessToken as string, - refreshToken: creds.refreshToken as string, - expiresAt: creds.expiresAt as number, - subscriptionType: - typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, - }; -} - -function readCredentialsFile(filePath: string): ClaudeCredentials | null { - try { - if (!existsSync(filePath)) return null; - const raw = readFileSync(filePath, "utf-8").trim(); - if (!raw) return null; - return parseCredentialsFile(raw); - } catch { - return null; - } -} - -function writeCredentialsFile(filePath: string, creds: ClaudeCredentials): void { - let existing: Record<string, unknown> = {}; - try { - if (existsSync(filePath)) { - const raw = readFileSync(filePath, "utf-8").trim(); - if (raw) { - existing = JSON.parse(raw); - } - } - } catch { - existing = {}; - } - - const hasWrapper = "claudeAiOauth" in existing; - const target = hasWrapper ? (existing.claudeAiOauth as Record<string, unknown>) : existing; - target.accessToken = creds.accessToken; - target.refreshToken = creds.refreshToken; - target.expiresAt = creds.expiresAt; - if (creds.subscriptionType) { - target.subscriptionType = creds.subscriptionType; - } - - const dir = dirname(filePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: "utf-8", mode: 0o600 }); - if (process.platform !== "win32") { - chmodSync(filePath, 0o600); - } -} - -async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials | null> { - const body = new URLSearchParams({ - grant_type: "refresh_token", - client_id: OAUTH_CLIENT_ID, - refresh_token: refreshToken, - }); - - try { - const response = await fetch(OAUTH_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - if (!response.ok) { - return null; - } - - const data = (await response.json()) as Record<string, unknown>; - if (!data.access_token || typeof data.access_token !== "string") { - return null; - } - - return { - accessToken: data.access_token as string, - refreshToken: (data.refresh_token as string) ?? refreshToken, - expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000, - subscriptionType: - typeof data.subscriptionType === "string" ? data.subscriptionType : undefined, - }; - } catch { - return null; - } -} - -function buildAccountLabels(accounts: ClaudeAccount[]): void { - for (const acct of accounts) { - acct.label = acct.credentials.subscriptionType - ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}` - : "Claude"; - } -} - -/** - * Load Claude accounts from the SQLite database. - * Returns accounts for all stored anthropic credentials. - * This is the preferred path — file-based discovery is the fallback. - */ -export function getClaudeAccountsFromDB(): ClaudeAccount[] { - const stored = listStoredCredentials(); - const accounts: ClaudeAccount[] = []; - - for (const cred of stored) { - if (cred.provider !== "anthropic") continue; - accounts.push({ - id: cred.keyId, - label: "", - source: `db:${cred.keyId}`, - credentials: { - accessToken: cred.accessToken, - refreshToken: cred.refreshToken, - expiresAt: cred.expiresAt, - subscriptionType: cred.subscriptionType ?? undefined, - }, - }); - } - - buildAccountLabels(accounts); - return accounts; -} - -export function discoverClaudeAccounts(): ClaudeAccount[] { - const accounts: ClaudeAccount[] = []; - - if (!existsSync(CREDENTIALS_DIR)) { - return accounts; - } - - const primaryCreds = readCredentialsFile(PRIMARY_CREDENTIALS_FILE); - if (primaryCreds) { - accounts.push({ - id: "claude-default", - label: "", - source: PRIMARY_CREDENTIALS_FILE, - credentials: primaryCreds, - }); - } - - try { - const files = readdirSync(CREDENTIALS_DIR); - const credFiles = files.filter( - (f) => f.startsWith(".credentials") && f.endsWith(".json") && f !== ".credentials.json", - ); - for (const file of credFiles) { - const filePath = join(CREDENTIALS_DIR, file); - const creds = readCredentialsFile(filePath); - if (creds) { - const id = basename(file, ".json").replace(/^\.credentials/, "claude") || `claude-${file}`; - accounts.push({ - id, - label: "", - source: filePath, - credentials: creds, - }); - } - } - } catch { - // ignore - } - - buildAccountLabels(accounts); - return accounts; -} - -export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null { - const cached = accountCacheMap.get(account.id); - const now = Date.now(); - if ( - cached && - now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && - cached.creds.expiresAt > now + 60_000 - ) { - return cached.creds; - } - - // Re-read credentials: from DB for DB-backed accounts, from file otherwise - if (account.source.startsWith("db:")) { - const stored = getStoredCredentials(account.id); - if (stored) { - account.credentials = { - accessToken: stored.accessToken, - refreshToken: stored.refreshToken, - expiresAt: stored.expiresAt, - subscriptionType: stored.subscriptionType ?? undefined, - }; - } - } else { - const onDisk = readCredentialsFile(account.source); - if (onDisk) { - account.credentials = onDisk; - } - } - - if (account.credentials.expiresAt > now + 60_000) { - accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now }); - return account.credentials; - } - - // Try OAuth refresh - if (account.credentials.refreshToken) { - // Synchronous refresh not available in this context, but the async version will be used - // by getCredentialsForAccount below - return null; - } - - return null; -} - -export async function refreshAccountCredentialsAsync( - account: ClaudeAccount, -): Promise<ClaudeCredentials | null> { - const cached = accountCacheMap.get(account.id); - const now = Date.now(); - if ( - cached && - now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && - cached.creds.expiresAt > now + 60_000 - ) { - return cached.creds; - } - - // Re-read credentials: from DB for DB-backed accounts, from file otherwise - if (account.source.startsWith("db:")) { - const stored = getStoredCredentials(account.id); - if (stored) { - account.credentials = { - accessToken: stored.accessToken, - refreshToken: stored.refreshToken, - expiresAt: stored.expiresAt, - subscriptionType: stored.subscriptionType ?? undefined, - }; - } - } else { - const onDisk = readCredentialsFile(account.source); - if (onDisk) { - account.credentials = onDisk; - } - } - - if (account.credentials.expiresAt > now + 60_000) { - accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now }); - return account.credentials; - } - - // Try OAuth refresh - if (account.credentials.refreshToken) { - const refreshed = await refreshViaOAuth(account.credentials.refreshToken); - if (refreshed && refreshed.expiresAt > now + 60_000) { - account.credentials = refreshed; - // Update DB if this is a DB-backed account, otherwise write to file - if (account.source.startsWith("db:")) { - updateStoredTokens( - account.id, - refreshed.accessToken, - refreshed.refreshToken, - refreshed.expiresAt, - ); - } else { - writeCredentialsFile(account.source, refreshed); - } - accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now }); - return refreshed; - } - } - - return null; -} - -// ─── Billing Header Computation ──────────────────────────────── - -const BILLING_SALT = "59cf53e54c78"; -const CC_VERSION = "2.1.112"; - -function extractFirstUserMessageText(messages: Array<{ role: string; content: string }>): string { - const userMsg = messages.find((m) => m.role === "user"); - if (!userMsg) return ""; - if (typeof userMsg.content === "string") return userMsg.content; - return ""; -} - -function computeCch(messageText: string): string { - return createHash("sha256").update(messageText).digest("hex").slice(0, 5); -} - -function computeVersionSuffix(messageText: string, version: string): string { - const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join(""); - const input = `${BILLING_SALT}${sampled}${version}`; - return createHash("sha256").update(input).digest("hex").slice(0, 3); -} - -export function buildBillingHeaderValue( - messages: Array<{ role: string; content: string }>, -): string { - const text = extractFirstUserMessageText(messages); - const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION; - const suffix = computeVersionSuffix(text, version); - const cch = computeCch(text); - return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=sdk-cli; cch=${cch};`; -} - -export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; - -/** - * Build the request body for a Claude "wake" probe — a tiny, cheap message - * whose only purpose is to keep the subscription's rate-limit window warm. - * - * This MUST mirror the shape of a genuine Claude Code CLI request, because - * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated, - * Claude-Code-billed requests. A bare `{ model, messages }` body (no system - * identity) is rejected (401/403) — which is exactly how the old probe silently - * failed. The valid shape is: - * - * system: [ - * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control - * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, - * ] - * messages: [ { role: "user", content: "hi" } ] - * - * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user - * turn. Pure: deterministic given its inputs (the billing header samples only - * the user text), so it can be unit-tested without touching the network. - */ -export function buildWakeProbeBody(model: string): { - model: string; - max_tokens: number; - system: Array<{ type: "text"; text: string }>; - messages: Array<{ role: "user"; content: string }>; -} { - const messages = [{ role: "user" as const, content: "hi" }]; - return { - model, - max_tokens: 16, - system: [ - { type: "text", text: buildBillingHeaderValue(messages) }, - { type: "text", text: SYSTEM_IDENTITY }, - ], - messages, - }; -} - -// ─── Anthropic Request Headers ──────────────────────────────── - -export function getAnthropicHeaders(accessToken: string): Record<string, string> { - return { - authorization: `Bearer ${accessToken}`, - "anthropic-version": "2023-06-01", - "anthropic-beta": getAnthropicBetas().join(","), - "anthropic-dangerous-direct-browser-access": "true", - "x-app": "cli", - "user-agent": `claude-cli/${CC_VERSION} (external, sdk-cli)`, - }; -} - -// ─── Usage Tracking ─────────────────────────────────────────── - -export interface ClaudeUsageBucket { - utilization?: number; - resetsAt?: number; -} - -export interface ClaudeUsageReport { - fiveHour?: ClaudeUsageBucket; - sevenDay?: ClaudeUsageBucket; - sevenDayOpus?: ClaudeUsageBucket; - sevenDaySonnet?: ClaudeUsageBucket; - accountId?: string; - email?: string; - orgId?: string; -} - -/** - * A usage report paired with provenance: whether it came back from a fresh - * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served - * from the local `usage_cache` table after a failed/skipped live fetch. - * - * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when - * that cached payload was last fetched FROM the source (the `usage_cache.cached_at` - * column). `source: "live"` omits `cachedAt` (the data is current as of now). - */ -export interface ClaudeUsageResult { - report: ClaudeUsageReport; - source: "live" | "cache"; - /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */ - cachedAt?: number; -} - -// ─── Well-known Anthropic models ────────────────────────────── - -/** - * Fetch the live list of available models from Anthropic's /v1/models endpoint. - * Requires valid OAuth credentials with anthropic-beta headers. - */ -export async function fetchAnthropicModels(accessToken: string): Promise<string[]> { - const headers: Record<string, string> = { - ...getAnthropicHeaders(accessToken), - accept: "application/json", - }; - - try { - const response = await fetch("https://api.anthropic.com/v1/models", { headers }); - if (!response.ok) { - console.warn(`dispatch: Anthropic /v1/models returned ${response.status}`); - return []; - } - - const data = (await response.json()) as { - data?: Array<{ id: string }>; - models?: Array<{ id: string }>; - }; - const entries = data.data ?? data.models ?? []; - return entries.map((m) => m.id).filter(Boolean); - } catch (err) { - console.warn( - `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`, - ); - return []; - } -} - -/** Fallback list if /v1/models is unreachable. */ -export const ANTHROPIC_MODELS_FALLBACK = [ - "claude-sonnet-4-20250514", - "claude-opus-4-20250514", - "claude-3.5-sonnet-20241022", - "claude-3.5-haiku-20241022", - "claude-3-opus-20240229", -]; - -/** - * Pick the model to use for a Claude "wake" probe from a list of model ids. - * - * The probe only needs a small/cheap model to register activity against the - * subscription, so we target Haiku. Model ids change over time (the old - * hardcoded `claude-3-5-haiku-20241022` started returning HTTP 404), so the - * caller fetches the live list from `/v1/models` and we resolve by substring. - * - * Selection: the FIRST id whose name contains "haiku" (case-insensitive). - * Anthropic's `/v1/models` returns models newest-first, so first-match - * naturally prefers the newest Haiku. Returns `null` when nothing matches so - * the caller can surface a clear error instead of probing an invalid model. - */ -export function selectHaikuModel(models: string[]): string | null { - return models.find((id) => id.toLowerCase().includes("haiku")) ?? null; -} - -// ─── Credential Validation ──────────────────────────────────── - -export interface ClaudeProfile { - accountId?: string; - email?: string; - subscriptionType?: string; -} - -/** - * Validate that Claude credentials are usable by hitting the OAuth profile endpoint. - * Returns the profile info if valid, or null if the token is dead. - */ -export async function validateAccountCredentials( - account: ClaudeAccount, -): Promise<ClaudeProfile | null> { - const creds = await refreshAccountCredentialsAsync(account); - if (!creds) return null; - - const url = "https://api.anthropic.com/api/oauth/profile"; - const headers: Record<string, string> = { - ...getAnthropicHeaders(creds.accessToken), - accept: "application/json, text/plain, */*", - }; - - try { - const response = await fetch(url, { headers }); - if (!response.ok) return null; - - const data = (await response.json()) as Record<string, unknown>; - - const profile: ClaudeProfile = {}; - const uuid = typeof data.uuid === "string" ? data.uuid : undefined; - const email = typeof data.email === "string" ? data.email : undefined; - - if (uuid) profile.accountId = uuid; - if (email) profile.email = email; - - // subscriptionType comes from the credentials file, but profile may also carry it - profile.subscriptionType = account.credentials.subscriptionType; - - return profile; - } catch { - return null; - } -} - -async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport | null> { - const url = "https://api.anthropic.com/api/oauth/usage"; - const headers: Record<string, string> = { - ...getAnthropicHeaders(accessToken), - accept: "application/json, text/plain, */*", - "content-type": "application/json", - }; - - try { - const response = await fetch(url, { headers }); - if (!response.ok) return null; - - const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined; - const data = (await response.json()) as Record<string, unknown>; - - const parseBucket = (bucket: unknown): ClaudeUsageBucket | undefined => { - if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined; - const b = bucket as Record<string, unknown>; - // API returns utilization as 0-100 percentage; normalize to 0-1 fraction - const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined; - const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined; - const resetsAt = - typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined; - if (utilization === undefined && resetsAt === undefined) return undefined; - return { utilization, resetsAt }; - }; - - const report: ClaudeUsageReport = { - fiveHour: parseBucket(data.five_hour), - sevenDay: parseBucket(data.seven_day), - sevenDayOpus: parseBucket(data.seven_day_opus), - sevenDaySonnet: parseBucket(data.seven_day_sonnet), - }; - - if (orgId) report.orgId = orgId; - - // Try to extract identity - const accountId = - typeof data.account_id === "string" - ? data.account_id - : typeof data.user_id === "string" - ? data.user_id - : typeof data.org_id === "string" - ? data.org_id - : undefined; - if (accountId) report.accountId = accountId; - - const email = typeof data.email === "string" ? data.email : undefined; - if (email) report.email = email; - - return report; - } catch { - return null; - } -} - -/** - * Read a cached usage report plus the epoch-ms it was last fetched from source. - * Returns `null` when there is no cached row (or on any DB/parse error). - */ -function getCachedUsageWithMeta( - keyId: string, -): { report: ClaudeUsageReport; cachedAt: number } | null { - try { - const db = getDatabase(); - const row = db - .query("SELECT report_json, cached_at FROM usage_cache WHERE key_id = $keyId") - .get({ $keyId: keyId }) as { report_json: string; cached_at: number } | null; - if (!row) return null; - return { - report: JSON.parse(row.report_json) as ClaudeUsageReport, - cachedAt: row.cached_at, - }; - } catch { - return null; - } -} - -function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageReport): void { - try { - const db = getDatabase(); - db.query( - `INSERT INTO usage_cache (key_id, provider, cached_at, report_json) - VALUES ($keyId, $provider, $cachedAt, $reportJson) - ON CONFLICT(key_id) DO UPDATE SET - cached_at = $cachedAt, - report_json = $reportJson`, - ).run({ - $keyId: keyId, - $provider: provider, - $cachedAt: Date.now(), - $reportJson: JSON.stringify(report), - }); - } catch { - // Ignore DB errors - } -} - -/** - * Fetch an account's usage report along with its provenance (live vs cache). - * - * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint; - * on success the fresh report is cached and returned as `source: "live"`. If - * credentials cannot be refreshed OR the live fetch returns nothing, fall back - * to the local `usage_cache` row and return it as `source: "cache"` with the - * `cachedAt` timestamp recording when that payload was last fetched from source. - * Returns `null` only when neither a live report nor a cached row is available. - */ -export async function getAccountUsageWithSource( - account: ClaudeAccount, -): Promise<ClaudeUsageResult | null> { - const creds = await refreshAccountCredentialsAsync(account); - if (creds) { - const report = await fetchClaudeUsage(creds.accessToken); - if (report) { - setCachedUsage(account.id, "anthropic", report); - return { report, source: "live" }; - } - } - const cached = getCachedUsageWithMeta(account.id); - if (cached) { - return { report: cached.report, source: "cache", cachedAt: cached.cachedAt }; - } - return null; -} - -export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> { - const result = await getAccountUsageWithSource(account); - return result?.report ?? null; -} diff --git a/packages/core/src/credentials/copilot.ts b/packages/core/src/credentials/copilot.ts deleted file mode 100644 index 8baf6f4..0000000 --- a/packages/core/src/credentials/copilot.ts +++ /dev/null @@ -1,64 +0,0 @@ -// ─── GitHub Copilot Usage Tracking ─────────────────────────── -// Uses the internal GitHub Copilot user endpoint (same one the -// official VS Code Copilot extension calls). - -export interface CopilotUsageReport { - tokensConsumed?: number; - tokensRemaining?: number; - percentUsed?: number; // 0-100 - resetAt?: number; // Unix timestamp ms - plan?: string; -} - -export async function fetchCopilotUsage( - token: string, - _baseUrl: string, -): Promise<CopilotUsageReport | null> { - const url = "https://api.github.com/copilot_internal/user"; - const headers: Record<string, string> = { - authorization: `Bearer ${token}`, - accept: "application/json", - }; - - try { - const response = await fetch(url, { headers }); - if (!response.ok) return null; - - const data = (await response.json()) as Record<string, unknown>; - const plan = typeof data.copilot_plan === "string" ? data.copilot_plan : undefined; - - const resetDate = typeof data.quota_reset_date === "string" ? data.quota_reset_date : undefined; - const resetAt = resetDate ? Date.parse(resetDate) : undefined; - - const qs = data.quota_snapshots as Record<string, unknown> | undefined; - const pi = qs?.premium_interactions as Record<string, unknown> | undefined; - - const entitlement = typeof pi?.entitlement === "number" ? pi.entitlement : undefined; - const remaining = typeof pi?.remaining === "number" ? pi.remaining : undefined; - const percentRemaining = - typeof pi?.percent_remaining === "number" ? pi.percent_remaining : undefined; - - if (entitlement === undefined && remaining === undefined) { - return null; - } - - const tokensConsumed = - entitlement !== undefined && remaining !== undefined ? entitlement - remaining : undefined; - const percentUsed = - percentRemaining !== undefined - ? Math.round((100 - percentRemaining) * 100) / 100 - : tokensConsumed !== undefined && entitlement !== undefined && entitlement > 0 - ? Math.round((tokensConsumed / entitlement) * 10000) / 100 - : undefined; - - return { - tokensConsumed, - tokensRemaining: remaining, - percentUsed, - resetAt: resetAt && !Number.isNaN(resetAt) ? resetAt : undefined, - plan, - }; - } catch { - return null; - } -} diff --git a/packages/core/src/credentials/google.ts b/packages/core/src/credentials/google.ts deleted file mode 100644 index 17bc930..0000000 --- a/packages/core/src/credentials/google.ts +++ /dev/null @@ -1,178 +0,0 @@ -// ─── Google Gemini Usage Tracking ─────────────────────────── -// Two modes: -// 1. API key → queries native Gemini models endpoint for rate limits -// 2. Cookie → scrapes gemini.google.com for current usage % (like OpenCode) -// -// Set GEMINI_COOKIE env var with the value of your __Secure-1PSID cookie -// from gemini.google.com to see current usage percentages. - -export interface GoogleUsageBucket { - percentUsed: number; // 0-100 - resetsAt?: string; // e.g. "22:40" or "30 May at 17:40" -} - -export interface GoogleUsageReport { - // Mode 1: API key rate limits - models?: Array<{ - name: string; - inputTokenLimit: number; - outputTokenLimit: number; - rpm: number; - requestsPerDay: number; - }>; - // Mode 2: Cookie-scraped usage from gemini.google.com - currentUsage?: GoogleUsageBucket; - weeklyUsage?: GoogleUsageBucket; -} - -// Helpers to extract HTML text between markers -function extractBetween(html: string, after: string, before: string): string | null { - const start = html.indexOf(after); - if (start === -1) return null; - const s = start + after.length; - const end = html.indexOf(before, s); - if (end === -1) return null; - return html.slice(s, end).trim(); -} - -function extractPercent(html: string, afterMarker: string): number | null { - const chunk = extractBetween(html, afterMarker, "%"); - if (!chunk) return null; - const digits = chunk.replace(/\D/g, ""); - const n = parseInt(digits, 10); - return Number.isNaN(n) ? null : n; -} - -function extractResetTime(html: string, afterMarker: string): string | null { - // Look for "Resets at HH:MM" or "Resets on DD Mon at HH:MM" - const start = html.indexOf(afterMarker); - if (start === -1) return null; - const chunk = html.slice(start + afterMarker.length); - // Match time patterns - const m = chunk.match(/Resets?\s+(at|on)\s+([^<]+)/i); - if (!m?.[1] || !m[2]) return null; - return `${m[1]} ${m[2].trim()}`; -} - -async function scrapeGeminiWeb(cookie: string): Promise<{ - currentUsage?: GoogleUsageBucket; - weeklyUsage?: GoogleUsageBucket; -} | null> { - try { - const response = await fetch("https://gemini.google.com/app", { - headers: { - cookie: `__Secure-1PSID=${cookie}`, - "accept-language": "en-US,en;q=0.9", - }, - redirect: "follow", - }); - - if (!response.ok) return null; - - const html = await response.text(); - - // Detect auth redirect - if (html.includes("ServiceLogin") || html.includes("sign in")) { - return null; - } - - // Look for usage data in the page - // Pattern: "Current usage" followed by a percentage and reset time - const currentPct = extractPercent(html, "Current usage"); - const currentReset = extractResetTime(html, "Current usage"); - - // Weekly limit section - const weeklyPct = extractPercent(html, "Weekly limit"); - const weeklyReset = extractResetTime(html, "Weekly limit"); - - const result: { - currentUsage?: GoogleUsageBucket; - weeklyUsage?: GoogleUsageBucket; - } = {}; - - if (currentPct !== null) { - result.currentUsage = { - percentUsed: currentPct, - resetsAt: currentReset ?? undefined, - }; - } - - if (weeklyPct !== null) { - result.weeklyUsage = { - percentUsed: weeklyPct, - resetsAt: weeklyReset ?? undefined, - }; - } - - if (result.currentUsage || result.weeklyUsage) return result; - return null; - } catch { - return null; - } -} - -async function fetchModelsViaApiKey(apiKey: string): Promise<Array<{ - name: string; - inputTokenLimit: number; - outputTokenLimit: number; - rpm: number; - requestsPerDay: number; -}> | null> { - const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`; - - try { - const response = await fetch(url); - if (!response.ok) return null; - - const data = (await response.json()) as { - models?: Array<{ - name: string; - inputTokenLimit?: number; - outputTokenLimit?: number; - rateLimit?: { requestsPerMinute?: number }; - limits?: { requestsPerDay?: number }; - }>; - }; - - return (data.models ?? []) - .filter((m) => { - const name = m.name?.replace(/^models\//, "") ?? ""; - return name.startsWith("gemini-"); - }) - .map((m) => ({ - name: m.name?.replace(/^models\//, "") ?? "", - inputTokenLimit: m.inputTokenLimit ?? 0, - outputTokenLimit: m.outputTokenLimit ?? 0, - rpm: m.rateLimit?.requestsPerMinute ?? 0, - requestsPerDay: m.limits?.requestsPerDay ?? 0, - })); - } catch { - return null; - } -} - -export async function fetchGoogleUsage( - apiKey: string, - _baseUrl: string, -): Promise<GoogleUsageReport | null> { - const results: GoogleUsageReport = {}; - - // Try API key mode: get model rate limits - const models = await fetchModelsViaApiKey(apiKey); - if (models) { - results.models = models; - } - - // Try cookie mode: scrape gemini.google.com usage - const cookie = process.env.GEMINI_COOKIE; - if (cookie) { - const scraped = await scrapeGeminiWeb(cookie); - if (scraped) { - if (scraped.currentUsage) results.currentUsage = scraped.currentUsage; - if (scraped.weeklyUsage) results.weeklyUsage = scraped.weeklyUsage; - } - } - - if (results.models || results.currentUsage || results.weeklyUsage) return results; - return null; -} diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts deleted file mode 100644 index 131f035..0000000 --- a/packages/core/src/credentials/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -export { - deleteApiKey, - getApiKey, - listApiKeys, - resolveApiKey, - type StoredApiKey, - setApiKey, -} from "./api-keys.js"; -export { - ANTHROPIC_MODELS_FALLBACK, - buildBillingHeaderValue, - buildWakeProbeBody, - type ClaudeAccount, - type ClaudeCredentials, - type ClaudeProfile, - type ClaudeUsageBucket, - type ClaudeUsageReport, - type ClaudeUsageResult, - discoverClaudeAccounts, - fetchAnthropicModels, - getAccountUsage, - getAccountUsageWithSource, - getAnthropicBetas, - getAnthropicHeaders, - getClaudeAccountsFromDB, - refreshAccountCredentials, - refreshAccountCredentialsAsync, - SYSTEM_IDENTITY, - selectHaikuModel, - validateAccountCredentials, -} from "./claude.js"; -export { - type CopilotUsageReport, - fetchCopilotUsage, -} from "./copilot.js"; -export { - fetchGoogleUsage, - type GoogleUsageReport, -} from "./google.js"; -export { - fetchOpencodeUsage, - type OpencodeUsageBucket, - type OpencodeUsageReport, -} from "./opencode.js"; -export { - deleteStoredCredentials, - getStoredCredentials, - importCredentialsFromFile, - listStoredCredentials, - type StoredCredential, - updateStoredTokens, -} from "./store.js"; diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts deleted file mode 100644 index d4d4851..0000000 --- a/packages/core/src/credentials/opencode.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { resolveApiKey } from "./api-keys.js"; - -// ─── OpenCode Usage Tracking ────────────────────────────────── -// OpenCode has no public usage API. We scrape usage from the -// SolidStart SSR-rendered workspace page using a session cookie. -// Requires OPENCODE_COOKIE env var. -// Workspace IDs: OPENCODE_WS1_ID for opencode-1, OPENCODE_WS2_ID for opencode-2. - -export interface OpencodeUsageBucket { - utilization?: number; // 0-1 fraction - resetsAt?: number; // Unix timestamp ms -} - -export interface OpencodeUsageReport { - fiveHour?: OpencodeUsageBucket; - weekly?: OpencodeUsageBucket; - monthly?: OpencodeUsageBucket; -} - -function getWorkspaceId(keyId: string): string | undefined { - // Check DB for workspace ID: stored as "opencode-ws1", "opencode-ws2", or "opencode-ws" - const match = keyId.match(/opencode-(\d+)$/i); - if (match) { - const num = match[1]; - const specific = resolveApiKey(`opencode-ws${num}`); - if (specific) return specific; - } - return resolveApiKey("opencode-ws") ?? undefined; -} - -function parseOcDouble(html: string, key: string): number { - const idx = html.indexOf(`${key}:`); - if (idx === -1) return 0; - let start = idx + key.length + 1; - while (start < html.length && html[start] === " ") start++; - let end = start; - while (end < html.length && html[end] !== "," && html[end] !== "}") { - end++; - } - const val = parseFloat(html.slice(start, end)); - return Number.isNaN(val) ? 0 : val; -} - -function parseOcInt(html: string, key: string): number { - const idx = html.indexOf(`${key}:`); - if (idx === -1) return 0; - let i = idx + key.length + 1; - while (i < html.length && html[i] === " ") i++; - return parseInt(html.slice(i), 10) || 0; -} - -function parseOcBucket( - html: string, - bucketName: string, -): { utilization: number; resetsAt: number } | null { - const search = `${bucketName}:`; - const pos = html.indexOf(search); - if (pos === -1) return null; - - // Find the opening brace after the bucket name - const brace = html.indexOf("{", pos); - if (brace === -1) return null; - - const resetSecs = parseOcInt(html.slice(brace), "resetInSec"); - const usagePct = parseOcDouble(html.slice(brace), "usagePercent"); - - const utilization = usagePct / 100; // convert 0-100% to 0-1 fraction - const resetsAt = Date.now() + resetSecs * 1000; - - return { utilization, resetsAt }; -} - -export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> { - const cookie = resolveApiKey("opencode-cookie"); - const wsId = getWorkspaceId(keyId); - - if (!cookie || !wsId) { - return null; - } - - const url = `https://opencode.ai/workspace/${encodeURIComponent(wsId)}/go`; - - try { - const response = await fetch(url, { - headers: { - accept: "text/html", - cookie: `auth=${cookie}`, - }, - redirect: "follow", - }); - - if (!response.ok) return null; - - const html = await response.text(); - - // Auth redirect check - if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) { - return null; - } - - // Find the lite.subscription data block. - // HTML contains: lite.subscription.get[\"<wsId>\"] - // We need literal backslashes; use \x5c (hex for backslash). - const wsKey = `lite.subscription.get[\x5c"${wsId}\x5c"]`; - const wsPos = html.indexOf(wsKey); - if (wsPos === -1) return null; - - // Search for the resolved data starting from the ws key position - const minePos = html.indexOf("mine:", wsPos); - const slice = minePos !== -1 ? html.slice(minePos) : ""; - - const fiveHour = parseOcBucket(slice, "rollingUsage"); - const weekly = parseOcBucket(slice, "weeklyUsage"); - const monthly = parseOcBucket(slice, "monthlyUsage"); - - if (!fiveHour && !weekly && !monthly) return null; - - return { - fiveHour: fiveHour ?? undefined, - weekly: weekly ?? undefined, - monthly: monthly ?? undefined, - }; - } catch { - return null; - } -} diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts deleted file mode 100644 index 662b322..0000000 --- a/packages/core/src/credentials/store.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { getDatabase } from "../db/index.js"; -import type { ClaudeCredentials } from "./claude.js"; - -export interface StoredCredential { - keyId: string; - provider: string; - accessToken: string; - refreshToken: string; - expiresAt: number; - subscriptionType: string | null; - sourceFile: string | null; - importedAt: number; - updatedAt: number; -} - -function parseCredentialsFile(raw: string): ClaudeCredentials | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - - const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed; - const creds = data as Record<string, unknown>; - - if (creds.mcpOAuth && !creds.accessToken) return null; - - if ( - typeof creds.accessToken !== "string" || - typeof creds.refreshToken !== "string" || - typeof creds.expiresAt !== "number" - ) { - return null; - } - - return { - accessToken: creds.accessToken as string, - refreshToken: creds.refreshToken as string, - expiresAt: creds.expiresAt as number, - subscriptionType: - typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, - }; -} - -/** - * Import credentials from a file into the database for a specific key. - * Reads the credential file, parses it, and upserts into the credentials table. - */ -export function importCredentialsFromFile( - keyId: string, - provider: string, - filePath: string, -): { success: boolean; error?: string } { - if (!existsSync(filePath)) { - return { success: false, error: `File not found: ${filePath}` }; - } - - let raw: string; - try { - raw = readFileSync(filePath, "utf-8").trim(); - } catch (e) { - return { - success: false, - error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`, - }; - } - - if (!raw) { - return { success: false, error: "File is empty" }; - } - - const creds = parseCredentialsFile(raw); - if (!creds) { - return { success: false, error: "Invalid credentials format" }; - } - - const db = getDatabase(); - const now = Date.now(); - - db.query( - `INSERT INTO credentials (key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at) - VALUES ($keyId, $provider, $accessToken, $refreshToken, $expiresAt, $subscriptionType, $sourceFile, $now, $now) - ON CONFLICT(key_id) DO UPDATE SET - access_token = $accessToken, - refresh_token = $refreshToken, - expires_at = $expiresAt, - subscription_type = $subscriptionType, - source_file = $sourceFile, - updated_at = $now`, - ).run({ - $keyId: keyId, - $provider: provider, - $accessToken: creds.accessToken, - $refreshToken: creds.refreshToken, - $expiresAt: creds.expiresAt, - $subscriptionType: creds.subscriptionType ?? null, - $sourceFile: filePath, - $now: now, - }); - - return { success: true }; -} - -/** - * Get stored credentials for a specific key from the database. - */ -export function getStoredCredentials(keyId: string): StoredCredential | null { - const db = getDatabase(); - const row = db - .query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId", - ) - .get({ $keyId: keyId }) as Record<string, unknown> | null; - - if (!row) return null; - - return { - keyId: row.key_id as string, - provider: row.provider as string, - accessToken: row.access_token as string, - refreshToken: row.refresh_token as string, - expiresAt: row.expires_at as number, - subscriptionType: row.subscription_type as string | null, - sourceFile: row.source_file as string | null, - importedAt: row.imported_at as number, - updatedAt: row.updated_at as number, - }; -} - -/** - * Update tokens in the database after a refresh. - */ -export function updateStoredTokens( - keyId: string, - accessToken: string, - refreshToken: string, - expiresAt: number, -): void { - const db = getDatabase(); - db.query( - `UPDATE credentials SET access_token = $accessToken, refresh_token = $refreshToken, expires_at = $expiresAt, updated_at = $now WHERE key_id = $keyId`, - ).run({ - $keyId: keyId, - $accessToken: accessToken, - $refreshToken: refreshToken, - $expiresAt: expiresAt, - $now: Date.now(), - }); -} - -/** - * Delete stored credentials for a key. - */ -export function deleteStoredCredentials(keyId: string): void { - const db = getDatabase(); - db.query("DELETE FROM credentials WHERE key_id = $keyId").run({ $keyId: keyId }); -} - -/** - * List all keys that have imported credentials, with their status. - */ -export function listStoredCredentials(): StoredCredential[] { - const db = getDatabase(); - const rows = db - .query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id", - ) - .all() as Array<Record<string, unknown>>; - - return rows.map((row) => ({ - keyId: row.key_id as string, - provider: row.provider as string, - accessToken: row.access_token as string, - refreshToken: row.refresh_token as string, - expiresAt: row.expires_at as number, - subscriptionType: row.subscription_type as string | null, - sourceFile: row.source_file as string | null, - importedAt: row.imported_at as number, - updatedAt: row.updated_at as number, - })); -} diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts deleted file mode 100644 index b434a47..0000000 --- a/packages/core/src/db/chunks.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { - explodeTurn, - explodeUserText, - groupRowsToMessages, - type MessageRow, -} from "../chunks/transform.js"; -import type { - ChunkData, - ChunkRow, - ChunkRowDraft, - TextData, - UsageData, - UsageStats, -} from "../types/index.js"; -import { getDatabase } from "./index.js"; - -// Re-export the DB-free transforms so existing barrel consumers -// (`@dispatch/core`) keep importing them from here. The browser frontend deep- -// imports them directly from `chunks/transform.js` to avoid the DB dependency. -export { explodeTurn, explodeUserText, groupRowsToMessages, type MessageRow }; - -// ─── Persistence ───────────────────────────────────────────────── - -function mapRow(row: Record<string, unknown>): ChunkRow { - let data: ChunkData; - try { - data = JSON.parse(row.data_json as string) as ChunkData; - } catch { - data = { text: "" } as TextData; - } - return { - id: row.id as string, - tabId: row.tab_id as string, - seq: row.seq as number, - turnId: row.turn_id as string, - step: row.step as number, - role: row.role as ChunkRow["role"], - type: row.type as ChunkRow["type"], - data, - createdAt: row.created_at as number, - }; -} - -/** - * Append one or more chunk-row drafts to a tab, assigning a monotonic per-tab - * `seq` and a fresh id/timestamp to each. Returns the inserted rows in order. - */ -export function appendChunks(tabId: string, drafts: ChunkRowDraft[]): ChunkRow[] { - if (drafts.length === 0) return []; - const db = getDatabase(); - const maxSeq = db - .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") - .get({ $tabId: tabId }) as { max_seq: number }; - let seq = (maxSeq?.max_seq ?? -1) + 1; - const now = Date.now(); - const insert = db.query( - `INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) - VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)`, - ); - const out: ChunkRow[] = []; - // Wrap the whole batch in one transaction: a turn's chunks are persisted in - // a single `appendChunks` call, so this is one fsync per turn instead of one - // per row — the chosen low-IO write strategy for constrained backends. - const insertAll = db.transaction(() => { - for (const draft of drafts) { - const id = randomUUID(); - insert.run({ - $id: id, - $tabId: tabId, - $seq: seq, - $turnId: draft.turnId, - $step: draft.step, - $role: draft.role, - $type: draft.type, - $dataJson: JSON.stringify(draft.data), - $now: now, - }); - out.push({ - id, - tabId, - seq, - turnId: draft.turnId, - step: draft.step, - role: draft.role, - type: draft.type, - data: draft.data, - createdAt: now, - }); - seq++; - } - }); - insertAll(); - return out; -} - -/** - * Read chunk rows for a tab in `seq` order (ASC). Pagination mirrors the old - * message pagination but at chunk granularity: - * - no options → all rows; - * - `before` → rows with `seq < before`, most-recent-first then reversed; - * - `limit` → most recent `limit` rows, reversed to ASC. - */ -export function getChunksForTab( - tabId: string, - options?: { limit?: number; before?: number }, -): ChunkRow[] { - const db = getDatabase(); - if (!options) { - const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC") - .all({ $tabId: tabId }) as Array<Record<string, unknown>>; - return rows.map(mapRow); - } - const { limit, before } = options; - if (before !== undefined) { - if (limit !== undefined) { - const rows = db - .query( - "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit", - ) - .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>; - return rows.map(mapRow).reverse(); - } - const rows = db - .query( - "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC", - ) - .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>; - return rows.map(mapRow).reverse(); - } - if (limit !== undefined) { - const rows = db - .query( - "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit", - ) - .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>; - return rows.map(mapRow).reverse(); - } - const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC") - .all({ $tabId: tabId }) as Array<Record<string, unknown>>; - return rows.map(mapRow); -} - -/** - * Derived, grouped view of a tab's full history as messages. Used to - * pre-populate the agent's in-memory `ChatMessage[]` history when an Agent is - * (re)constructed. Always reads the full log (grouping a partial window would - * be lossy for the rebuild path). - */ -export function getMessagesForTab(tabId: string): MessageRow[] { - return groupRowsToMessages(getChunksForTab(tabId)); -} - -export function getTotalChunkCount(tabId: string): number { - const db = getDatabase(); - const row = db - .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'") - .get({ $tabId: tabId }) as { count: number } | null; - return row?.count ?? 0; -} - -/** - * Aggregate per-tab token/cache usage across ALL persisted `usage` chunk rows. - * - * Usage rows are written as an invisible side channel (one row per `usage` - * AgentEvent) and are query-excluded from `getChunksForTab`/`getTotalChunkCount`, - * so this aggregate is the read path. Because it sums server-side over every - * row, it stays complete even after the frontend evicts/pages out old turns - * (eviction is in-memory only). The return shape is structurally identical to - * the frontend `CacheStats`, so reload can seed it directly. - * - * - cumulative `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens` - * = SUM over all usage rows; - * - `requests` = COUNT of usage rows; - * - `last` = the highest-seq usage row's split (most recent request); - * - `null` when the tab has no usage rows. - * - * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on - * `json_extract` over the freeform `data_json`. - */ -export function getUsageStatsForTab(tabId: string): UsageStats | null { - const db = getDatabase(); - const rows = db - .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC") - .all({ $tabId: tabId }) as Array<{ data_json: string }>; - if (rows.length === 0) return null; - - let inputTokens = 0; - let outputTokens = 0; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; - let last: UsageData | null = null; - for (const row of rows) { - let u: UsageData; - try { - u = JSON.parse(row.data_json) as UsageData; - } catch { - continue; - } - inputTokens += u.inputTokens ?? 0; - outputTokens += u.outputTokens ?? 0; - cacheReadTokens += u.cacheReadTokens ?? 0; - cacheWriteTokens += u.cacheWriteTokens ?? 0; - last = { - inputTokens: u.inputTokens ?? 0, - outputTokens: u.outputTokens ?? 0, - cacheReadTokens: u.cacheReadTokens ?? 0, - cacheWriteTokens: u.cacheWriteTokens ?? 0, - }; - } - - return { - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - requests: rows.length, - last, - }; -} - -export function clearChunksForTab(tabId: string): void { - const db = getDatabase(); - db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId }); -} - -/** - * Relocate every chunk row from one tab to another (compaction backup path). - * - * Used by conversation compaction to move the FULL pre-compaction history off - * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id - * (`toTabId`), leaving the canonical id free to be re-seeded with the summary + - * preserved tail. `seq` values are preserved (they remain per-tab monotonic for - * the destination since it starts empty), as are turn ids, so the relocated - * history groups identically under its new tab. Returns the number of rows - * moved. - */ -export function rekeyChunks(fromTabId: string, toTabId: string): number { - const db = getDatabase(); - const result = db - .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from") - .run({ $from: fromTabId, $to: toTabId }); - return Number(result.changes ?? 0); -} diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts deleted file mode 100644 index 93ec1f9..0000000 --- a/packages/core/src/db/index.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Database } from "bun:sqlite"; -import { existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { isAbsolute, join } from "node:path"; - -/** - * Returns the directory for persistent Dispatch data, following XDG Base - * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to - * `~/.local/share/dispatch`). - */ -function getDataDir(): string { - const xdg = process.env.XDG_DATA_HOME; - const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share"); - return join(base, "dispatch"); -} - -let _db: Database | null = null; - -/** - * Get (or create) the singleton SQLite database. - * - * - Creates the data directory if it doesn't exist. - * - Creates `dispatch.db` if it doesn't exist. - * - Enables WAL journal mode for concurrent read performance. - */ -export function getDatabase(): Database { - if (_db) return _db; - - const dir = getDataDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const dbPath = join(dir, "dispatch.db"); - _db = new Database(dbPath, { create: true }); - - // WAL mode: better concurrent read performance, safe for single-writer - _db.run("PRAGMA journal_mode = WAL;"); - // Recommended for WAL: normal synchronous is safe and faster - _db.run("PRAGMA synchronous = NORMAL;"); - // Enable foreign keys - _db.run("PRAGMA foreign_keys = ON;"); - - // Create tables - _db.run(`CREATE TABLE IF NOT EXISTS credentials ( - key_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expires_at INTEGER NOT NULL, - subscription_type TEXT, - source_file TEXT, - imported_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`); - - // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe - // slot). The PK is (hour, slot_minute). Destructive migration off the legacy - // single-row-per-hour schema: detect by absence of the `slot_minute` column - // and drop the old table. Other tables (credentials, api_keys, usage_cache, - // settings, tabs, chunks) are NOT touched. - const legacyWakeSchema = (() => { - try { - const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>; - if (cols.length === 0) return false; // table doesn't exist yet - return !cols.some((c) => c.name === "slot_minute"); - } catch { - return false; - } - })(); - if (legacyWakeSchema) { - _db.run("DROP TABLE IF EXISTS wake_schedule"); - } - _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule ( - hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23), - slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)), - next_wake_at INTEGER NOT NULL, - PRIMARY KEY (hour, slot_minute) - )`); - - _db.run(`CREATE TABLE IF NOT EXISTS usage_cache ( - key_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - cached_at INTEGER NOT NULL, - report_json TEXT NOT NULL - )`); - - _db.run(`CREATE TABLE IF NOT EXISTS api_keys ( - key_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - api_key TEXT NOT NULL, - imported_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`); - - _db.run(`CREATE TABLE IF NOT EXISTS tabs ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - key_id TEXT, - model_id TEXT, - parent_tab_id TEXT, - status TEXT NOT NULL DEFAULT 'idle', - is_open INTEGER NOT NULL DEFAULT 1, - position INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`); - - try { - _db.run("ALTER TABLE tabs ADD COLUMN parent_tab_id TEXT"); - } catch { - // Column already exists — ignore - } - - // ─── Append-only chunk log (replaces the old `messages` blob table) ── - // - // A conversation is stored as a flat, append-only stream of chunk rows - // keyed by a per-tab monotonic `seq`. "Message" and "turn" are DERIVED - // groupings (see db/chunks.ts), never stored containers. This is what - // powers per-chunk frontend pagination AND the stable per-step wire - // format that fixes Anthropic prompt-cache churn (see notes/plan-chunk-log.md). - // - // role : 'user' | 'assistant' | 'tool' | 'system' - // type : 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'error' | 'system' - // step : LLM round-trip index within a turn (user/system rows = 0) - // data_json: the type-specific payload (see ChunkData in types) - _db.run(`CREATE TABLE IF NOT EXISTS chunks ( - id TEXT PRIMARY KEY, - tab_id TEXT NOT NULL, - seq INTEGER NOT NULL, - turn_id TEXT NOT NULL, - step INTEGER NOT NULL DEFAULT 0, - role TEXT NOT NULL, - type TEXT NOT NULL, - data_json TEXT NOT NULL, - created_at INTEGER NOT NULL - )`); - - _db.run(`CREATE INDEX IF NOT EXISTS idx_chunks_tab_seq ON chunks(tab_id, seq)`); - - // One-shot migration off the legacy `messages` blob model. Beta software, - // no backward compatibility: the old chat history is destroyed (tabs + - // messages), while settings / credentials / api_keys / usage_cache / - // wake_schedule are preserved. Detect the old schema by the presence of - // the `messages` table; once dropped, this branch never runs again. - const hasLegacyMessages = _db - .query("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'") - .get() as { name: string } | null; - if (hasLegacyMessages) { - _db.run("DROP TABLE IF EXISTS messages"); - // Clear conversation containers too (fresh slate for the new model). - _db.run("DELETE FROM tabs"); - _db.run("DELETE FROM chunks"); - } - - _db.run(`CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - )`); - - return _db; -} - -/** Close the database connection (e.g. on shutdown). */ -export function closeDatabase(): void { - if (_db) { - _db.close(); - _db = null; - } -} - -/** Returns the path where the database file lives (or will live). */ -export function getDatabasePath(): string { - if (_db) return _db.filename; - const dir = getDataDir(); - return join(dir, "dispatch.db"); -} diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts deleted file mode 100644 index f9d152e..0000000 --- a/packages/core/src/db/settings.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getDatabase } from "./index.js"; - -export function getSetting(key: string): string | null { - const db = getDatabase(); - const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { - value: string; - } | null; - return row?.value ?? null; -} - -export function setSetting(key: string, value: string): void { - const db = getDatabase(); - db.query( - `INSERT INTO settings (key, value) VALUES ($key, $value) - ON CONFLICT(key) DO UPDATE SET value = $value`, - ).run({ $key: key, $value: value }); -} - -export function deleteSetting(key: string): void { - const db = getDatabase(); - db.query("DELETE FROM settings WHERE key = $key").run({ $key: key }); -} diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts deleted file mode 100644 index f719a01..0000000 --- a/packages/core/src/db/tabs.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { getDatabase } from "./index.js"; - -export interface TabRow { - id: string; - title: string; - keyId: string | null; - modelId: string | null; - parentTabId: string | null; - status: string; - isOpen: boolean; - position: number; - createdAt: number; - updatedAt: number; -} - -function rowToTab(row: Record<string, unknown>): TabRow { - return { - id: row.id as string, - title: row.title as string, - keyId: row.key_id as string | null, - modelId: row.model_id as string | null, - parentTabId: (row.parent_tab_id as string) ?? null, - status: row.status as string, - isOpen: (row.is_open as number) === 1, - position: row.position as number, - createdAt: row.created_at as number, - updatedAt: row.updated_at as number, - }; -} - -export function createTab( - id: string, - title: string, - options?: { keyId?: string | null; modelId?: string | null; parentTabId?: string | null }, -): TabRow { - const db = getDatabase(); - const now = Date.now(); - const maxPos = db - .query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1") - .get() as { max_pos: number }; - const position = (maxPos?.max_pos ?? -1) + 1; - const keyId = options?.keyId ?? null; - const modelId = options?.modelId ?? null; - const parentTabId = options?.parentTabId ?? null; - db.query( - `INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at) - VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)`, - ).run({ - $id: id, - $title: title, - $keyId: keyId, - $modelId: modelId, - $parentTabId: parentTabId, - $position: position, - $now: now, - }); - return { - id, - title, - keyId, - modelId, - parentTabId, - status: "idle", - isOpen: true, - position, - createdAt: now, - updatedAt: now, - }; -} - -export function getTab(id: string): TabRow | null { - const db = getDatabase(); - const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record< - string, - unknown - > | null; - return row ? rowToTab(row) : null; -} - -export function listOpenTabs(): TabRow[] { - const db = getDatabase(); - const rows = db - .query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") - .all() as Array<Record<string, unknown>>; - return rows.map(rowToTab); -} - -export function updateTabTitle(id: string, title: string): void { - const db = getDatabase(); - db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({ - $id: id, - $title: title, - $now: Date.now(), - }); -} - -export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void { - const db = getDatabase(); - db.query( - "UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id", - ).run({ - $id: id, - $keyId: keyId, - $modelId: modelId, - $now: Date.now(), - }); -} - -export function updateTabStatus(id: string, status: string): void { - const db = getDatabase(); - db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({ - $id: id, - $status: status, - $now: Date.now(), - }); -} - -export function updateTabPositions(idsInOrder: string[]): void { - const db = getDatabase(); - const now = Date.now(); - const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id"); - // One transaction so a reorder is atomic: either every tab lands at its new - // slot or none does, never a half-applied ordering. - const applyAll = db.transaction(() => { - idsInOrder.forEach((id, index) => { - update.run({ $id: id, $position: index, $now: now }); - }); - }); - applyAll(); -} - -export function archiveTab(id: string): void { - const db = getDatabase(); - db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ - $id: id, - $now: Date.now(), - }); -} - -/** - * Return the IDs of `rootId` plus every OPEN descendant tab, in leaf-first - * order (children before their parent). Archived descendants - * (`is_open = 0`) and their sub-trees are skipped — closing a parent - * shouldn't drag archived branches back into view. - * - * The starting `rootId` is always included in the result, even if no row - * with that id exists in the `tabs` table (graceful handling for stale - * references). - * - * Order matters for the cascade-close path: callers archive descendants - * leaf-first so foreign-key cleanup (messages, etc.) doesn't fail on - * partially-deleted parents. - * - * Cycle-safe: a `visited` set guards against accidental `parent_tab_id` - * loops that would otherwise spin forever. - */ -export function getDescendantIds(rootId: string): string[] { - const db = getDatabase(); - const visited = new Set<string>(); - const order: string[] = []; - const queue: string[] = [rootId]; - while (queue.length > 0) { - const id = queue.shift() as string; - if (visited.has(id)) continue; - visited.add(id); - order.push(id); - const children = db - .query("SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1") - .all({ $id: id }) as Array<{ id: string }>; - for (const child of children) { - if (!visited.has(child.id)) queue.push(child.id); - } - } - return order.reverse(); -} - -/** - * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`. - * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything - * shorter is rejected as too broad — an agent must echo at least the 4-char - * handle shown in the UI. - */ -export const MIN_TAB_PREFIX_LENGTH = 4; - -/** - * Outcome of resolving a short tab handle (a git-style prefix of a tab's - * UUID) back to a concrete open tab. - * - * - `ok` — exactly one open tab matched; `tab` is it. - * - `none` — no open tab matched (bad/stale handle, or too-short prefix). - * - `ambiguous` — more than one open tab shares the prefix; `matches` lists - * them so the caller can ask for one more character (the same - * UX as `git checkout <ambiguous-sha>`). - */ -export type ResolveTabPrefixResult = - | { status: "ok"; tab: TabRow } - | { status: "none" } - | { status: "ambiguous"; matches: TabRow[] }; - -/** - * Resolve a short tab handle to a single OPEN tab by prefix match — the - * git-short-hash model. The handle is NEVER stored: it is always derived from - * (and matched against) the canonical lowercase UUID in `tabs.id`. - * - * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and - * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We - * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also - * ASCII-case-insensitive by default) and strip everything outside the UUID - * alphabet `[0-9a-f-]` so no wildcard can survive into the query. - * - * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns - * `none` rather than matching a large swath of tabs. - * - * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix - * must not cause phantom ambiguity or resolve to a dead conversation. - */ -export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult { - const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, ""); - if (sanitized.length < MIN_TAB_PREFIX_LENGTH) { - return { status: "none" }; - } - const db = getDatabase(); - const rows = db - .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC") - .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>; - if (rows.length === 0) return { status: "none" }; - if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) }; - return { status: "ambiguous", matches: rows.map(rowToTab) }; -} - -/** - * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars) - * that identifies `tabId` among the currently OPEN tabs — the backend twin of - * the frontend's display helper. Used when a tool needs to echo a tab's own - * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a - * value from the wire. - * - * Returns the full id if no shorter unique prefix exists (degenerate — only if - * two open tabs share an entire id, which UUID uniqueness precludes). - */ -export function shortestUniquePrefix(tabId: string): string { - const db = getDatabase(); - const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>; - const others = rows.map((r) => r.id).filter((id) => id !== tabId); - for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) { - const candidate = tabId.slice(0, len); - if (!others.some((id) => id.startsWith(candidate))) return candidate; - } - return tabId; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts deleted file mode 100644 index e951d08..0000000 --- a/packages/core/src/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -// @dispatch/core — Agent runtime, LLM integration, tools - -// Agent & LLM -export { Agent } from "./agent/agent.js"; -export { - deleteAgent, - expandAgentToolNames, - GLOBAL_AGENTS_DIR, - getAgentDirPaths, - getAgentDirs, - getProjectAgentsDir, - loadAgent, - loadAgents, - saveAgent, -} from "./agents/index.js"; -// Chunk helpers -export { - appendEventToChunks, - applySystemEvent, - type IdentifiedMessage, - type SystemEventLike, -} from "./chunks/append.js"; -// Compaction -export { - buildCompactionPrompt, - buildCompactionRequest, - buildSummaryTurnText, - type CompactionRequest, - DEFAULT_TAIL_TURNS, - extractPreviousSummary, - type HeadTailSelection, - renderTranscript, - SUMMARY_MARKER, - SUMMARY_TEMPLATE, - selectHeadTail, - TOOL_OUTPUT_MAX_CHARS, -} from "./compaction/index.js"; -// Config -export { - configToRuleset, - createConfigWatcher, - getGlobalConfigPath, - loadConfig, - loadGlobalConfig, - mergeConfigs, - validateConfig, - watchDirConfig, -} from "./config/index.js"; -// Credentials -export * from "./credentials/index.js"; -export { - appendChunks, - clearChunksForTab, - explodeTurn, - explodeUserText, - getChunksForTab, - getMessagesForTab, - getTotalChunkCount, - getUsageStatsForTab, - groupRowsToMessages, - type MessageRow, - rekeyChunks, -} from "./db/chunks.js"; -// Database -export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js"; -export { deleteSetting, getSetting, setSetting } from "./db/settings.js"; -// Tabs & Messages -export { - archiveTab, - createTab, - getTab, - listOpenTabs, - MIN_TAB_PREFIX_LENGTH, - type ResolveTabPrefixResult, - resolveTabPrefix, - shortestUniquePrefix, - type TabRow, - updateTabModel, - updateTabPositions, - updateTabStatus, - updateTabTitle, -} from "./db/tabs.js"; -export { - debugVerbosity, - isDebugEnabled, - logAgentLoop, - logStepLifecycle, - logStreamEvent, -} from "./llm/debug-logger.js"; -export { createProvider } from "./llm/provider.js"; -// LSP (Language Server Protocol) -export { - createLspClient, - type Diagnostic as LspDiagnostic, - type LspClient, - LspManager, - type LspServerHandle, - pretty as prettyDiagnostic, - type ResolvedLspServer, - report as reportDiagnostics, - resolveServersFromConfig, -} from "./lsp/index.js"; -// Models -export { - ACCEPTED_ATTACHMENT_MEDIA_TYPES, - ACCEPTED_IMAGE_MEDIA_TYPES, - ACCEPTED_PDF_MEDIA_TYPE, - type AttachmentValidationError, - type AttachmentValidationResult, - base64ByteLength, - getModelsCatalog, - hasAttachments, - isAcceptedAttachmentMediaType, - isImageMediaType, - isPdfMediaType, - MAX_ATTACHMENTS, - MAX_IMAGE_BYTES, - MAX_PDF_BYTES, - MAX_TOTAL_ATTACHMENT_BYTES, - type ModelInputCapabilities, - ModelRegistry, - resolveContextLimit, - resolveModelCapabilities, - validateUserContent, -} from "./models/index.js"; -// Notifications (ntfy.sh) -export * from "./notifications/index.js"; -export * from "./permission/index.js"; -// Skills -export { - createSkillsWatcher, - getSkillByName, - loadSkills, - parseSkillFile, - resolveSkillsForAgent, -} from "./skills/index.js"; -export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; -// Tools -export { createKeyUsageTool, type KeyUsageCallbacks } from "./tools/key-usage.js"; -export { createListFilesTool } from "./tools/list-files.js"; -export { createLspTool, type LspToolContext } from "./tools/lsp.js"; -export { createReadFileTool } from "./tools/read-file.js"; -export { createReadFileSliceTool } from "./tools/read-file-slice.js"; -export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js"; -export { createToolRegistry } from "./tools/registry.js"; -export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js"; -export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js"; -export { createSearchCodeTool } from "./tools/search-code.js"; -export { - createSendToTabTool, - type ResolvedTabRef, - type SendToTabCallbacks, - type TabResolution, -} from "./tools/send-to-tab.js"; -export { analyzeCommand } from "./tools/shell-analyze.js"; -export { - type AvailableAgent, - createSummonTool, - type SummonCallbacks, - toAvailableSubagents, - toAvailableUserAgents, -} from "./tools/summon.js"; -export { createTaskListTool, TaskList, TODO_DESCRIPTION } from "./tools/task-list.js"; -export { clearSpillForTab } from "./tools/truncate.js"; -export { createWebSearchTool } from "./tools/web-search.js"; -export { type AfterWriteHook, createWriteFileTool } from "./tools/write-file.js"; -export { - BackgroundTranscriptStore, - createYoutubeTranscribeTool, -} from "./tools/youtube-transcribe.js"; -// Types & Permissions -export * from "./types/index.js"; diff --git a/packages/core/src/llm/anthropic-oauth-transform.ts b/packages/core/src/llm/anthropic-oauth-transform.ts deleted file mode 100644 index 467a307..0000000 --- a/packages/core/src/llm/anthropic-oauth-transform.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Wire-level request restructuring for the Claude OAuth (Pro/Max) flow. - * - * Anthropic validates the `system` array on OAuth-authenticated, Claude-Code- - * billed requests. A genuine Claude Code request looks like: - * - * system: [ - * { type: "text", text: "x-anthropic-billing-header: ..." }, // system[0], NO cache_control - * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.", - * cache_control: { type: "ephemeral" } }, // identity, separate block - * ] - * messages: [ { role: "user", content: "<the real system prompt>\n\n<user text>" }, ... ] - * - * i.e. ONLY the billing header and the verbatim identity string may live in - * `system[]`. Any third-party system prompt (Dispatch's tool/agent instructions) - * MUST be relocated into the first user message. When third-party content stays - * in `system[]` next to the identity, Anthropic bills it as premium "extra - * usage" (token burn) and refuses to apply the Claude Code prompt-cache scope — - * producing the 0% cache hit rate and ballooning cost we were seeing. - * - * Dispatch builds its system prompt as ONE concatenated block - * (`<billing>\n<identity>\n\n<systemPrompt>`); `@ai-sdk/anthropic` serializes - * that into a single `system[]` text entry. This transform runs at fetch time - * on the already-serialized JSON body and reshapes it into the structure above. - * - * Mirrors `references/opencode-claude-auth/src/transforms.ts` (`transformBody`), - * adapted to Dispatch: tool names are already PascalCase-`mcp_`-prefixed by the - * agent, so this transform leaves tools and messages (other than the relocation) - * untouched. It is defensive: any parse/shape surprise returns the body - * unchanged so a transform bug can never break a request. - */ - -const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; -const BILLING_PREFIX = "x-anthropic-billing-header"; - -type SystemBlock = { type: "text"; text: string; cache_control?: unknown } & Record< - string, - unknown ->; - -interface AnthropicRequestBody { - system?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>; - messages?: Array<{ - role?: string; - content?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>; - }>; - [key: string]: unknown; -} - -/** - * Restructure a serialized Anthropic request body string for the Claude Code - * OAuth flow. Returns the (possibly rewritten) body, or the original input - * unchanged when it isn't a JSON string we recognize. - */ -export function transformClaudeOAuthBody( - body: BodyInit | null | undefined, -): BodyInit | null | undefined { - if (typeof body !== "string") return body; - let parsed: AnthropicRequestBody; - try { - parsed = JSON.parse(body) as AnthropicRequestBody; - } catch { - return body; - } - if (!parsed || typeof parsed !== "object") return body; - try { - const changed = restructureSystem(parsed); - return changed ? JSON.stringify(parsed) : body; - } catch { - // Never let a transform bug break a real request. - return body; - } -} - -/** - * In-place restructure of `parsed.system` / `parsed.messages`. Returns true if - * anything changed (so the caller knows to re-serialize). - */ -function restructureSystem(parsed: AnthropicRequestBody): boolean { - const raw = parsed.system; - let entries: SystemBlock[]; - if (typeof raw === "string") { - entries = [{ type: "text", text: raw }]; - } else if (Array.isArray(raw)) { - entries = raw.map((e) => - typeof e === "string" - ? { type: "text", text: e } - : ({ ...e, type: "text", text: typeof e.text === "string" ? e.text : "" } as SystemBlock), - ); - } else { - return false; // no system field — nothing to do - } - - const combined = entries.map((e) => e.text).join("\n\n"); - - // Only act on Claude Code shaped requests (must carry the identity string). - if (!combined.includes(SYSTEM_IDENTITY)) return false; - - const hadCacheControl = entries.some((e) => e.cache_control != null); - - // Peel the billing-header line out (it is a single line with no newlines). - const lines = combined.split("\n"); - const billingIdx = lines.findIndex((l) => l.startsWith(BILLING_PREFIX)); - let billingLine: string | null = null; - if (billingIdx !== -1) { - billingLine = lines[billingIdx] ?? null; - lines.splice(billingIdx, 1); - } - const afterBilling = lines.join("\n").replace(/^\n+/, ""); - - // Split the identity prefix from the rest (Dispatch's real system prompt). - let rest = ""; - if (afterBilling.startsWith(SYSTEM_IDENTITY)) { - rest = afterBilling.slice(SYSTEM_IDENTITY.length).replace(/^\n+/, ""); - } else { - // Identity is present but not at the front (unexpected) — still isolate it. - rest = afterBilling.replace(SYSTEM_IDENTITY, "").replace(/^\n+/, ""); - } - - // Rebuild system[]: billing (no cache_control) then identity (cached). - const newSystem: SystemBlock[] = []; - if (billingLine) newSystem.push({ type: "text", text: billingLine }); - const identityBlock: SystemBlock = { type: "text", text: SYSTEM_IDENTITY }; - if (hadCacheControl) identityBlock.cache_control = { type: "ephemeral" }; - newSystem.push(identityBlock); - - // Relocate the third-party system prompt into the first user message. - if (rest.length > 0) { - const firstUser = Array.isArray(parsed.messages) - ? parsed.messages.find((m) => m.role === "user") - : undefined; - if (firstUser) { - if (typeof firstUser.content === "string") { - firstUser.content = `${rest}\n\n${firstUser.content}`; - } else if (Array.isArray(firstUser.content)) { - firstUser.content.unshift({ type: "text", text: rest }); - } else { - firstUser.content = rest; - } - } else { - // No user message to host it — keep it as a (cached) system block so - // the request still carries the instructions. - const restBlock: SystemBlock = { type: "text", text: rest }; - if (hadCacheControl) restBlock.cache_control = { type: "ephemeral" }; - newSystem.push(restBlock); - } - } - - parsed.system = newSystem; - return true; -} - -export const __test = { restructureSystem, SYSTEM_IDENTITY, BILLING_PREFIX }; diff --git a/packages/core/src/llm/debug-logger.ts b/packages/core/src/llm/debug-logger.ts deleted file mode 100644 index 072a7a1..0000000 --- a/packages/core/src/llm/debug-logger.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Debug logger for LLM API requests and responses. - * - * Enable via environment variable: DISPATCH_DEBUG_LLM=1 - * - * Logs every outgoing request body and incoming response body to timestamped - * files under `DISPATCH_DEBUG_LLM_DIR` (default: /tmp/dispatch/llm-debug/). - * - * Each request/response pair shares a sequence number for easy correlation. - * Files are named: `{seq}_{timestamp}_{direction}_{model}.json` - * - * For streaming responses (SSE), the raw chunks are captured as they arrive - * and written out as a JSON array when the stream completes. - * - * Additional logging layers: - * - Stream events: every AI SDK stream event (text-delta, tool-call, etc.) - * - Step lifecycle: step start/end, tool execution timing - * - Agent loop: step count, break conditions, tool call counts - * - * All output goes to stderr (console.error) for stream event logs, and to - * files for request/response bodies (too large for console). - */ - -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const ENABLED = !!process.env.DISPATCH_DEBUG_LLM; -const LOG_DIR = process.env.DISPATCH_DEBUG_LLM_DIR || "/tmp/dispatch/llm-debug"; -let seq = 0; - -/** Verbosity levels: - * 1 = requests/responses only (files) - * 2 = + stream events to stderr - * 3 = + step lifecycle + agent loop details to stderr - */ -const VERBOSITY = Math.max(1, Number(process.env.DISPATCH_DEBUG_LLM_VERBOSITY) || 1); - -function ensureDir(): void { - try { - mkdirSync(LOG_DIR, { recursive: true }); - } catch { - // best effort - } -} - -function ts(): string { - return new Date().toISOString().replace(/[:.]/g, "-"); -} - -function sanitizeModel(model: string): string { - return model.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 60); -} - -function sanitizeTab(tabId?: string): string { - if (!tabId) return "notab"; - return tabId.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 40); -} - -export function isDebugEnabled(): boolean { - return ENABLED; -} - -export function debugVerbosity(): number { - return ENABLED ? VERBOSITY : 0; -} - -/** - * Allocate a fresh sequence number. Used by the fetch wrapper so the request - * and the response share the same id without needing a separate `logRequest` - * call from the agent loop (which doesn't see the actual HTTP body anyway). - */ -export function nextDebugSeq(): number { - return ++seq; -} - -/** - * Log an outgoing request to the AI model endpoint. - * Returns a request ID for correlating with the response. - */ -export function logRequest(data: { - model: string; - url?: string; - method?: string; - headers?: Record<string, string>; - body: unknown; - tabId?: string; - step?: number; - provider?: string; -}): number { - if (!ENABLED) return -1; - ensureDir(); - const id = ++seq; - const filename = `${String(id).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_REQ_${sanitizeModel(data.model)}.json`; - const payload = { - _debug: { - seq: id, - direction: "request", - timestamp: new Date().toISOString(), - tabId: data.tabId, - step: data.step, - provider: data.provider, - }, - url: data.url, - method: data.method ?? "POST", - headers: data.headers, - body: data.body, - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write request log: ${err}`); - } - console.error( - `[dispatch-debug] REQ #${id} → ${data.model} (step=${data.step ?? "?"}, tab=${data.tabId ?? "?"})`, - ); - return id; -} - -/** - * Log the raw fetch-level request (the actual HTTP body sent to the provider). - * Called from the instrumented fetch wrapper. - */ -export function logRawFetchRequest(data: { - requestId: number; - url: string; - method: string; - headers: Record<string, string>; - body: string | null; - tabId?: string; -}): void { - if (!ENABLED) return; - ensureDir(); - const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_REQ.json`; - const payload = { - _debug: { - seq: data.requestId, - direction: "raw-request", - timestamp: new Date().toISOString(), - tabId: data.tabId, - }, - url: data.url, - method: data.method, - headers: data.headers, - body: tryParseJson(data.body), - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write raw request log: ${err}`); - } -} - -/** - * Log the raw fetch-level response (HTTP status, headers, body). - */ -export function logRawFetchResponse(data: { - requestId: number; - url: string; - status: number; - statusText: string; - headers: Record<string, string>; - body: string | null; - isStreaming: boolean; - tabId?: string; -}): void { - if (!ENABLED) return; - ensureDir(); - const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_RES_${data.status}.json`; - const payload = { - _debug: { - seq: data.requestId, - direction: "raw-response", - timestamp: new Date().toISOString(), - isStreaming: data.isStreaming, - tabId: data.tabId, - }, - url: data.url, - status: data.status, - statusText: data.statusText, - headers: data.headers, - body: tryParseJson(data.body), - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write raw response log: ${err}`); - } -} - -/** - * Accumulator for streaming response chunks. Call `addChunk()` as SSE events - * arrive, then `flush()` when the stream ends to write them all to disk. - */ -export class StreamResponseLogger { - private requestId: number; - private model: string; - private tabId?: string; - private chunks: Array<{ timestamp: string; data: string }> = []; - private startTime: number; - - constructor(requestId: number, model: string, tabId?: string) { - this.requestId = requestId; - this.model = model; - this.tabId = tabId; - this.startTime = Date.now(); - } - - addChunk(rawLine: string): void { - if (!ENABLED) return; - this.chunks.push({ - timestamp: new Date().toISOString(), - data: rawLine, - }); - } - - flush(meta?: { finishReason?: string; error?: string }): void { - if (!ENABLED) return; - ensureDir(); - const elapsed = Date.now() - this.startTime; - const filename = `${String(this.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(this.tabId)}_STREAM_RES_${sanitizeModel(this.model)}.json`; - const payload = { - _debug: { - seq: this.requestId, - direction: "stream-response", - timestamp: new Date().toISOString(), - tabId: this.tabId, - model: this.model, - elapsedMs: elapsed, - chunkCount: this.chunks.length, - ...meta, - }, - chunks: this.chunks, - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write stream response log: ${err}`); - } - console.error( - `[dispatch-debug] STREAM #${this.requestId} complete: ${this.chunks.length} chunks in ${elapsed}ms (${this.model})`, - ); - } -} - -/** - * Log an AI SDK stream event (text-delta, tool-call, finish-step, etc.). - * Only logs at verbosity >= 2. - */ -export function logStreamEvent(data: { - requestId: number; - step: number; - eventType: string; - detail?: unknown; - tabId?: string; -}): void { - if (!ENABLED || VERBOSITY < 2) return; - const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : ""; - console.error( - `[dispatch-debug] STREAM_EVENT #${data.requestId} step=${data.step} ${data.eventType}${detail}`, - ); -} - -/** - * Log step lifecycle events (step start, tool execution, step end). - * Only logs at verbosity >= 3. - */ -export function logStepLifecycle(data: { - tabId?: string; - step: number; - event: string; - detail?: unknown; -}): void { - if (!ENABLED || VERBOSITY < 3) return; - const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : ""; - console.error( - `[dispatch-debug] STEP tab=${data.tabId ?? "?"} step=${data.step} ${data.event}${detail}`, - ); -} - -/** - * Log agent loop-level events (loop start, break conditions, etc.). - * Only logs at verbosity >= 3. - */ -export function logAgentLoop(data: { tabId?: string; event: string; detail?: unknown }): void { - if (!ENABLED || VERBOSITY < 3) return; - const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : ""; - console.error(`[dispatch-debug] AGENT tab=${data.tabId ?? "?"} ${data.event}${detail}`); -} - -/** - * Wrap a fetch function so every request/response pair is logged to disk - * under `DISPATCH_DEBUG_LLM_DIR` when `DISPATCH_DEBUG_LLM` is set. When - * disabled, returns the input fetch unchanged (zero overhead). - * - * Critical implementation note — SSE bodies: the AI SDK consumes - * `response.body` as a `ReadableStream`. Reading it from anywhere else - * (e.g. calling `.text()`) drains the stream and the SDK gets an empty - * body. We therefore `response.clone()` the response and tee its body via - * a `TransformStream` so each SSE line is forwarded to the SDK AND - * captured into a `StreamResponseLogger`. The clone returns its own - * Response object whose body the SDK reads normally. - * - * For non-streaming responses (`content-type` is not `text/event-stream`) - * we just clone and read once via `.text()` — simpler and safe because - * non-streaming bodies are bounded. - */ -export function wrapFetchWithLogging<F extends (...args: never[]) => Promise<Response> | Response>( - baseFetch: F, - opts: { tabId?: string; modelHint?: string }, -): F { - if (!ENABLED) return baseFetch; - const wrapped = async (...args: Parameters<F>) => { - const requestId = ++seq; - const [input, init] = args as unknown as [RequestInfo | URL, RequestInit | undefined]; - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - const method = - init?.method ?? - (typeof input === "object" && "method" in input ? (input as Request).method : "POST"); - - // Snapshot headers as a plain object for logging. - const headerObj: Record<string, string> = {}; - try { - const h = new Headers(init?.headers); - h.forEach((v, k) => { - // Redact bearer / api-key headers — useful in shared logs. - if (/^(authorization|x-api-key|cookie)$/i.test(k)) { - headerObj[k] = "<redacted>"; - } else { - headerObj[k] = v; - } - }); - } catch { - // best effort - } - - // Capture request body. Most providers send a JSON string here; if it's - // a stream/blob/etc. we skip body logging (rare in our codebase). - let bodyStr: string | null = null; - if (typeof init?.body === "string") { - bodyStr = init.body; - } else if (init?.body instanceof Uint8Array) { - bodyStr = new TextDecoder().decode(init.body); - } - - logRawFetchRequest({ - requestId, - url, - method, - headers: headerObj, - body: bodyStr, - tabId: opts.tabId, - }); - - const response = await (baseFetch as unknown as typeof fetch)(input, init); - - const respHeaders: Record<string, string> = {}; - response.headers.forEach((v, k) => { - respHeaders[k] = v; - }); - const contentType = response.headers.get("content-type") ?? ""; - const isStreaming = contentType.includes("text/event-stream"); - - if (!isStreaming) { - // Clone so we don't drain the SDK's copy. Bounded body — safe to read. - try { - const cloned = response.clone(); - const text = await cloned.text(); - logRawFetchResponse({ - requestId, - url, - status: response.status, - statusText: response.statusText, - headers: respHeaders, - body: text, - isStreaming: false, - tabId: opts.tabId, - }); - } catch (err) { - console.error(`[dispatch-debug] Failed to clone non-stream response: ${err}`); - } - return response; - } - - // Streaming path: write a header file with status + headers immediately - // (the body file comes later via StreamResponseLogger.flush). - logRawFetchResponse({ - requestId, - url, - status: response.status, - statusText: response.statusText, - headers: respHeaders, - body: null, - isStreaming: true, - tabId: opts.tabId, - }); - - // Tee the body through a TransformStream so each SSE chunk is captured - // without consuming the stream the SDK needs. - const streamLogger = new StreamResponseLogger( - requestId, - opts.modelHint ?? "stream", - opts.tabId, - ); - const decoder = new TextDecoder(); - const tee = new TransformStream<Uint8Array, Uint8Array>({ - transform(chunk, controller) { - try { - streamLogger.addChunk(decoder.decode(chunk, { stream: true })); - } catch { - // never let logging break the stream - } - controller.enqueue(chunk); - }, - flush() { - try { - streamLogger.flush(); - } catch { - // best effort - } - }, - }); - - // `response.body` is `ReadableStream<Uint8Array> | null`. If null (no - // body), there's nothing to tee — return as-is. - if (!response.body) return response; - const teed = response.body.pipeThrough(tee); - return new Response(teed, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - }; - return wrapped as unknown as F; -} - -function tryParseJson(s: string | null): unknown { - if (s === null) return null; - try { - return JSON.parse(s); - } catch { - return s; - } -} diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts deleted file mode 100644 index ca734f9..0000000 --- a/packages/core/src/llm/provider.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { createAnthropic } from "@ai-sdk/anthropic"; -import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; -import type { LanguageModelV3 } from "@ai-sdk/provider"; -import type { FetchFunction } from "@ai-sdk/provider-utils"; -import { getAnthropicBetas } from "../credentials/anthropic-betas.js"; -import { transformClaudeOAuthBody } from "./anthropic-oauth-transform.js"; -import { wrapFetchWithLogging } from "./debug-logger.js"; - -export interface ProviderConfig { - apiKey: string; - baseURL: string; - provider?: string; - claudeCredentials?: { - accessToken: string; - }; - /** Optional tab id for labelling debug logs. No effect when - * `DISPATCH_DEBUG_LLM` is unset. */ - tabId?: string; -} - -const MCP_PREFIX = "mcp_"; - -function prefixToolName(name: string): string { - return `${MCP_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`; -} - -function unprefixToolName(name: string): string { - if (name.startsWith(MCP_PREFIX)) { - const rest = name.slice(MCP_PREFIX.length); - return `${rest.charAt(0).toLowerCase()}${rest.slice(1)}`; - } - return name; -} - -// Explicit factory return type so the inferred type doesn't leak references -// into transitive `@ai-sdk/provider` paths (which would trip TS2742). -// `@ai-sdk/anthropic` v3.x and `@ai-sdk/openai-compatible` v2.x both return -// `LanguageModelV3`-spec models; `wrapLanguageModel` likewise. -export type ModelFactory = (modelId: string) => LanguageModelV3; - -export function createProvider(config: ProviderConfig): ModelFactory { - if (config.provider === "anthropic") { - return createClaudeOAuthProvider(config); - } - - if (config.provider === "opencode-anthropic") { - return createApiKeyAnthropicProvider(config); - } - - // Default: OpenAI-compatible provider (OpenCode Zen — DeepSeek, GLM, - // Kimi, MiniMax, etc.). - // - // `@ai-sdk/[email protected]` handles reasoning round-tripping - // natively: it reads `{ type: "reasoning", text }` parts from each - // assistant message's content and emits them as `reasoning_content` - // on the wire (see node_modules/@ai-sdk/openai-compatible/dist/index.mjs - // lines 215-216 and 245). Our `toModelMessages` in agent.ts already - // emits reasoning parts from `ThinkingChunk`s, so no middleware is - // needed. - // - // (The v4-era `normalizeMessages` middleware that lived here was - // actively breaking DeepSeek: it stripped reasoning parts from - // content AND wrote them under `providerMetadata` — wrong key in v3 - // prompts, which use `providerOptions`. The result was that - // reasoning_content never reached the wire and DeepSeek rejected the - // follow-up turn with "must be passed back".) - // - // Debug logging: when DISPATCH_DEBUG_LLM is set, wrap the base fetch - // so every wire request/response (including SSE chunks) is captured. - // When disabled, `wrapFetchWithLogging` returns the input unchanged - // (zero overhead). - const loggingFetch = wrapFetchWithLogging(globalThis.fetch, { - tabId: config.tabId, - modelHint: "opencode-zen", - }) as unknown as FetchFunction; - - const provider = createOpenAICompatible({ - name: "opencode-zen", - apiKey: config.apiKey, - baseURL: config.baseURL, - fetch: loggingFetch, - }); - - return (modelId: string) => provider(modelId); -} - -/** - * Claude OAuth provider. Used by Dispatch's `anthropic` provider keys - * (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer` - * (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI - * request headers so the request bills against the user's Claude subscription. - * - * The `anthropic-beta` header is REQUIRED here. `@ai-sdk/anthropic` only emits - * an `anthropic-beta` header for betas it auto-derives from tool definitions - * (computer-use, structured-outputs, etc.) — it does NOT add the prompt-caching - * or oauth betas on its own. Without `prompt-caching-scope-2026-01-05` the API - * silently ignores every `cache_control` breakpoint we attach to messages, - * giving a 0% cache hit rate and a massive token burn (see notes/claude-report.md). - * The SDK folds any `anthropic-beta` it finds on the provider's config headers - * back into its own beta set (via `getBetasFromHeaders`), so the values here - * are merged — not overwritten — with any tool-derived betas. - */ -function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory { - // Stable per-provider session id — mirrors the Claude Code CLI, which sends - // the same `X-Claude-Code-Session-Id` across a session's requests. - const sessionId = randomUUID(); - - // Wrap the base fetch FIRST so the logging wrapper sees the genuine - // outgoing HTTP body — i.e. AFTER the OAuth body transform and AFTER the - // Claude-Code session headers have been stamped on. Order matters: if we - // wrapped the inner `baseFetch` instead, the logs would show the pre- - // transform body and miss the session headers, defeating the point of - // capturing the wire for cache/billing debugging. - const baseFetch = wrapFetchWithLogging(globalThis.fetch, { - tabId: config.tabId, - modelHint: "claude-oauth", - }); - - // Custom fetch that (1) restructures the request body into the genuine - // Claude Code system layout — required for Anthropic to bill correctly and - // apply the prompt-cache scope (see anthropic-oauth-transform.ts) — and - // (2) stamps the Claude Code session/request id headers the real CLI sends. - // Cast through `unknown`: `FetchFunction` is `typeof globalThis.fetch`, whose - // (Bun) type carries a `preconnect` member a plain wrapper can't satisfy. - const oauthFetch = (async ( - input: Parameters<FetchFunction>[0], - init?: Parameters<FetchFunction>[1], - ) => { - const nextInit: RequestInit = { ...init }; - if (init?.body != null) { - nextInit.body = transformClaudeOAuthBody(init.body) ?? init.body; - } - const headers = new Headers(init?.headers); - headers.set("X-Claude-Code-Session-Id", sessionId); - if (!headers.has("x-client-request-id")) { - headers.set("x-client-request-id", randomUUID()); - } - nextInit.headers = headers; - return baseFetch(input, nextInit); - }) as unknown as FetchFunction; - - const anthropic = createAnthropic({ - baseURL: config.baseURL || "https://api.anthropic.com/v1", - authToken: config.claudeCredentials?.accessToken ?? config.apiKey, - fetch: oauthFetch, - headers: { - "anthropic-beta": getAnthropicBetas().join(","), - "anthropic-dangerous-direct-browser-access": "true", - "x-app": "cli", - "user-agent": "claude-cli/2.1.112 (external, sdk-cli)", - }, - }); - return (modelId: string) => anthropic(modelId); -} - -/** - * Plain-API-key Anthropic-format provider. Used to hit gateways that speak - * Anthropic's `/messages` protocol with a standard `x-api-key` header — most - * importantly OpenCode Go's MiniMax and Qwen routes. Unlike the Claude OAuth - * variant, no `claudeCredentials` are present, no Claude Code mimicry headers - * are sent, and the API key is passed verbatim through the SDK's default - * authentication path. - */ -function createApiKeyAnthropicProvider(config: ProviderConfig): ModelFactory { - const loggingFetch = wrapFetchWithLogging(globalThis.fetch, { - tabId: config.tabId, - modelHint: "opencode-anthropic", - }) as unknown as FetchFunction; - - const anthropic = createAnthropic({ - apiKey: config.apiKey, - baseURL: config.baseURL || "https://opencode.ai/zen/go/v1", - fetch: loggingFetch, - }); - - return (modelId: string) => anthropic(modelId); -} - -export { prefixToolName, unprefixToolName }; diff --git a/packages/core/src/lsp/client.ts b/packages/core/src/lsp/client.ts deleted file mode 100644 index da0c916..0000000 --- a/packages/core/src/lsp/client.ts +++ /dev/null @@ -1,658 +0,0 @@ -import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { extname, isAbsolute, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { - createMessageConnection, - type MessageConnection, - StreamMessageReader, - StreamMessageWriter, -} from "vscode-jsonrpc/node"; -import type { Diagnostic } from "vscode-languageserver-types"; -import { languageIdForExtension } from "./language.js"; - -export type { Diagnostic } from "vscode-languageserver-types"; - -// ─── Timing constants (mirrors opencode) ───────────────────────── -const DIAGNOSTICS_DEBOUNCE_MS = 150; -const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000; -const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000; -const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000; -const INITIALIZE_TIMEOUT_MS = 45_000; - -// ─── LSP spec constants ────────────────────────────────────────── -const FILE_CHANGE_CREATED = 1; -const FILE_CHANGE_CHANGED = 2; -const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2; - -/** - * A live spawned language-server process plus the `initializationOptions` to - * hand it. Produced by the server-spawning layer (`server.ts`) and consumed by - * `createLspClient`. - */ -export interface LspServerHandle { - process: ChildProcessWithoutNullStreams; - initialization?: Record<string, unknown>; -} - -interface ServerCapabilities { - textDocumentSync?: number | { change?: number }; - diagnosticProvider?: unknown; - [key: string]: unknown; -} - -interface DiagnosticRequestResult { - handled: boolean; - matched: boolean; - byFile: Map<string, Diagnostic[]>; -} - -interface CapabilityRegistration { - id: string; - method: string; - registerOptions?: { - identifier?: string; - workspaceDiagnostics?: boolean; - }; -} - -type DocumentDiagnosticReport = { - items?: Diagnostic[]; - relatedDocuments?: Record<string, DocumentDiagnosticReport>; -}; - -type WorkspaceDiagnosticReport = { - items?: { uri?: string; items?: Diagnostic[] }[]; -}; - -/** Public shape of a connected LSP client. */ -export interface LspClient { - readonly serverID: string; - readonly root: string; - readonly connection: MessageConnection; - /** - * Open (or re-sync) a file with the server. Returns the document version - * sent — pass it to `waitForDiagnostics` to wait for diagnostics matching - * this exact sync. - */ - notifyOpen(path: string): Promise<number>; - /** Snapshot of all known diagnostics keyed by absolute file path. */ - readonly diagnostics: Map<string, Diagnostic[]>; - /** Wait until diagnostics for `path` settle (push and/or pull). */ - waitForDiagnostics(request: { - path: string; - version: number; - mode?: "document" | "full"; - after?: number; - }): Promise<void>; - /** Generic LSP request passthrough (hover, definition, references, …). */ - request<T = unknown>(method: string, params: unknown): Promise<T | null>; - /** Shut the connection and child process down. */ - shutdown(): Promise<void>; -} - -function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { - return new Promise<T>((resolvePromise, reject) => { - const timer = setTimeout(() => reject(new Error(`LSP request timed out after ${ms}ms`)), ms); - promise.then( - (value) => { - clearTimeout(timer); - resolvePromise(value); - }, - (err) => { - clearTimeout(timer); - reject(err); - }, - ); - }); -} - -function getFilePath(uri: string): string | undefined { - if (!uri.startsWith("file://")) return undefined; - return fileURLToPath(uri); -} - -function getSyncKind(capabilities?: ServerCapabilities): number | undefined { - if (!capabilities) return undefined; - const sync = capabilities.textDocumentSync; - if (typeof sync === "number") return sync; - return sync?.change; -} - -function endPosition(text: string) { - const lines = text.split(/\r\n|\r|\n/); - return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 }; -} - -function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] { - const seen = new Set<string>(); - return items.filter((item) => { - const key = JSON.stringify({ - code: item.code, - severity: item.severity, - message: item.message, - source: item.source, - range: item.range, - }); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function configurationValue(settings: unknown, section?: string): unknown { - if (!section) return settings ?? null; - const result = section.split(".").reduce<unknown>((acc, key) => { - if (!acc || typeof acc !== "object" || !(key in acc)) return undefined; - return (acc as Record<string, unknown>)[key]; - }, settings); - return result ?? null; -} - -/** - * Create and initialize an LSP client over a spawned server's stdio. - * - * Performs the full `initialize`/`initialized` handshake (with a 45s timeout), - * wires push (`textDocument/publishDiagnostics`) and pull - * (`textDocument/diagnostic`, `workspace/diagnostic`) diagnostics, answers the - * `workspace/configuration`, `workspaceFolders`, and capability-registration - * requests servers commonly make, and returns a small client surface used by - * the manager and tools. Plain-TypeScript port of opencode's `lsp/client.ts`. - */ -export async function createLspClient(input: { - serverID: string; - server: LspServerHandle; - root: string; - directory: string; -}): Promise<LspClient> { - const { serverID, server, root, directory } = input; - - const connection = createMessageConnection( - new StreamMessageReader(server.process.stdout), - new StreamMessageWriter(server.process.stdin), - ); - - // Server stderr is routine for many tools (luau-lsp logs sourcemap status - // there). Keep it quiet unless debugging. - server.process.stderr?.on("data", () => { - /* swallowed — see opencode: stderr is mostly informational */ - }); - - // ─── Connection state ─── - const pushDiagnostics = new Map<string, Diagnostic[]>(); - const pullDiagnostics = new Map<string, Diagnostic[]>(); - const published = new Map<string, { at: number; version?: number }>(); - const diagnosticRegistrations = new Map<string, CapabilityRegistration>(); - const registrationListeners = new Set<() => void>(); - const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>(); - const files: Record<string, { version: number; text: string }> = {}; - - const mergedDiagnostics = (filePath: string) => - dedupeDiagnostics([ - ...(pushDiagnostics.get(filePath) ?? []), - ...(pullDiagnostics.get(filePath) ?? []), - ]); - const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { - pushDiagnostics.set(filePath, next); - for (const listener of diagnosticListeners) listener({ path: filePath, serverID }); - }; - const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { - pullDiagnostics.set(filePath, next); - }; - const emitRegistrationChange = () => { - for (const listener of [...registrationListeners]) listener(); - }; - - // ─── Notification / request handlers ─── - connection.onNotification( - "textDocument/publishDiagnostics", - (params: { uri: string; diagnostics: Diagnostic[]; version?: number }) => { - const filePath = getFilePath(params.uri); - if (!filePath) return; - published.set(filePath, { - at: Date.now(), - version: typeof params.version === "number" ? params.version : undefined, - }); - updatePushDiagnostics(filePath, params.diagnostics); - }, - ); - connection.onRequest("window/workDoneProgress/create", () => null); - connection.onRequest("workspace/configuration", (params: { items?: { section?: string }[] }) => { - const items = params.items ?? []; - return items.map((item) => configurationValue(server.initialization, item.section)); - }); - connection.onRequest( - "client/registerCapability", - (params: { registrations?: CapabilityRegistration[] }) => { - const registrations = params.registrations ?? []; - let changed = false; - for (const registration of registrations) { - if (registration.method !== "textDocument/diagnostic") continue; - diagnosticRegistrations.set(registration.id, registration); - changed = true; - } - if (changed) emitRegistrationChange(); - return null; - }, - ); - connection.onRequest( - "client/unregisterCapability", - (params: { unregisterations?: { id: string; method: string }[] }) => { - const registrations = params.unregisterations ?? []; - let changed = false; - for (const registration of registrations) { - if (registration.method !== "textDocument/diagnostic") continue; - diagnosticRegistrations.delete(registration.id); - changed = true; - } - if (changed) emitRegistrationChange(); - return null; - }, - ); - connection.onRequest("workspace/workspaceFolders", () => [ - { name: "workspace", uri: pathToFileURL(root).href }, - ]); - connection.onRequest("workspace/diagnostic/refresh", () => null); - connection.listen(); - - // ─── Initialize handshake ─── - const initialized = await withTimeout( - connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { - rootUri: pathToFileURL(root).href, - processId: server.process.pid ?? null, - workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }], - initializationOptions: { ...server.initialization }, - capabilities: { - window: { workDoneProgress: true }, - workspace: { - configuration: true, - didChangeWatchedFiles: { dynamicRegistration: true }, - diagnostics: { refreshSupport: false }, - }, - textDocument: { - synchronization: { didOpen: true, didChange: true }, - diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, - publishDiagnostics: { versionSupport: false }, - }, - }, - }), - INITIALIZE_TIMEOUT_MS, - ); - - const syncKind = getSyncKind(initialized.capabilities); - const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider); - - await connection.sendNotification("initialized", {}); - if (server.initialization) { - await connection.sendNotification("workspace/didChangeConfiguration", { - settings: server.initialization, - }); - } - - // ─── Pull-diagnostics helpers ─── - const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { - const handled = results.some((r) => r.handled); - const matched = results.some((r) => r.matched); - if (!handled) return { handled: false, matched: false }; - - const merged = new Map<string, Diagnostic[]>(); - for (const result of results) { - for (const [target, items] of result.byFile.entries()) { - merged.set(target, (merged.get(target) ?? []).concat(items)); - } - } - if (matched && !merged.has(filePath)) merged.set(filePath, []); - for (const [target, items] of merged.entries()) { - updatePullDiagnostics(target, dedupeDiagnostics(items)); - } - return { handled, matched }; - }; - - async function requestDiagnosticReport( - filePath: string, - identifier?: string, - ): Promise<DiagnosticRequestResult> { - const report = await withTimeout( - connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", { - ...(identifier ? { identifier } : {}), - textDocument: { uri: pathToFileURL(filePath).href }, - }), - DIAGNOSTICS_REQUEST_TIMEOUT_MS, - ).catch(() => null); - const empty: DiagnosticRequestResult = { - handled: false, - matched: false, - byFile: new Map(), - }; - if (!report) return empty; - - const byFile = new Map<string, Diagnostic[]>(); - const push = (target: string, items: Diagnostic[]) => { - byFile.set(target, (byFile.get(target) ?? []).concat(items)); - }; - let handled = false; - let matched = false; - if (Array.isArray(report.items)) { - push(filePath, report.items); - handled = true; - matched = true; - } - for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { - const relatedPath = getFilePath(uri); - if (!relatedPath || !Array.isArray(related.items)) continue; - push(relatedPath, related.items); - handled = true; - matched = matched || relatedPath === filePath; - } - return { handled, matched, byFile }; - } - - async function requestWorkspaceDiagnosticReport( - filePath: string, - identifier?: string, - ): Promise<DiagnosticRequestResult> { - const report = await withTimeout( - connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", { - ...(identifier ? { identifier } : {}), - previousResultIds: [], - }), - DIAGNOSTICS_REQUEST_TIMEOUT_MS, - ).catch(() => null); - if (!report) return { handled: false, matched: false, byFile: new Map() }; - - const byFile = new Map<string, Diagnostic[]>(); - let matched = false; - for (const item of report.items ?? []) { - const relatedPath = item.uri ? getFilePath(item.uri) : undefined; - if (!relatedPath || !Array.isArray(item.items)) continue; - byFile.set(relatedPath, (byFile.get(relatedPath) ?? []).concat(item.items)); - matched = matched || relatedPath === filePath; - } - return { handled: true, matched, byFile }; - } - - function documentPullState() { - const documentRegistrations = [...diagnosticRegistrations.values()].filter( - (r) => r.registerOptions?.workspaceDiagnostics !== true, - ); - return { - documentIdentifiers: [ - ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), - ], - supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, - }; - } - - function workspacePullState() { - const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( - (r) => r.registerOptions?.workspaceDiagnostics === true, - ); - return { - workspaceIdentifiers: [ - ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), - ], - supported: workspaceRegistrations.length > 0, - }; - } - - const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => - results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0); - - async function requestDiagnostics( - filePath: string, - requests: Promise<DiagnosticRequestResult>[], - done: (results: DiagnosticRequestResult[]) => boolean, - ) { - if (!requests.length) return { handled: false, matched: false }; - const results: DiagnosticRequestResult[] = []; - return new Promise<{ handled: boolean; matched: boolean }>((resolvePromise) => { - let pending = requests.length; - let resolved = false; - const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { - if (resolved) return; - if (!force && !done(results)) return; - resolved = true; - resolvePromise(merged); - }; - for (const request of requests) { - request.then((result) => { - results.push(result); - pending -= 1; - const merged = mergeResults(filePath, results); - finish(merged); - if (pending === 0) finish(merged, true); - }); - } - }); - } - - async function requestDocumentDiagnostics(filePath: string) { - const state = documentPullState(); - if (!state.supported) return { handled: false, matched: false }; - return requestDiagnostics( - filePath, - [ - requestDiagnosticReport(filePath), - ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), - ], - (results) => hasCurrentFileDiagnostics(filePath, results), - ); - } - - async function requestFullDiagnostics(filePath: string) { - const documentState = documentPullState(); - const workspaceState = workspacePullState(); - if (!documentState.supported && !workspaceState.supported) { - return { handled: false, matched: false }; - } - return mergeResults( - filePath, - await Promise.all([ - ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), - ...documentState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), - ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), - ...workspaceState.workspaceIdentifiers.map((id) => - requestWorkspaceDiagnosticReport(filePath, id), - ), - ]), - ); - } - - function waitForRegistrationChange(timeout: number) { - if (timeout <= 0) return Promise.resolve(false); - return new Promise<boolean>((resolvePromise) => { - let finished = false; - let timer: ReturnType<typeof setTimeout> | undefined; - const finish = (result: boolean) => { - if (finished) return; - finished = true; - if (timer) clearTimeout(timer); - registrationListeners.delete(listener); - resolvePromise(result); - }; - const listener = () => finish(true); - registrationListeners.add(listener); - timer = setTimeout(() => finish(false), timeout); - }); - } - - function waitForFreshPush(request: { - path: string; - version: number; - after: number; - timeout: number; - }) { - if (request.timeout <= 0) return Promise.resolve(false); - return new Promise<boolean>((resolvePromise) => { - let finished = false; - let debounceTimer: ReturnType<typeof setTimeout> | undefined; - let timeoutTimer: ReturnType<typeof setTimeout> | undefined; - let unsub: (() => void) | undefined; - const finish = (result: boolean) => { - if (finished) return; - finished = true; - if (debounceTimer) clearTimeout(debounceTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - unsub?.(); - resolvePromise(result); - }; - const schedule = () => { - const hit = published.get(request.path); - if (!hit) return; - if (typeof hit.version === "number" && hit.version !== request.version) return; - if (hit.at < request.after && hit.version !== request.version) return; - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout( - () => finish(true), - Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)), - ); - }; - timeoutTimer = setTimeout(() => finish(false), request.timeout); - const listener = (event: { path: string; serverID: string }) => { - if (event.path !== request.path || event.serverID !== serverID) return; - schedule(); - }; - diagnosticListeners.add(listener); - unsub = () => diagnosticListeners.delete(listener); - schedule(); - }); - } - - async function waitForDocumentDiagnostics(request: { - path: string; - version: number; - after?: number; - }) { - const startedAt = request.after ?? Date.now(); - const pushWait = waitForFreshPush({ - path: request.path, - version: request.version, - after: startedAt, - timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, - }); - while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { - const result = await requestDocumentDiagnostics(request.path); - if (result.matched) return; - const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt); - if (remaining <= 0) return; - const next = await Promise.race([ - pushWait.then((ready) => (ready ? "push" : "timeout")), - waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), - ]); - if (next !== "registration") return; - } - } - - async function waitForFullDiagnostics(request: { - path: string; - version: number; - after?: number; - }) { - const startedAt = request.after ?? Date.now(); - const pushWait = waitForFreshPush({ - path: request.path, - version: request.version, - after: startedAt, - timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, - }); - while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { - const result = await requestFullDiagnostics(request.path); - if (result.handled || result.matched) return; - const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt); - if (remaining <= 0) return; - const next = await Promise.race([ - pushWait.then((ready) => (ready ? "push" : "timeout")), - waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), - ]); - if (next !== "registration") return; - } - } - - const normalize = (p: string) => (isAbsolute(p) ? p : resolve(directory, p)); - - // ─── Public surface ─── - const client: LspClient = { - serverID, - root, - connection, - async notifyOpen(path: string) { - const filePath = normalize(path); - const text = await readFile(filePath, "utf8"); - const languageId = languageIdForExtension(extname(filePath)); - const uri = pathToFileURL(filePath).href; - const document = files[filePath]; - - if (document !== undefined) { - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [{ uri, type: FILE_CHANGE_CHANGED }], - }); - const next = document.version + 1; - files[filePath] = { version: next, text }; - await connection.sendNotification("textDocument/didChange", { - textDocument: { uri, version: next }, - contentChanges: - syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL - ? [ - { - range: { - start: { line: 0, character: 0 }, - end: endPosition(document.text), - }, - text, - }, - ] - : [{ text }], - }); - return next; - } - - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [{ uri, type: FILE_CHANGE_CREATED }], - }); - pushDiagnostics.delete(filePath); - pullDiagnostics.delete(filePath); - await connection.sendNotification("textDocument/didOpen", { - textDocument: { uri, languageId, version: 0, text }, - }); - files[filePath] = { version: 0, text }; - return 0; - }, - get diagnostics() { - const result = new Map<string, Diagnostic[]>(); - for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { - result.set(key, mergedDiagnostics(key)); - } - return result; - }, - async waitForDiagnostics(request) { - const normalizedPath = normalize(request.path); - if (request.mode === "document") { - await waitForDocumentDiagnostics({ - path: normalizedPath, - version: request.version, - after: request.after, - }); - return; - } - await waitForFullDiagnostics({ - path: normalizedPath, - version: request.version, - after: request.after, - }); - }, - async request<T = unknown>(method: string, params: unknown): Promise<T | null> { - return connection.sendRequest<T>(method, params).catch(() => null); - }, - async shutdown() { - try { - connection.end(); - connection.dispose(); - } catch { - /* connection may already be closed */ - } - server.process.kill(); - }, - }; - - return client; -} diff --git a/packages/core/src/lsp/diagnostic.ts b/packages/core/src/lsp/diagnostic.ts deleted file mode 100644 index 1ad4d0f..0000000 --- a/packages/core/src/lsp/diagnostic.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Diagnostic } from "vscode-languageserver-types"; - -/** - * Diagnostic formatting helpers. Ported from opencode's `lsp/diagnostic.ts`. - * - * LSP positions are 0-based on the wire; we render them 1-based (editor-style) - * so they line up with what `read_file` shows and what editors report. - */ - -/** Max diagnostics rendered per file before truncating with a "… and N more". */ -const MAX_PER_FILE = 20; - -const SEVERITY_LABEL: Record<number, string> = { - 1: "ERROR", - 2: "WARN", - 3: "INFO", - 4: "HINT", -}; - -/** Render a single diagnostic as `SEVERITY [line:col] message` (1-based). */ -export function pretty(diagnostic: Diagnostic): string { - const severity = SEVERITY_LABEL[diagnostic.severity ?? 1] ?? "ERROR"; - const line = diagnostic.range.start.line + 1; - const col = diagnostic.range.start.character + 1; - return `${severity} [${line}:${col}] ${diagnostic.message}`; -} - -/** - * Build a `<diagnostics file="…">` block for a file's ERROR-severity - * diagnostics, or `""` when there are none. Errors only — warnings/info/hints - * are intentionally omitted so the model is nudged toward the things that - * actually break the build (matching opencode's behavior). - */ -export function report(file: string, issues: Diagnostic[]): string { - const errors = issues.filter((item) => item.severity === 1); - if (errors.length === 0) return ""; - const limited = errors.slice(0, MAX_PER_FILE); - const more = errors.length - MAX_PER_FILE; - const suffix = more > 0 ? `\n... and ${more} more` : ""; - return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`; -} diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts deleted file mode 100644 index fd43c2f..0000000 --- a/packages/core/src/lsp/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -// LSP (Language Server Protocol) integration. -// -// Config-driven only: servers are declared in `dispatch.toml`'s `[lsp.<id>]` -// block (see `LspServerConfig` in `../types`). There is no builtin server -// registry and no auto-download. The primary model-facing surface is -// diagnostics-on-write (the host passes a write hook that calls `touchFile` + -// `report`); an on-demand `lsp` tool exposes hover/definition/references too. - -export { - createLspClient, - type Diagnostic, - type LspClient, - type LspServerHandle, -} from "./client.js"; -export { pretty, report } from "./diagnostic.js"; -export { LANGUAGE_EXTENSIONS, languageIdForExtension } from "./language.js"; -export { LspManager } from "./manager.js"; -export { type ResolvedLspServer, resolveServersFromConfig } from "./server.js"; diff --git a/packages/core/src/lsp/language.ts b/packages/core/src/lsp/language.ts deleted file mode 100644 index 3e9fe68..0000000 --- a/packages/core/src/lsp/language.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * File-extension → LSP `languageId` map. - * - * The LSP `textDocument/didOpen` notification carries a `languageId` string - * that tells the server how to parse the document. This table is a trimmed - * port of opencode's `lsp/language.ts`, with one critical addition for this - * project: `.luau` → `"luau"`. Roblox Luau sources use the `.luau` extension, - * which standard Lua tooling does not recognise — luau-lsp expects the - * `"luau"` languageId. - * - * Extensions are looked up with their leading dot (e.g. `".luau"`). Unknown - * extensions fall back to `"plaintext"` at the call site. - */ -export const LANGUAGE_EXTENSIONS: Record<string, string> = { - // Luau (Roblox) — the reason this module exists. Keep first for visibility. - ".luau": "luau", - ".lua": "lua", - // A pragmatic subset of common languages, mirroring opencode's table so a - // user can point an arbitrary LSP server at this codebase and have the - // right languageId reported. - ".c": "c", - ".cpp": "cpp", - ".cc": "cpp", - ".cxx": "cpp", - ".h": "c", - ".hpp": "cpp", - ".cs": "csharp", - ".css": "css", - ".dart": "dart", - ".go": "go", - ".html": "html", - ".htm": "html", - ".java": "java", - ".js": "javascript", - ".jsx": "javascriptreact", - ".json": "json", - ".jsonc": "jsonc", - ".kt": "kotlin", - ".kts": "kotlin", - ".md": "markdown", - ".markdown": "markdown", - ".php": "php", - ".py": "python", - ".rb": "ruby", - ".rs": "rust", - ".scss": "scss", - ".sass": "sass", - ".sh": "shellscript", - ".bash": "shellscript", - ".zsh": "shellscript", - ".sql": "sql", - ".svelte": "svelte", - ".swift": "swift", - ".toml": "toml", - ".ts": "typescript", - ".tsx": "typescriptreact", - ".mts": "typescript", - ".cts": "typescript", - ".vue": "vue", - ".xml": "xml", - ".yaml": "yaml", - ".yml": "yaml", - ".zig": "zig", -}; - -/** - * Resolve the LSP `languageId` for a file path's extension, falling back to - * `"plaintext"` when the extension is unknown. - */ -export function languageIdForExtension(extension: string): string { - return LANGUAGE_EXTENSIONS[extension] ?? "plaintext"; -} diff --git a/packages/core/src/lsp/manager.ts b/packages/core/src/lsp/manager.ts deleted file mode 100644 index db8b68e..0000000 --- a/packages/core/src/lsp/manager.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { extname } from "node:path"; -import { createLspClient, type Diagnostic, type LspClient } from "./client.js"; -import type { ResolvedLspServer } from "./server.js"; - -/** - * Process-wide owner of LSP client lifecycles. - * - * Clients are keyed by `root + serverID` and spawned lazily on the first file - * that matches a server's extensions, then reused. Concurrent spawns for the - * same key are de-duplicated via an in-flight map, and servers that fail to - * start are remembered in `broken` so we don't spawn-spam. Modeled on - * opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery. - * - * The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a - * tab's working-directory config (`resolveServersFromConfig`) and pass them in - * alongside the `root`. This keeps per-working-directory config out of the - * manager while letting it own all the long-lived processes for the process. - */ -export class LspManager { - private clients = new Map<string, LspClient>(); - private spawning = new Map<string, Promise<LspClient | undefined>>(); - private broken = new Set<string>(); - - private key(root: string, serverID: string): string { - return `${root}\u0000${serverID}`; - } - - private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] { - const extension = extname(file) || file; - return servers.filter( - (server) => server.extensions.length === 0 || server.extensions.includes(extension), - ); - } - - /** - * True if any provided server is configured to attach to this file's - * extension (regardless of whether it has spawned yet). Used to decide - * whether an LSP operation is even applicable to a file. - */ - hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean { - return this.serversForFile(file, servers).length > 0; - } - - /** - * Get (spawning if needed) all clients that should attach to `file` at - * `root`. Spawn failures are swallowed (logged via `broken`) and simply - * yield fewer clients — callers degrade gracefully to "no diagnostics". - */ - async getClients(input: { - file: string; - root: string; - servers: ResolvedLspServer[]; - }): Promise<LspClient[]> { - const { file, root, servers } = input; - const matching = this.serversForFile(file, servers); - const result: LspClient[] = []; - - for (const server of matching) { - const key = this.key(root, server.id); - if (this.broken.has(key)) continue; - - const existing = this.clients.get(key); - if (existing) { - result.push(existing); - continue; - } - - const inflight = this.spawning.get(key); - if (inflight) { - const client = await inflight; - if (client) result.push(client); - continue; - } - - const task = this.spawn(server, root, key); - this.spawning.set(key, task); - task.finally(() => { - if (this.spawning.get(key) === task) this.spawning.delete(key); - }); - const client = await task; - if (client) result.push(client); - } - - return result; - } - - private async spawn( - server: ResolvedLspServer, - root: string, - key: string, - ): Promise<LspClient | undefined> { - let handle: ReturnType<ResolvedLspServer["spawn"]>; - try { - handle = server.spawn(root); - } catch (err) { - this.broken.add(key); - console.warn( - `dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`, - ); - return undefined; - } - - // A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH) - // emits `error` on the child process; mark broken so we don't retry it. - handle.process.on("error", (err) => { - this.broken.add(key); - console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`); - }); - - try { - const client = await createLspClient({ - serverID: server.id, - server: handle, - root, - directory: root, - }); - // A racing caller may have created the same client; prefer the - // existing one and discard ours. - const existing = this.clients.get(key); - if (existing) { - await client.shutdown(); - return existing; - } - this.clients.set(key, client); - return client; - } catch (err) { - this.broken.add(key); - try { - handle.process.kill(); - } catch { - /* already dead */ - } - console.warn( - `dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`, - ); - return undefined; - } - } - - /** - * Open/sync a file with its clients and (optionally) wait for diagnostics - * to settle. `mode: "document"` waits for the file's own diagnostics; - * `"full"` also waits on workspace diagnostics; omitted just syncs. - */ - async touchFile(input: { - file: string; - root: string; - servers: ResolvedLspServer[]; - mode?: "document" | "full"; - }): Promise<void> { - const clients = await this.getClients(input); - await Promise.all( - clients.map(async (client) => { - const after = Date.now(); - const version = await client.notifyOpen(input.file); - if (!input.mode) return; - await client.waitForDiagnostics({ - path: input.file, - version, - mode: input.mode, - after, - }); - }), - ).catch((err) => { - console.warn( - `dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - } - - /** - * Merged diagnostics for a single file across all of its clients, keyed by - * absolute file path. Includes related-file diagnostics a client surfaced - * (e.g. workspace pulls), so the result map may contain more than `file`. - */ - getDiagnostics(input: { - root: string; - servers: ResolvedLspServer[]; - file: string; - }): Record<string, Diagnostic[]> { - const results: Record<string, Diagnostic[]> = {}; - const matching = this.serversForFile(input.file, input.servers); - for (const server of matching) { - const client = this.clients.get(this.key(input.root, server.id)); - if (!client) continue; - for (const [path, diags] of client.diagnostics.entries()) { - results[path] = (results[path] ?? []).concat(diags); - } - } - return results; - } - - /** - * Run a positional LSP request (hover/definition/references/etc.) against - * every client for the file and flatten the (non-null) results. `line`/ - * `character` are 0-based here — the caller converts from editor 1-based. - */ - async request(input: { - file: string; - root: string; - servers: ResolvedLspServer[]; - method: string; - params: Record<string, unknown>; - }): Promise<unknown[]> { - const clients = await this.getClients(input); - const results = await Promise.all( - clients.map((client) => client.request(input.method, input.params)), - ); - return results.filter((r) => r !== null && r !== undefined); - } - - /** Shut down every live client and clear all state. */ - async shutdownAll(): Promise<void> { - const clients = [...this.clients.values()]; - this.clients.clear(); - this.spawning.clear(); - this.broken.clear(); - await Promise.all(clients.map((client) => client.shutdown().catch(() => {}))); - } -} diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts deleted file mode 100644 index 1fb002e..0000000 --- a/packages/core/src/lsp/server.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import type { LspServerConfig } from "../types/index.js"; -import type { LspServerHandle } from "./client.js"; - -/** - * A resolved, ready-to-spawn LSP server derived from a `dispatch.toml` - * `[lsp.<id>]` entry. Config-driven only — dispatch ships no builtin server - * registry and performs no auto-download (unlike opencode). The declared - * executable (`command[0]`) must already be on PATH. - */ -export interface ResolvedLspServer { - id: string; - /** Extensions (with leading dot) this server attaches to, e.g. `".luau"`. */ - extensions: string[]; - /** Launch the server over stdio rooted at `root`. */ - spawn(root: string): LspServerHandle; -} - -/** - * Spawn a child process for an LSP server over stdio. Inherits `process.env` - * (so a PATH-resident `rojo` is visible to luau-lsp's sourcemap autogenerate) - * and merges any `env` from the server config on top. - */ -function spawnServer( - command: string[], - cwd: string, - env: Record<string, string> | undefined, - initialization: Record<string, unknown> | undefined, -): LspServerHandle { - const [cmd, ...args] = command; - if (!cmd) throw new Error("LSP server command is empty"); - const proc = spawn(cmd, args, { - cwd, - env: { ...process.env, ...env }, - stdio: ["pipe", "pipe", "pipe"], - }) as ChildProcessWithoutNullStreams; - return { - process: proc, - ...(initialization ? { initialization } : {}), - }; -} - -/** - * Turn the parsed `dispatch.toml` `lsp` block into a list of spawnable - * servers. Disabled entries are dropped. Entries with no `command`/`extensions` - * are skipped defensively (the config validator already enforces these, but we - * guard here too so a hand-built config object can't crash the manager). - */ -export function resolveServersFromConfig( - lsp: Record<string, LspServerConfig> | undefined, -): ResolvedLspServer[] { - if (!lsp) return []; - const servers: ResolvedLspServer[] = []; - for (const [id, entry] of Object.entries(lsp)) { - if (entry.disabled) continue; - if (!entry.command || entry.command.length === 0) continue; - if (!entry.extensions || entry.extensions.length === 0) continue; - const command = entry.command; - const env = entry.env; - const initialization = entry.initialization; - servers.push({ - id, - extensions: entry.extensions, - spawn: (root: string) => spawnServer(command, root, env, initialization), - }); - } - return servers; -} diff --git a/packages/core/src/models/attachments.ts b/packages/core/src/models/attachments.ts deleted file mode 100644 index 5c98db4..0000000 --- a/packages/core/src/models/attachments.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Validation + limits for multimodal user attachments (images / PDFs). -// -// Kept dependency-free (no DB / `bun:sqlite` import) so both the API layer -// (`/chat` request validation) and any future caller can share the exact same -// allowlist and size/count ceilings. The limits mirror Anthropic's documented -// vision/PDF API constraints (the only image-capable providers Dispatch maps), -// so a request that passes here won't be rejected by the provider for size. - -import type { UserAttachmentPart, UserContentPart } from "../types/index.js"; - -/** Accepted image media types. */ -export const ACCEPTED_IMAGE_MEDIA_TYPES = [ - "image/png", - "image/jpeg", - "image/webp", - "image/gif", -] as const; - -/** Accepted document media types. */ -export const ACCEPTED_PDF_MEDIA_TYPE = "application/pdf"; - -/** Every media type we accept as an attachment. */ -export const ACCEPTED_ATTACHMENT_MEDIA_TYPES = [ - ...ACCEPTED_IMAGE_MEDIA_TYPES, - ACCEPTED_PDF_MEDIA_TYPE, -] as const; - -/** Per-image byte ceiling (Anthropic: 5 MB/image). */ -export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; - -/** Per-PDF byte ceiling (Anthropic: 32 MB/PDF). */ -export const MAX_PDF_BYTES = 32 * 1024 * 1024; - -/** Max attachments per message (Anthropic: 20 images/request). */ -export const MAX_ATTACHMENTS = 20; - -/** - * Total attachment payload ceiling for a single request (decoded bytes). Bounds - * the overall request size even when each individual file is within its limit. - */ -export const MAX_TOTAL_ATTACHMENT_BYTES = 32 * 1024 * 1024; - -/** Whether a media type is an accepted image type. */ -export function isImageMediaType(mediaType: string): boolean { - return (ACCEPTED_IMAGE_MEDIA_TYPES as readonly string[]).includes(mediaType); -} - -/** Whether a media type is the accepted PDF type. */ -export function isPdfMediaType(mediaType: string): boolean { - return mediaType === ACCEPTED_PDF_MEDIA_TYPE; -} - -/** Whether a media type is an accepted attachment type at all. */ -export function isAcceptedAttachmentMediaType(mediaType: string): boolean { - return (ACCEPTED_ATTACHMENT_MEDIA_TYPES as readonly string[]).includes(mediaType); -} - -/** - * Decoded byte length of a base64 string, computed WITHOUT allocating the - * decoded buffer. Tolerates an optional `data:<mediaType>;base64,` prefix and - * any embedded whitespace/newlines. Returns 0 for an empty/whitespace string. - */ -export function base64ByteLength(b64: string): number { - // Strip a data-URI prefix if present. - const comma = b64.indexOf(","); - const body = b64.startsWith("data:") && comma !== -1 ? b64.slice(comma + 1) : b64; - let len = 0; - let pad = 0; - for (let i = 0; i < body.length; i++) { - const ch = body.charCodeAt(i); - // Skip whitespace (space, \t, \n, \r). - if (ch === 32 || ch === 9 || ch === 10 || ch === 13) continue; - len++; - if (body[i] === "=") pad++; - } - if (len === 0) return 0; - // 4 base64 chars → 3 bytes, minus padding. - return Math.floor((len * 3) / 4) - pad; -} - -export type AttachmentValidationError = - | { code: "unsupported-type"; mediaType: string } - | { code: "image-too-large"; mediaType: string; bytes: number; limit: number } - | { code: "pdf-too-large"; bytes: number; limit: number } - | { code: "too-many"; count: number; limit: number } - | { code: "total-too-large"; bytes: number; limit: number } - | { code: "empty"; mediaType: string }; - -export interface AttachmentValidationResult { - ok: boolean; - errors: AttachmentValidationError[]; -} - -/** Extract just the attachment parts from a mixed content list. */ -function attachmentsOf(content: UserContentPart[]): UserAttachmentPart[] { - return content.filter((p): p is UserAttachmentPart => p.type === "attachment"); -} - -/** - * Validate the attachments in a multimodal user content list against the - * media-type allowlist and the size/count ceilings. Pure: never throws, - * collects every violation so the caller can report them all at once. - * - * Text parts are ignored (always valid). An empty content list is valid (it's - * just a text-only message expressed as parts). - */ -export function validateUserContent(content: UserContentPart[]): AttachmentValidationResult { - const errors: AttachmentValidationError[] = []; - const attachments = attachmentsOf(content); - - if (attachments.length > MAX_ATTACHMENTS) { - errors.push({ code: "too-many", count: attachments.length, limit: MAX_ATTACHMENTS }); - } - - let total = 0; - for (const att of attachments) { - if (!isAcceptedAttachmentMediaType(att.mediaType)) { - errors.push({ code: "unsupported-type", mediaType: att.mediaType }); - continue; - } - const bytes = base64ByteLength(att.data); - total += bytes; - if (bytes === 0) { - errors.push({ code: "empty", mediaType: att.mediaType }); - continue; - } - if (isPdfMediaType(att.mediaType)) { - if (bytes > MAX_PDF_BYTES) { - errors.push({ code: "pdf-too-large", bytes, limit: MAX_PDF_BYTES }); - } - } else if (bytes > MAX_IMAGE_BYTES) { - errors.push({ - code: "image-too-large", - mediaType: att.mediaType, - bytes, - limit: MAX_IMAGE_BYTES, - }); - } - } - - if (total > MAX_TOTAL_ATTACHMENT_BYTES) { - errors.push({ code: "total-too-large", bytes: total, limit: MAX_TOTAL_ATTACHMENT_BYTES }); - } - - return { ok: errors.length === 0, errors }; -} - -/** Convenience: does the content list contain at least one attachment? */ -export function hasAttachments(content: UserContentPart[] | undefined | null): boolean { - return !!content && content.some((p) => p.type === "attachment"); -} diff --git a/packages/core/src/models/catalog.ts b/packages/core/src/models/catalog.ts deleted file mode 100644 index ac310b1..0000000 --- a/packages/core/src/models/catalog.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; - -/** - * models.dev-backed model catalog. Resolves a model's MAXIMUM context window - * (`limit.context`) dynamically from the public models.dev API, mirroring how - * opencode determines per-model context limits — no hardcoded table. - * - * The catalog is fetched once, cached on disk with a short TTL, and reused. On - * fetch failure we fall back to a stale-but-present cache so the lookup keeps - * working offline. Lookups never throw: an unknown/unreachable model resolves - * to `null`, which the UI renders as "max unknown". - */ - -/** Shape of the slice of models.dev's `/api.json` we consume. */ -interface ModelsDevModel { - limit?: { - context?: number; - output?: number; - }; - /** - * Input/output modalities the model accepts. We read `input` to decide - * whether the model can take image / pdf attachments. Absent on older - * catalog entries — treated as "unknown" (capability resolves to `null`). - */ - modalities?: { - input?: string[]; - output?: string[]; - }; -} - -interface ModelsDevProvider { - id: string; - models: Record<string, ModelsDevModel | undefined>; -} - -type ModelsDevCatalog = Record<string, ModelsDevProvider | undefined>; - -/** Where models.dev's API lives. Overridable for tests / private mirrors. */ -const MODELS_URL = process.env.DISPATCH_MODELS_URL || "https://models.dev"; - -/** Disk cache path (reuses the repo's `/tmp/dispatch` convention). */ -const CACHE_PATH = "/tmp/dispatch/models-dev.json"; - -/** How long a cached catalog stays fresh before we re-fetch. */ -const CACHE_TTL_MS = 5 * 60 * 1000; - -/** Network timeout for the catalog fetch. */ -const FETCH_TIMEOUT_MS = 10_000; - -/** - * After a failed fetch we memoize the fallback for this long before retrying, - * so a sustained outage doesn't make every lookup hang on a fresh timeout. - */ -const FETCH_PENALTY_MS = 60_000; - -/** - * Dispatch provider id → models.dev provider ids to search, in priority order. - * We only support Claude-backed providers (per product scope). `anthropic` and - * `opencode-anthropic` are both Claude; we try the first-party `anthropic` - * catalog first, then the `opencode` gateway catalog as a fallback. - */ -const PROVIDER_MAP: Record<string, string[]> = { - anthropic: ["anthropic", "opencode"], - "opencode-anthropic": ["anthropic", "opencode"], -}; - -/** In-process memoized catalog promise (one fetch/parse per TTL window). */ -let cached: { catalog: ModelsDevCatalog; fetchedAt: number } | null = null; -let inflight: Promise<ModelsDevCatalog> | null = null; - -function readDiskCache(): { catalog: ModelsDevCatalog; mtimeMs: number } | null { - try { - const stat = statSync(CACHE_PATH); - const text = readFileSync(CACHE_PATH, "utf-8"); - return { catalog: JSON.parse(text) as ModelsDevCatalog, mtimeMs: stat.mtimeMs }; - } catch { - return null; - } -} - -function writeDiskCache(text: string): void { - try { - mkdirSync(dirname(CACHE_PATH), { recursive: true }); - // Write-then-rename so a concurrent reader never sees a half-written - // file (rename is atomic on the same filesystem). The temp name is - // process-scoped to avoid two writers clobbering each other's temp. - const tmp = `${CACHE_PATH}.${process.pid}.tmp`; - writeFileSync(tmp, text, "utf-8"); - renameSync(tmp, CACHE_PATH); - } catch { - // Best-effort: a read-only /tmp shouldn't break lookups. - } -} - -async function fetchCatalog(): Promise<ModelsDevCatalog> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - const res = await fetch(`${MODELS_URL}/api.json`, { signal: controller.signal }); - if (!res.ok) throw new Error(`models.dev returned HTTP ${res.status}`); - const text = await res.text(); - const catalog = JSON.parse(text) as ModelsDevCatalog; - writeDiskCache(text); - return catalog; - } finally { - clearTimeout(timer); - } -} - -/** - * Load the models.dev catalog, preferring in-process memo, then a fresh disk - * cache, then a network fetch. On network failure, falls back to any stale - * disk cache; if nothing is available, returns an empty catalog. - */ -export async function getModelsCatalog(): Promise<ModelsDevCatalog> { - if (process.env.DISPATCH_DISABLE_MODELS_FETCH) { - const disk = readDiskCache(); - return disk?.catalog ?? {}; - } - - const now = Date.now(); - if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.catalog; - - // Fresh disk cache satisfies the request without a network round-trip. - const disk = readDiskCache(); - if (disk && now - disk.mtimeMs < CACHE_TTL_MS) { - // Inherit the file's mtime as `fetchedAt` so loading a disk cache into - // a fresh process doesn't reset its TTL (which would otherwise double - // the worst-case staleness across process boundaries). - cached = { catalog: disk.catalog, fetchedAt: disk.mtimeMs }; - return disk.catalog; - } - - if (!inflight) { - inflight = fetchCatalog() - .then((catalog) => { - cached = { catalog, fetchedAt: Date.now() }; - return catalog; - }) - .catch((err) => { - // Network failed — serve a stale cache if we have one. - console.warn( - `dispatch: failed to fetch models.dev catalog: ${err instanceof Error ? err.message : String(err)}`, - ); - const fallback = disk?.catalog ?? ({} as ModelsDevCatalog); - // Memoize the fallback with a short "penalty" TTL so a sustained - // outage doesn't make every lookup hang on a fresh 10s timeout. - // `fetchedAt` is backdated so the memo expires after FETCH_PENALTY_MS. - cached = { - catalog: fallback, - fetchedAt: Date.now() - CACHE_TTL_MS + FETCH_PENALTY_MS, - }; - return fallback; - }) - .finally(() => { - inflight = null; - }); - } - return inflight; -} - -/** - * Resolve a model's maximum context window (in tokens) for the given Dispatch - * provider + model id. Returns `null` when the provider is unsupported, the - * model is unknown, or the catalog is unavailable — callers should render that - * as "max unknown" (no denominator / percentage). - */ -export async function resolveContextLimit( - provider: string, - modelId: string, -): Promise<number | null> { - const candidates = PROVIDER_MAP[provider]; - if (!candidates || !modelId) return null; - - const catalog = await getModelsCatalog(); - for (const providerId of candidates) { - const ctx = catalog[providerId]?.models?.[modelId]?.limit?.context; - if (typeof ctx === "number" && ctx > 0) return ctx; - } - return null; -} - -/** - * Image / PDF input capabilities for a model, resolved from the models.dev - * catalog's `modalities.input` list. - */ -export interface ModelInputCapabilities { - /** Model accepts image input (vision). */ - image: boolean; - /** Model accepts PDF/document input. */ - pdf: boolean; -} - -/** - * Resolve whether a model accepts image / pdf input for the given Dispatch - * provider + model id. Returns `null` when the capability is UNKNOWN — i.e. the - * provider is unsupported/unmapped, the model is absent from the catalog, the - * entry predates the `modalities` field, or the catalog is unavailable. Callers - * should treat `null` as "can't verify" (optimistic allow) rather than a - * definitive "no", so a temporary catalog outage never disables a known-good - * vision model. - * - * A non-null result means the catalog DID describe the model's input modalities - * — `{ image, pdf }` then reflects exactly what it advertises (a definitive - * yes/no for each). - */ -export async function resolveModelCapabilities( - provider: string, - modelId: string, -): Promise<ModelInputCapabilities | null> { - const candidates = PROVIDER_MAP[provider]; - if (!candidates || !modelId) return null; - - const catalog = await getModelsCatalog(); - for (const providerId of candidates) { - const input = catalog[providerId]?.models?.[modelId]?.modalities?.input; - if (Array.isArray(input)) { - return { image: input.includes("image"), pdf: input.includes("pdf") }; - } - } - return null; -} - -/** Test-only: reset the in-process memo so a test can re-exercise loading. */ -export function __resetCatalogCacheForTests(): void { - cached = null; - inflight = null; -} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts deleted file mode 100644 index 15d1ee2..0000000 --- a/packages/core/src/models/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export { - ACCEPTED_ATTACHMENT_MEDIA_TYPES, - ACCEPTED_IMAGE_MEDIA_TYPES, - ACCEPTED_PDF_MEDIA_TYPE, - type AttachmentValidationError, - type AttachmentValidationResult, - base64ByteLength, - hasAttachments, - isAcceptedAttachmentMediaType, - isImageMediaType, - isPdfMediaType, - MAX_ATTACHMENTS, - MAX_IMAGE_BYTES, - MAX_PDF_BYTES, - MAX_TOTAL_ATTACHMENT_BYTES, - validateUserContent, -} from "./attachments.js"; -export { - getModelsCatalog, - type ModelInputCapabilities, - resolveContextLimit, - resolveModelCapabilities, -} from "./catalog.js"; -export { ModelRegistry } from "./registry.js"; diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts deleted file mode 100644 index 4a24a51..0000000 --- a/packages/core/src/models/registry.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { KeyDefinition, KeyState } from "../types/index.js"; - -export class ModelRegistry { - private keyStates: Map<string, KeyState>; - private keyOrder: string[]; - - constructor(keys: KeyDefinition[]) { - this.keyStates = new Map(); - this.keyOrder = []; - this._initConfig(keys, new Map()); - } - - private _initConfig(keys: KeyDefinition[], existingStates: Map<string, KeyState>): void { - this.keyOrder = keys.map((k) => k.id); - - const newStates = new Map<string, KeyState>(); - for (const key of keys) { - const existing = existingStates.get(key.id); - if (existing) { - // Preserve existing state but update definition - newStates.set(key.id, { ...existing, definition: key }); - } else { - newStates.set(key.id, { definition: key, status: "active" }); - } - } - this.keyStates = newStates; - } - - getKeys(): KeyState[] { - return this.keyOrder - .map((id) => this.keyStates.get(id)) - .filter((state): state is KeyState => state !== undefined); - } - - markKeyExhausted(keyId: string, error?: string): void { - const state = this.keyStates.get(keyId); - if (!state) return; - this.keyStates.set(keyId, { - ...state, - status: "exhausted", - lastError: error, - exhaustedAt: Date.now(), - }); - } - - markKeyActive(keyId: string): void { - const state = this.keyStates.get(keyId); - if (!state) return; - const updated: KeyState = { - definition: state.definition, - status: "active", - }; - this.keyStates.set(keyId, updated); - } - - hasAvailableKey(provider: string): boolean { - for (const state of this.keyStates.values()) { - if (state.definition.provider === provider && state.status === "active") { - return true; - } - } - return false; - } - - allKeysExhausted(): boolean { - for (const state of this.keyStates.values()) { - if (state.status === "active") { - return false; - } - } - return true; - } - - updateConfig(keys: KeyDefinition[]): void { - this._initConfig(keys, this.keyStates); - } - - // Internal: get ordered key states for a specific provider - getOrderedKeysForProvider(provider: string): KeyState[] { - return this.keyOrder - .map((id) => this.keyStates.get(id)) - .filter( - (state): state is KeyState => state !== undefined && state.definition.provider === provider, - ); - } -} diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts deleted file mode 100644 index 49e6ff4..0000000 --- a/packages/core/src/notifications/config.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Persisted ntfy config — single global JSON blob under one settings key. -// -// One global config (no per-user split): the rest of Dispatch's settings -// table is global today (cf. `title_model_*`, `perm_*`), so notification -// config follows the same pattern. - -import { deleteSetting, getSetting, setSetting } from "../db/settings.js"; -import type { NotificationEventType, NtfyConfig } from "./types.js"; -import { NTFY_DEFAULT_EVENTS, NTFY_EVENT_TYPES } from "./types.js"; - -export const NTFY_CONFIG_KEY = "ntfy_config"; - -/** Defaults returned when nothing is persisted yet. */ -export function defaultNtfyConfig(): NtfyConfig { - return { - enabled: false, - topic: "", - authToken: "", - events: { ...NTFY_DEFAULT_EVENTS }, - notifySubagents: false, - }; -} - -/** - * Normalize an arbitrary parsed JSON value into a complete `NtfyConfig`. - * Tolerant of missing / unexpected fields so a config from an older build - * never throws — missing event toggles fall back to defaults. - */ -export function normalizeNtfyConfig(raw: unknown): NtfyConfig { - const base = defaultNtfyConfig(); - if (!raw || typeof raw !== "object") return base; - const obj = raw as Record<string, unknown>; - const out: NtfyConfig = { - enabled: typeof obj.enabled === "boolean" ? obj.enabled : base.enabled, - topic: typeof obj.topic === "string" ? obj.topic : base.topic, - authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken, - events: { ...base.events }, - notifySubagents: - typeof obj.notifySubagents === "boolean" ? obj.notifySubagents : base.notifySubagents, - }; - const rawEvents = obj.events; - if (rawEvents && typeof rawEvents === "object") { - const evObj = rawEvents as Record<string, unknown>; - for (const key of NTFY_EVENT_TYPES) { - const v = evObj[key]; - if (typeof v === "boolean") out.events[key as NotificationEventType] = v; - } - } - return out; -} - -/** Load the persisted config (or defaults if none/corrupt). */ -export function loadNtfyConfig(): NtfyConfig { - const raw = getSetting(NTFY_CONFIG_KEY); - if (!raw) return defaultNtfyConfig(); - try { - return normalizeNtfyConfig(JSON.parse(raw)); - } catch { - return defaultNtfyConfig(); - } -} - -/** Persist a complete config (after server-side normalization). */ -export function saveNtfyConfig(config: NtfyConfig): void { - const normalized = normalizeNtfyConfig(config); - setSetting(NTFY_CONFIG_KEY, JSON.stringify(normalized)); -} - -/** Wipe the persisted config (revert to defaults on next load). */ -export function clearNtfyConfig(): void { - deleteSetting(NTFY_CONFIG_KEY); -} - -/** Strip the auth token from a config before returning it over the API. */ -export function redactNtfyConfig(config: NtfyConfig): NtfyConfig & { hasAuthToken: boolean } { - return { ...config, authToken: "", hasAuthToken: config.authToken.trim().length > 0 }; -} diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts deleted file mode 100644 index 01ce00c..0000000 --- a/packages/core/src/notifications/dispatcher.ts +++ /dev/null @@ -1,287 +0,0 @@ -// NotificationDispatcher — turns high-level Dispatch events into -// `sendNtfy(...)` calls, gated by the persisted user config. -// -// The dispatcher is transport-agnostic at the `notify(event)` interface -// boundary: only `sendNtfy` is wired today, but adding another transport -// (email, webhook, etc.) means changing this one file, not the call sites. -// -// All sends are non-blocking (fire-and-forget). A 10-second timeout in -// `sendNtfy` bounds the worst case; the dispatcher additionally guards -// every send in a try/catch so a transport bug can never propagate into -// the agent loop. - -import { loadNtfyConfig } from "./config.js"; -import { type FetchLike, sendNtfy } from "./ntfy.js"; -import type { NotificationEvent, NtfyConfig } from "./types.js"; - -/** Minimal shape of an `AgentManager`-style event stream we hook into. */ -export interface AgentEventSource { - onEvent( - listener: (event: { type: string; tabId: string; [key: string]: unknown }) => void, - ): () => void; -} - -/** Minimal shape of a `PermissionManager`-style prompt source. */ -export interface PermissionPromptSource { - onPromptAdded( - listener: (prompt: { id: string; permission: string; description: string }) => void, - ): () => void; -} - -/** Look up a human-readable tab title for nicer notification text. */ -export type TabTitleLookup = (tabId: string) => string | null; - -/** - * Look up a tab's `parentTabId`. Returns `null` for top-level tabs (no - * parent) and `undefined` when the lookup can't be performed (no DB, tab - * not found). Both non-strings cause the dispatcher to fall back to - * "treat as top-level" to avoid silently dropping notifications when the - * lookup is broken. - */ -export type TabParentLookup = (tabId: string) => string | null | undefined; - -export interface DispatcherOptions { - /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */ - loadConfig?: () => NtfyConfig; - /** Override the transport (tests). Defaults to the real `sendNtfy`. */ - send?: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>; - /** Optional fetch override (forwarded to `sendNtfy` when `send` not set). */ - fetchImpl?: FetchLike; - /** Look up a tab title for richer titles. */ - getTabTitle?: TabTitleLookup; - /** - * Look up a tab's `parentTabId`. Used to honour the - * `notifySubagents` config flag — when false, `turn-completed` / - * `turn-error` from subagent tabs (those with a parent) are - * suppressed. - */ - getTabParentId?: TabParentLookup; - /** - * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit - * the whole pending list on every change, so dedupe is essential. - */ - dedupeWindowMs?: number; -} - -export class NotificationDispatcher { - private loadConfig: () => NtfyConfig; - private send: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>; - private getTabTitle: TabTitleLookup | undefined; - private getTabParentId: TabParentLookup | undefined; - private dedupeWindowMs: number; - /** Recently-sent dedupeKey → expiresAt epoch ms. */ - private recentlySent = new Map<string, number>(); - private unsubs: Array<() => void> = []; - - constructor(opts: DispatcherOptions = {}) { - this.loadConfig = opts.loadConfig ?? loadNtfyConfig; - this.send = - opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined)); - this.getTabTitle = opts.getTabTitle; - this.getTabParentId = opts.getTabParentId; - this.dedupeWindowMs = opts.dedupeWindowMs ?? 5_000; - } - - /** - * Single internal entry point — every public hook funnels through here. - * Public so a future caller can synthesize an arbitrary notification - * (e.g. a CLI `dispatch notify` command); kept narrow. - */ - notify(event: NotificationEvent): void { - const config = this.loadConfig(); - if (!config.enabled) return; - if (!config.events[event.type]) return; - if (event.dedupeKey && this.isDuplicate(event.dedupeKey)) return; - if (event.dedupeKey) this.markSent(event.dedupeKey); - - // Fire-and-forget: never await, never throw. - try { - void Promise.resolve(this.send(config, event)).catch((err) => { - console.warn( - `[ntfy] send failed for ${event.type}: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - } catch (err) { - // Guard the synchronous portion of `send` too. - console.warn( - `[ntfy] dispatch threw for ${event.type}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - /** - * Hook into an `AgentManager`-style event stream. - * - * Maps: - * - `done` → `turn-completed` - * - `error` → `turn-error` - * - `tab-created` → `agent-spawned` (only top-level user-agent tabs) - * - * `status` events are ignored — they fire on every transition and we'd - * either spam or duplicate the `done`/`error` notifications. - * - * Turn events from subagent tabs are suppressed when - * `config.notifySubagents === false` (the default). A parent agent - * spawning 8 subagents would otherwise produce 9 "Turn complete" - * pushes per round; almost always noise. Permission prompts are NOT - * gated this way — a subagent's permission request still needs human - * input to proceed, so suppressing those would silently hang the - * subagent. - */ - attachToAgentManager(source: AgentEventSource): () => void { - const unsub = source.onEvent((event) => { - if (event.type === "done") { - if (this.isSubagentSuppressed(event.tabId)) return; - this.notify(this.buildTurnCompleted(event)); - } else if (event.type === "error") { - if (this.isSubagentSuppressed(event.tabId)) return; - this.notify(this.buildTurnError(event)); - } else if (event.type === "tab-created") { - const ev = event as unknown as { - tabId: string; - id: string; - title: string; - parentTabId: string | null; - agentSlug?: string | null; - }; - // Only notify for top-level user-agent tabs spawned via `summon`. - // Filtering on `agentSlug` skips "blank" new tabs the user opened - // manually, which would be noisy. - if (ev.parentTabId === null && ev.agentSlug) { - this.notify(this.buildAgentSpawned(ev)); - } - } - }); - this.unsubs.push(unsub); - return unsub; - } - - /** Hook into a `PermissionManager`-style prompt source. */ - attachToPermissionManager(source: PermissionPromptSource): () => void { - const unsub = source.onPromptAdded((prompt) => { - this.notify(this.buildPermissionRequired(prompt)); - }); - this.unsubs.push(unsub); - return unsub; - } - - /** Release all hooks acquired via `attachTo*`. */ - dispose(): void { - for (const u of this.unsubs) { - try { - u(); - } catch { - // best-effort - } - } - this.unsubs = []; - this.recentlySent.clear(); - } - - // ─── Event builders (internal) ──────────────────────────────── - - private buildTurnCompleted(event: { tabId: string }): NotificationEvent { - const tabLabel = this.tabLabel(event.tabId); - return { - type: "turn-completed", - title: `Turn complete — ${tabLabel}`, - message: `Assistant finished a turn in ${tabLabel}.`, - tabId: event.tabId, - }; - } - - private buildTurnError(event: { - tabId: string; - error?: unknown; - statusCode?: unknown; - }): NotificationEvent { - const tabLabel = this.tabLabel(event.tabId); - const errText = typeof event.error === "string" ? event.error : "Unknown error"; - const statusText = typeof event.statusCode === "number" ? ` (status ${event.statusCode})` : ""; - return { - type: "turn-error", - title: `Turn failed — ${tabLabel}`, - message: `${errText}${statusText}`, - tabId: event.tabId, - }; - } - - private buildPermissionRequired(prompt: { - id: string; - permission: string; - description: string; - }): NotificationEvent { - return { - type: "permission-required", - title: `Permission required: ${prompt.permission}`, - message: prompt.description || `Agent is requesting ${prompt.permission} permission.`, - // Permission prompts can re-emit (e.g. another prompt arrives while - // this one is still pending) — dedupe on the prompt id. - dedupeKey: `permission:${prompt.id}`, - }; - } - - private buildAgentSpawned(ev: { - tabId: string; - id: string; - title: string; - agentSlug?: string | null; - }): NotificationEvent { - return { - type: "agent-spawned", - title: `User agent spawned — ${ev.agentSlug ?? "agent"}`, - message: ev.title, - tabId: ev.tabId ?? ev.id, - }; - } - - private tabLabel(tabId: string): string { - const title = this.getTabTitle?.(tabId); - if (title?.trim()) return title.trim(); - return `tab ${tabId.slice(0, 8)}`; - } - - /** - * Returns true when this `tabId` belongs to a subagent AND the user has - * opted out of subagent turn notifications. On lookup failure - * (`getTabParentId` returns `undefined` or throws) we err on the side - * of "not a subagent" — better to over-notify than to silently drop - * legitimate top-level events when the DB is briefly unreadable. - */ - private isSubagentSuppressed(tabId: string): boolean { - const config = this.loadConfig(); - if (config.notifySubagents) return false; - if (!this.getTabParentId) return false; - let parent: string | null | undefined; - try { - parent = this.getTabParentId(tabId); - } catch { - return false; - } - // Only a non-empty string parent id means "this tab is a subagent". - return typeof parent === "string" && parent.length > 0; - } - - // ─── Dedupe helpers ─────────────────────────────────────────── - - private isDuplicate(key: string): boolean { - const expires = this.recentlySent.get(key); - if (expires === undefined) return false; - if (expires <= Date.now()) { - this.recentlySent.delete(key); - return false; - } - return true; - } - - private markSent(key: string): void { - // Lazy-evict expired entries when the map gets large. - if (this.recentlySent.size > 256) { - const now = Date.now(); - for (const [k, exp] of this.recentlySent) { - if (exp <= now) this.recentlySent.delete(k); - } - } - this.recentlySent.set(key, Date.now() + this.dedupeWindowMs); - } -} diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts deleted file mode 100644 index d1e7891..0000000 --- a/packages/core/src/notifications/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @dispatch/core — ntfy.sh push notifications - -export { - clearNtfyConfig, - defaultNtfyConfig, - loadNtfyConfig, - NTFY_CONFIG_KEY, - normalizeNtfyConfig, - redactNtfyConfig, - saveNtfyConfig, -} from "./config.js"; -export { - type AgentEventSource, - type DispatcherOptions, - NotificationDispatcher, - type PermissionPromptSource, - type TabParentLookup, - type TabTitleLookup, -} from "./dispatcher.js"; -export { - buildNtfyUrl, - type FetchLike, - NTFY_BASE_URL, - type NtfySendResult, - sendNtfy, -} from "./ntfy.js"; -export { - type NotificationEvent, - type NotificationEventType, - NTFY_DEFAULT_EVENTS, - NTFY_DEFAULT_PRIORITIES, - NTFY_DEFAULT_TAGS, - NTFY_EVENT_TYPES, - type NtfyConfig, - type NtfyPriority, -} from "./types.js"; diff --git a/packages/core/src/notifications/ntfy.ts b/packages/core/src/notifications/ntfy.ts deleted file mode 100644 index eb5de9e..0000000 --- a/packages/core/src/notifications/ntfy.ts +++ /dev/null @@ -1,149 +0,0 @@ -// ntfy.sh HTTP transport. -// -// ntfy's API is a simple POST to `https://ntfy.sh/<topic>` with the body -// as the message and metadata passed via HTTP headers: -// Title: notification title -// Priority: 1..5 (3 = default) -// Tags: comma-separated emoji shortcodes -// Click: URL opened when the notification is tapped -// -// The server is hardcoded to the public ntfy.sh instance; the user only -// configures a topic name. We intentionally use `fetch` directly — no -// SDK, no extra deps. - -import type { NotificationEvent, NtfyConfig } from "./types.js"; -import { NTFY_DEFAULT_PRIORITIES, NTFY_DEFAULT_TAGS } from "./types.js"; - -export interface NtfySendResult { - ok: boolean; - status?: number; - error?: string; -} - -/** - * Lightweight fetch shape so callers (and tests) can inject a mock without - * pulling in the DOM `fetch` type from a `Headers` instance. - */ -export type FetchLike = ( - input: string, - init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }, -) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise<string> }>; - -/** Base URL of the public ntfy.sh server. */ -export const NTFY_BASE_URL = "https://ntfy.sh"; - -/** - * Build the publish URL for a topic name. - * - * No client-side validation of the topic content: ntfy.sh's accepted - * character set has changed over time and a regex here only locks users - * out of legitimate topics. The topic is URL-encoded so the resulting - * URL is always syntactically valid; if ntfy rejects the name the HTTP - * error surfaces on the first send / `Send test`. - */ -export function buildNtfyUrl(topic: string): string { - return `${NTFY_BASE_URL}/${encodeURIComponent(topic.trim())}`; -} - -/** - * Send a single notification to the configured ntfy topic. - * - * Fire-and-forget at call sites: the dispatcher uses - * `void sendNtfy(...).catch(...)` so a slow/broken ntfy server never blocks - * a turn. We still return a structured result so the explicit - * `POST /notifications/test` route can surface failures back to the UI. - * - * Pure with respect to `config` / `event` — no DB, no module state. - */ -export async function sendNtfy( - config: NtfyConfig, - event: NotificationEvent, - fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike, - timeoutMs = 10_000, -): Promise<NtfySendResult> { - if (!config.enabled) return { ok: false, error: "Notifications are disabled" }; - if (!config.topic.trim()) return { ok: false, error: "Topic is required" }; - const targetUrl = buildNtfyUrl(config.topic); - - const priority = event.priority ?? NTFY_DEFAULT_PRIORITIES[event.type] ?? 3; - const baseTags = event.tags ?? NTFY_DEFAULT_TAGS[event.type] ?? []; - const tags = [...baseTags]; - if (event.tabId) { - // Short, ASCII-only tag so ntfy's comma-separated header parser is happy. - tags.push(`tab-${event.tabId.slice(0, 8)}`); - } - - const headers: Record<string, string> = { - // ntfy is tolerant of non-ASCII in the Title header but many proxies - // aren't — sanitizeHeader strips CR/LF/control chars (injection guard) - // and leaves UTF-8 in place. Body is sent verbatim as UTF-8. - Title: sanitizeHeader(event.title), - Priority: String(priority), - "Content-Type": "text/plain; charset=utf-8", - }; - if (tags.length > 0) headers.Tags = tags.map(sanitizeHeader).join(","); - if (event.clickUrl) headers.Click = sanitizeHeader(event.clickUrl); - const authValue = buildAuthHeaderValue(config.authToken); - if (authValue) headers.Authorization = authValue; - - // Per-request abort so a hung server doesn't pin a Bun worker forever. - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await fetchImpl(targetUrl, { - method: "POST", - headers, - body: event.message, - signal: controller.signal, - }); - if (!res.ok) { - const text = await safeReadText(res); - return { - ok: false, - status: res.status, - error: `ntfy responded ${res.status} ${res.statusText ?? ""}: ${text}`.trim(), - }; - } - return { ok: true, status: res.status }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { ok: false, error: msg }; - } finally { - clearTimeout(timer); - } -} - -/** - * Build the `Authorization` header value from the user-configured token. - * - * - Empty/blank ⇒ no header. - * - Already starts with `Bearer ` / `Basic ` / etc. (RFC 7235 scheme + space) - * ⇒ used verbatim. Lets users paste a complete header for Basic auth or - * any other scheme their private ntfy server supports. - * - Otherwise ⇒ prefixed with `Bearer ` (the common case). - */ -function buildAuthHeaderValue(rawToken: string): string | null { - const trimmed = (rawToken ?? "").trim(); - if (!trimmed) return null; - if (/^[A-Za-z][A-Za-z0-9._~+/-]*\s+\S/.test(trimmed)) { - // Already includes a scheme token (e.g. "Bearer xyz", "Basic dXNlcjpw"). - return sanitizeHeader(trimmed); - } - return `Bearer ${sanitizeHeader(trimmed)}`; -} - -function sanitizeHeader(value: string): string { - // Strip CR/LF (header injection guard) and other control chars, then trim. - // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional - return value.replace(/[\r\n\u0000-\u001f]+/g, " ").trim(); -} - -async function safeReadText(res: { text(): Promise<string> }): Promise<string> { - try { - const t = await res.text(); - return t.length > 200 ? `${t.slice(0, 200)}…` : t; - } catch { - return ""; - } -} diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts deleted file mode 100644 index ef6c059..0000000 --- a/packages/core/src/notifications/types.ts +++ /dev/null @@ -1,108 +0,0 @@ -// ntfy.sh push notifications — types - -/** - * Catalog of notification-worthy events. - * - * Kept intentionally small and stable: each entry is something a human - * actually wants pushed to their phone. New event types should be added - * with a sensible default (`NTFY_DEFAULT_EVENTS`) and a mapping in the - * dispatcher. - */ -export type NotificationEventType = - | "turn-completed" - | "turn-error" - | "permission-required" - | "agent-spawned"; - -/** ntfy priority levels (1=min … 5=max). */ -export type NtfyPriority = 1 | 2 | 3 | 4 | 5; - -/** - * A single notification request. Synthesised by the dispatcher from a - * higher-level event source (AgentManager / PermissionManager); fed to - * the ntfy transport. - * - * `dedupeKey` lets the dispatcher suppress duplicate sends (e.g. the - * permission system re-emits the pending list on every change). - */ -export interface NotificationEvent { - type: NotificationEventType; - /** Notification title (short). */ - title: string; - /** Notification body. */ - message: string; - /** Optional ntfy tags (emoji shortcodes — e.g. `["white_check_mark"]`). */ - tags?: string[]; - /** Optional priority override. Defaults are per-event-type. */ - priority?: NtfyPriority; - /** Optional URL the notification deep-links to when tapped. */ - clickUrl?: string; - /** Origin tab id (informational; included in tags as `tab:<short>`). */ - tabId?: string; - /** - * Stable key for suppressing duplicates. Same key + same type within a - * short window ⇒ dropped silently. - */ - dedupeKey?: string; -} - -/** - * Persisted ntfy configuration. Lives in the `settings` table under a - * single key (`ntfy_config`) — one global config, matching the codebase's - * existing single-user assumption (cf. `title_model_*`, `perm_*`). - * - * - `enabled` — master switch. Off ⇒ dispatcher never sends. - * - `topic` — bare ntfy.sh topic name, e.g. `my-secret-topic`. The - * server is hardcoded to https://ntfy.sh; the user only picks a topic. - * Missing ⇒ dispatcher never sends. - * - `authToken` — optional bearer token (rarely needed against ntfy.sh - * directly; preserved for users behind an auth-protected proxy). - * - `events` — per-event-type enable map. Missing entries default to OFF - * so a newly-added event type doesn't silently start firing. - * - `notifySubagents` — when false (default), `turn-completed` and - * `turn-error` notifications from subagent tabs (tabs with a - * `parentTabId`) are suppressed. A parent agent that spawns 8 - * subagents would otherwise push 9 "Turn complete" notifications per - * round — usually noise. `permission-required` is NOT gated: even a - * subagent's permission prompt needs a human tap to proceed. - * `agent-spawned` is already top-level-only by construction. - */ -export interface NtfyConfig { - enabled: boolean; - topic: string; - authToken: string; - events: Record<NotificationEventType, boolean>; - notifySubagents: boolean; -} - -/** All event types this build knows about (the source of truth for UI). */ -export const NTFY_EVENT_TYPES: NotificationEventType[] = [ - "turn-completed", - "turn-error", - "permission-required", - "agent-spawned", -]; - -/** Default per-event-type toggles. */ -export const NTFY_DEFAULT_EVENTS: Record<NotificationEventType, boolean> = { - "turn-completed": true, - "turn-error": true, - "permission-required": true, - "agent-spawned": false, -}; - -/** Default priority per event type (when the event itself doesn't override). */ -export const NTFY_DEFAULT_PRIORITIES: Record<NotificationEventType, NtfyPriority> = { - "turn-completed": 3, - "turn-error": 4, - "permission-required": 4, - "agent-spawned": 2, -}; - -/** Default tag (emoji) per event type. */ -export const NTFY_DEFAULT_TAGS: Record<NotificationEventType, string[]> = { - "turn-completed": ["white_check_mark"], - "turn-error": ["rotating_light"], - "permission-required": ["lock"], - "agent-spawned": ["sparkles"], -}; diff --git a/packages/core/src/permission/evaluate.ts b/packages/core/src/permission/evaluate.ts deleted file mode 100644 index 7a0d235..0000000 --- a/packages/core/src/permission/evaluate.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { PermissionRule, Ruleset } from "./index.js"; -import { Wildcard } from "./wildcard.js"; - -export function evaluate( - permission: string, - pattern: string, - ...rulesets: Ruleset[] -): PermissionRule { - const flat = ([] as PermissionRule[]).concat(...rulesets); - const match = flat.findLast( - (rule) => Wildcard.match(rule.permission, permission) && Wildcard.match(rule.pattern, pattern), - ); - if (match) return match; - return { action: "ask", permission, pattern }; -} diff --git a/packages/core/src/permission/index.ts b/packages/core/src/permission/index.ts deleted file mode 100644 index bf6efaf..0000000 --- a/packages/core/src/permission/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface PermissionRule { - permission: string; - pattern: string; - action: "allow" | "deny" | "ask"; -} - -export type Ruleset = PermissionRule[]; - -export type PermissionReply = "once" | "always" | "reject"; - -export interface PermissionRequest { - permission: string; - patterns: string[]; - always: string[]; - description: string; - metadata: Record<string, unknown>; -} - -export interface PermissionChecker { - ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply>; - getPending(): Array<{ id: string; request: PermissionRequest }>; -} - -export { evaluate } from "./evaluate.js"; -export { PermissionService } from "./service.js"; -export { Wildcard } from "./wildcard.js"; diff --git a/packages/core/src/permission/service.ts b/packages/core/src/permission/service.ts deleted file mode 100644 index 1c8c6f0..0000000 --- a/packages/core/src/permission/service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { evaluate } from "./evaluate.js"; -import type { PermissionReply, PermissionRequest, PermissionRule, Ruleset } from "./index.js"; - -export class PermissionService { - private pending: Map< - string, - { - request: PermissionRequest; - resolve: (reply: PermissionReply) => void; - reject: (err: Error) => void; - } - > = new Map(); - private approved: Ruleset = []; - private idCounter = 0; - - approve(rules: PermissionRule[]): void { - this.approved.push(...rules); - } - - async ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply> { - // Evaluate against all rulesets + approved (approved overrides) for ALL patterns - const patterns = request.always.length > 0 ? request.always : ["*"]; - const results = patterns.map((pattern) => - evaluate(request.permission, pattern, ...rulesets, this.approved), - ); - - // Any deny → deny - const denied = results.find((r) => r.action === "deny"); - if (denied) { - throw new Error(`Permission denied: ${request.permission} ${denied.pattern}`); - } - - // All allow → allow - if (results.every((r) => r.action === "allow")) { - return "once"; - } - - // action === "ask" — create a pending request - const id = String(++this.idCounter); - return new Promise<PermissionReply>((resolve, reject) => { - this.pending.set(id, { request, resolve, reject }); - }); - } - - reply(id: string, reply: PermissionReply): void { - if (reply === "reject") { - // Reject cascade — reject all pending - for (const [pendingId, entry] of this.pending) { - entry.reject(new Error(`Permission rejected: ${entry.request.permission}`)); - this.pending.delete(pendingId); - } - return; - } - - this.resolvePending(id, reply); - } - - getPending(): Array<{ id: string; request: PermissionRequest }> { - return Array.from(this.pending.entries()).map(([id, { request }]) => ({ id, request })); - } - - private resolvePending(id: string, reply: PermissionReply): void { - const entry = this.pending.get(id); - if (!entry) return; - - if (reply === "always") { - // Add rules for each pattern in request.patterns - for (const pattern of entry.request.patterns) { - this.approved.push({ permission: entry.request.permission, pattern, action: "allow" }); - } - } - - entry.resolve(reply); - this.pending.delete(id); - } -} diff --git a/packages/core/src/permission/wildcard.ts b/packages/core/src/permission/wildcard.ts deleted file mode 100644 index 7874680..0000000 --- a/packages/core/src/permission/wildcard.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const Wildcard = { - match(pattern: string, value: string): boolean { - // Escape regex special chars except * and ? - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); - // Convert wildcards - const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, "."); - const regex = new RegExp(`^${regexStr}$`, "s"); - return regex.test(value); - }, -}; diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts deleted file mode 100644 index a3fac70..0000000 --- a/packages/core/src/skills/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - createSkillsWatcher, - getSkillByName, - loadSkills, - resolveSkillsForAgent, -} from "./loader.js"; -export { parseSkillFile } from "./parser.js"; diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts deleted file mode 100644 index 5d043dd..0000000 --- a/packages/core/src/skills/loader.ts +++ /dev/null @@ -1,248 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import chokidar from "chokidar"; -import type { AgentSkillMapping, SkillDefinition, SkillScope } from "../types/index.js"; -import { parseSkillFile } from "./parser.js"; - -// ─── Internal Helpers ──────────────────────────────────────────── - -/** - * Recursively scan a directory for .md skill files. - * The `directory` field on each skill is the relative path from `baseDir` to the file's parent. - * Skips the `agents/` subdirectory (handled separately). - */ -function scanSkillsRecursive(baseDir: string, scope: SkillScope): SkillDefinition[] { - if (!fs.existsSync(baseDir)) return []; - - const results: SkillDefinition[] = []; - - function walk(dir: string) { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - // Skip agents/ at the top level (handled by loadAgentMappings) - const relFromBase = path.relative(baseDir, fullPath); - if (relFromBase === "agents") continue; - walk(fullPath); - } else if (entry.isFile() && entry.name.endsWith(".md")) { - const relDir = path.relative(baseDir, dir); - // relDir is "" for root, "general" for general/, "general/webapps" for nested - const directory = relDir === "." ? "" : relDir; - try { - const content = fs.readFileSync(fullPath, "utf-8"); - const skill = parseSkillFile(fullPath, content, scope, directory); - results.push(skill); - } catch { - // Skip unreadable files - } - } - } - } - - walk(baseDir); - return results; -} - -function loadAgentMappings(agentsDir: string, scope: SkillScope): AgentSkillMapping[] { - if (!fs.existsSync(agentsDir)) { - return []; - } - - const results: AgentSkillMapping[] = []; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(agentsDir, { withFileTypes: true }); - } catch { - return []; - } - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".txt")) { - continue; - } - - const fileName = entry.name; - let isOrchestrator = false; - let agentType: string; - - if (fileName.endsWith(".o.txt")) { - isOrchestrator = true; - agentType = fileName.slice(0, -6); // remove ".o.txt" - } else { - agentType = fileName.slice(0, -4); // remove ".txt" - } - - const filePath = path.join(agentsDir, fileName); - try { - const content = fs.readFileSync(filePath, "utf-8"); - const skills = content - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#")); - - results.push({ agentType, isOrchestrator, skills, scope }); - } catch { - // Skip unreadable mapping files - } - } - - return results; -} - -// ─── Public API ────────────────────────────────────────────────── - -export function loadSkills(projectDir: string): { - skills: SkillDefinition[]; - mappings: AgentSkillMapping[]; -} { - const globalBase = path.join(os.homedir(), ".skills"); - const projectBase = path.join(projectDir, ".skills"); - - const skills: SkillDefinition[] = []; - const mappings: AgentSkillMapping[] = []; - - // 1. Scan all global skills recursively (skipping agents/) - skills.push(...scanSkillsRecursive(globalBase, "global")); - - // 2. Scan all project skills recursively (skipping agents/) - skills.push(...scanSkillsRecursive(projectBase, "project")); - - // 3. Agent mappings — global then project - mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global")); - mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project")); - - return { skills, mappings }; -} - -export function resolveSkillsForAgent( - agentType: string, - isOrchestrator: boolean, - skills: SkillDefinition[], - mappings: AgentSkillMapping[], -): SkillDefinition[] { - // Helper: project overrides global for same-named skills - const dedupeByName = (list: SkillDefinition[]): SkillDefinition[] => { - const seen = new Map<string, SkillDefinition>(); - for (const skill of list) { - const existing = seen.get(skill.name); - if (!existing || skill.scope === "project") { - seen.set(skill.name, skill); - } - } - return Array.from(seen.values()); - }; - - // All default-directory skills (global first, then project — dedupe preserves project) - const defaultSkills = skills.filter((s) => s.directory === "default"); - - // Skills mapped to this agent type - const relevantMappings = mappings.filter( - (m) => m.agentType === agentType && m.isOrchestrator === isOrchestrator, - ); - - // Gather agent-specific skills in order: global mappings first, then project - const globalMappings = relevantMappings.filter((m) => m.scope === "global"); - const projectMappings = relevantMappings.filter((m) => m.scope === "project"); - - const agentSkillNames: string[] = []; - for (const mapping of [...globalMappings, ...projectMappings]) { - for (const skillFile of mapping.skills) { - const skillName = path.basename(skillFile, path.extname(skillFile)); - agentSkillNames.push(skillName); - } - } - - const agentSpecificSkills = agentSkillNames - .map((name) => getSkillByName(name, skills, "project")) - .filter((s): s is SkillDefinition => s !== undefined); - - const combined = [...defaultSkills, ...agentSpecificSkills]; - return dedupeByName(combined); -} - -export function getSkillByName( - name: string, - skills: SkillDefinition[], - preferScope?: SkillScope, -): SkillDefinition | undefined { - const matches = skills.filter((s) => s.name === name); - if (matches.length === 0) { - return undefined; - } - - if (preferScope) { - const preferred = matches.find((s) => s.scope === preferScope); - if (preferred) { - return preferred; - } - } - - // Default: project takes precedence - const projectMatch = matches.find((s) => s.scope === "project"); - return projectMatch ?? matches[0]; -} - -export function createSkillsWatcher( - projectDir: string, - onChange: (result: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }) => void, -): { close(): void } { - const globalBase = path.join(os.homedir(), ".skills"); - const projectBase = path.join(projectDir, ".skills"); - - let debounceTimer: ReturnType<typeof setTimeout> | null = null; - - const reload = () => { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - } - debounceTimer = setTimeout(() => { - debounceTimer = null; - const result = loadSkills(projectDir); - onChange(result); - }, 300); - }; - - const watchPaths = [globalBase, projectBase]; - - const watcher = chokidar.watch(watchPaths, { - ignoreInitial: true, - persistent: true, - }); - - watcher.on("add", (filePath: string) => { - if (filePath.endsWith(".md") || filePath.endsWith(".txt")) { - reload(); - } - }); - - watcher.on("change", (filePath: string) => { - if (filePath.endsWith(".md") || filePath.endsWith(".txt")) { - reload(); - } - }); - - watcher.on("unlink", (filePath: string) => { - if (filePath.endsWith(".md") || filePath.endsWith(".txt")) { - reload(); - } - }); - - return { - close() { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - watcher.close(); - }, - }; -} diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts deleted file mode 100644 index bd1205e..0000000 --- a/packages/core/src/skills/parser.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as path from "node:path"; -import { parse } from "smol-toml"; -import type { SkillDefinition, SkillDirectory, SkillScope } from "../types/index.js"; - -const FRONTMATTER_DELIMITER = "+++"; - -export function parseSkillFile( - filePath: string, - content: string, - scope: SkillScope, - directory: SkillDirectory, -): SkillDefinition { - const defaultName = path.basename(filePath, path.extname(filePath)); - - let name = defaultName; - let description = ""; - let tags: string[] = []; - let body = content; - - // Check for TOML frontmatter - const trimmed = content.trimStart(); - if (trimmed.startsWith(FRONTMATTER_DELIMITER)) { - const afterOpen = trimmed.slice(FRONTMATTER_DELIMITER.length); - // Find the closing +++ - const closeIndex = afterOpen.indexOf(FRONTMATTER_DELIMITER); - if (closeIndex !== -1) { - const tomlSource = afterOpen.slice(0, closeIndex); - body = afterOpen.slice(closeIndex + FRONTMATTER_DELIMITER.length); - - try { - const frontmatter = parse(tomlSource); - - if (typeof frontmatter.name === "string") { - name = frontmatter.name; - } - if (typeof frontmatter.description === "string") { - description = frontmatter.description; - } - if (Array.isArray(frontmatter.tags)) { - tags = (frontmatter.tags as unknown[]).filter((t): t is string => typeof t === "string"); - } - } catch { - // Malformed TOML — fall through with defaults - } - } - } - - // Trim leading newline from body (handles both LF and CRLF) - body = body.replace(/^\r?\n/, ""); - - return { - name, - description, - tags, - content: body, - scope, - source: filePath, - directory, - }; -} 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)}`; - } - }, - }; -} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts deleted file mode 100644 index e878813..0000000 --- a/packages/core/src/types/index.ts +++ /dev/null @@ -1,672 +0,0 @@ -import type { ZodType } from "zod"; -import type { PermissionChecker, Ruleset } from "../permission/index.js"; - -// ─── Message Types ─────────────────────────────────────────────── - -export type MessageRole = "user" | "assistant" | "system"; - -/** - * A single ordered chunk of content inside a message. The chunk list - * preserves the actual temporal ordering of text, reasoning, tool calls, - * system notices, and errors as they arrived from the model. - * - * Coalescing rules (see notes/plan-chunk-refactor.md): - * - `text` and `thinking` coalesce on consecutive same-type deltas. - * - `tool-batch` coalesces on consecutive `tool-call` events - * (appends a new entry to `calls`). - * - `error` and `system` are always single-event chunks (no coalescing). - */ -export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk; - -export interface TextChunk { - type: "text"; - text: string; -} - -export interface ThinkingChunk { - type: "thinking"; - text: string; - /** - * Full Anthropic `providerMetadata` blob captured from the v6 - * `reasoning-end` stream event (typically `{ anthropic: { signature - * } }` plus any other provider-side metadata). Round-tripped verbatim - * as `providerOptions` on the `ReasoningPart` of the next request so - * Anthropic can validate the thinking block's signature. - * - * Also acts as a "sealed" marker for `appendEventToChunks`: once - * `metadata` is set, the next `reasoning-delta` opens a new thinking - * chunk rather than extending this one (each Anthropic content block - * gets its own metadata, so two consecutive thinking blocks must not - * be coalesced). - * - * Optional: non-Anthropic models produce no metadata, and pre-v6 - * persisted chunks have neither field. - */ - metadata?: Record<string, unknown>; -} - -export interface ToolBatchChunk { - type: "tool-batch"; - calls: ToolBatchEntry[]; -} - -export interface ToolBatchEntry { - id: string; - name: string; - arguments: Record<string, unknown>; - result?: string; - isError?: boolean; - shellOutput?: { stdout: string; stderr: string }; -} - -export interface ErrorChunk { - type: "error"; - message: string; - statusCode?: number; -} - -export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled"; - -export interface SystemChunk { - type: "system"; - kind: SystemChunkKind; - text: string; -} - -export interface ChatMessage { - role: MessageRole; - chunks: Chunk[]; - /** - * Ephemeral ORDERED multimodal content for a user turn (interleaved text + - * image/pdf attachments). Set ONLY transiently on the in-flight user message - * so `toModelMessages` can emit multimodal `ImagePart`/`FilePart` content to - * the provider. Never persisted (the chunk log stores only the text, with - * `[image]`/`[pdf]` markers), so it's absent on history-rebuilt messages. - * When absent, the message is plain text built from its `chunks`. - */ - content?: UserContentPart[]; -} - -// ─── Multimodal user content (image / PDF attachments) ─────────── -// -// When a user pastes one or more images/PDFs into the chat input, the turn's -// user message carries an ORDERED list of content parts instead of a plain -// string. The ordering is meaningful — the user can interleave text and -// attachments ("here is image A: <A>, here is image B: <B>") and the model -// sees them in exactly that sequence. -// -// These parts are EPHEMERAL: they are forwarded to the model for the turn that -// produced them but are NOT persisted as raw bytes in the chunk log. History -// stores only the user's text (with `[image]` / `[pdf]` markers in place of -// each attachment), so a later reload re-renders the text but never re-sends -// the binary payload. This keeps the persisted log small and avoids re-billing -// image tokens on every subsequent turn. - -/** A plain-text segment of a multimodal user message. */ -export interface UserTextPart { - type: "text"; - text: string; -} - -/** - * A binary attachment (image or PDF) in a multimodal user message. `data` is a - * base64-encoded payload (no `data:` URI prefix); `mediaType` is the IANA media - * type (e.g. `image/png`, `application/pdf`). `name` is an optional original - * filename, used only for PDF `filename` passthrough and diagnostics. - */ -export interface UserAttachmentPart { - type: "attachment"; - /** IANA media type, e.g. `image/png`, `image/jpeg`, `application/pdf`. */ - mediaType: string; - /** Base64-encoded bytes WITHOUT a `data:` URI prefix. */ - data: string; - /** Optional original filename (mainly for PDFs). */ - name?: string; -} - -/** One ordered part of a multimodal user message. */ -export type UserContentPart = UserTextPart | UserAttachmentPart; - -// ─── Append-only chunk log (persisted model) ───────────────────── -// -// The DB stores a conversation as a flat stream of `ChunkRow`s (see -// db/chunks.ts). The render-facing `Chunk`/`ChatMessage` shapes above are -// DERIVED from these rows by grouping (turn_id + step + role). Tool calls -// and their results are SEPARATE rows linked by `callId`, mapping 1:1 to the -// Anthropic wire format. - -/** Role of a persisted chunk row. `tool` rows hold tool results. */ -export type ChunkRole = "user" | "assistant" | "tool" | "system"; - -/** Discriminator for a persisted chunk row's payload. */ -export type ChunkType = - | "text" - | "thinking" - | "tool_call" - | "tool_result" - | "error" - | "system" - | "usage"; - -export interface TextData { - text: string; -} -export interface ThinkingData { - text: string; - metadata?: Record<string, unknown>; -} -export interface ToolCallData { - callId: string; - name: string; - arguments: Record<string, unknown>; -} -export interface ToolResultData { - callId: string; - name: string; - result: string; - isError: boolean; - shellOutput?: { stdout: string; stderr: string }; -} -export interface ErrorData { - message: string; - statusCode?: number; -} -export interface SystemData { - kind: SystemChunkKind; - text: string; -} -/** - * Per-request token usage persisted as a SIDE-CHANNEL chunk row (one row per - * `usage` AgentEvent, i.e. one per LLM round-trip). These rows are deliberately - * EXCLUDED from `getChunksForTab`/`getTotalChunkCount` so they never enter the - * render, pagination, eviction, or agent-history-rebuild paths — they exist - * only to feed the backend aggregate `getUsageStatsForTab`, which seeds the - * frontend's `cacheStats` on reload. `inputTokens` is the TOTAL prompt - * (cached + fresh); `cacheReadTokens`/`cacheWriteTokens` are Anthropic's - * prompt-cache split. Mirrors the `usage` AgentEvent payload. - */ -export interface UsageData { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; -} - -/** - * Aggregate per-tab usage telemetry: the cumulative sum across ALL persisted - * `usage` rows, the request count, and the most recent request's split. This is - * the server-side source of truth (complete regardless of frontend - * eviction/pagination) returned by `getUsageStatsForTab`. Structurally - * identical to the frontend `CacheStats` so it can seed it directly. `null` when - * the tab has no usage rows. - */ -export interface UsageStats { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - /** Number of LLM requests (usage rows) counted. */ - requests: number; - last: { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - } | null; -} - -export type ChunkData = - | TextData - | ThinkingData - | ToolCallData - | ToolResultData - | ErrorData - | SystemData - | UsageData; - -/** - * A persisted chunk row — the append-only unit of conversation storage and - * the unit of frontend pagination. `seq` is per-tab monotonic and is both the - * ordering key and the pagination cursor. - */ -export interface ChunkRow { - id: string; - tabId: string; - seq: number; - turnId: string; - step: number; - role: ChunkRole; - type: ChunkType; - data: ChunkData; - createdAt: number; -} - -/** - * A chunk-row draft (no `seq`/`tabId`/`createdAt`/`id` yet) used when - * exploding an in-memory turn into rows for persistence. - */ -export interface ChunkRowDraft { - turnId: string; - step: number; - role: ChunkRole; - type: ChunkType; - data: ChunkData; -} - -export interface ToolCall { - id: string; - name: string; - arguments: Record<string, unknown>; -} - -export interface ToolResult { - toolCallId: string; - toolName: string; - result: string; - isError: boolean; -} - -// ─── Agent Status & Events ─────────────────────────────────────── - -export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key"; - -/** - * Per-tab snapshot of live state, sent on WS connect and via - * `GET /status`. Carries enough information for a freshly-loaded - * frontend to reconstruct any in-flight assistant message. - * - * - `status` — always present; mirrors the in-memory `TabAgent.status`. - * - `currentChunks` — the live in-flight `Chunk[]` for the running - * assistant turn. Present iff `status === "running"` AND - * `TabAgent.currentChunks` is non-null. Defensively copied at - * snapshot time; the consumer owns the array. - * - `currentAssistantId` — DB id of the in-flight assistant message - * (the row that the eventual `flushAssistant` call will write/update). - * Present iff `status === "running"` AND `TabAgent.currentAssistantId` - * is set. The frontend uses this to align its local assistant - * message id with the persisted id so subsequent `done` and reload - * paths line up. - * - * Not part of `AgentEvent` itself: the `statuses` payload is a WS- - * connect-level snapshot, not an event the `Agent` emits. The frontend - * mirrors this type in `packages/frontend/src/lib/types.ts`. - */ -export interface TabStatusSnapshot { - status: AgentStatus; - currentChunks?: Chunk[]; - currentAssistantId?: string; - /** - * `turn_id` of the in-flight turn. Present iff `status === "running"`. - * Lets a frontend that reconnects mid-stream key its live chunks the same - * way `turn-start` would, so they reconcile cleanly when the turn seals. - */ - currentTurnId?: string; - /** - * The tab's current todo list. Included for ALL tabs (not just running - * ones) so a freshly-reloaded frontend rehydrates the Tasks panel from the - * backend instead of blanking it. Omitted when the list is empty. - */ - tasks?: TaskItem[]; -} - -export type AgentEvent = - | { type: "status"; status: AgentStatus } - /** - * Emitted once at the start of a turn (`processMessage`), before any - * content deltas. Carries the `turn_id` shared by this turn's user message - * and every assistant/tool chunk row. The frontend tags its in-flight - * (live) chunks with this id so they key-match the sealed rows on - * turn-completion reconcile (no remount/flicker). Display/sync only — not - * conversation content. - */ - | { type: "turn-start"; turnId: string } - /** - * Emitted once after a turn has fully settled AND its chunks have been - * persisted (after `flushAssistant`). Signals the frontend that the turn's - * rows — with real `seq`s — are now durable and can be reloaded, so it can - * fold its transient live representation into the sealed chunk log. Emitted - * after `status: idle`/`error` (which fire before the DB write). Display/sync - * only — not conversation content. - * - * Carries `usageStats`: the tab's authoritative usage aggregate read from the - * DB AFTER the turn's usage rows were written. The frontend REPLACES (not adds) - * its live `cacheStats` with this, reconciling the live accumulator to the - * persisted truth every turn. This self-heals the live overshoot that occurs - * when a rate-limited fallback attempt's usage is streamed live but then - * discarded server-side (never persisted). `null` ⇒ tab has no usage rows; - * absent ⇒ leave `cacheStats` untouched (back-compat). - */ - | { type: "turn-sealed"; turnId: string; usageStats?: UsageStats | null } - | { type: "text-delta"; delta: string } - | { type: "reasoning-delta"; delta: string } - /** - * Emitted on the v6 `reasoning-end` stream event when it carries - * `providerMetadata`. `appendEventToChunks` attaches the metadata to - * the most recent unsealed `thinking` chunk; `toModelMessages` reads - * it back as `providerOptions` on the next request's `ReasoningPart`. - */ - | { type: "reasoning-end"; metadata?: Record<string, unknown> } - | { type: "tool-call"; toolCall: ToolCall } - | { type: "tool-result"; toolResult: ToolResult } - | { type: "shell-output"; data: string; stream: "stdout" | "stderr" } - /** - * Per-request token usage, emitted once per LLM round-trip (each - * `streamText` step) from the AI SDK `finish` stream event. `inputTokens` - * is the TOTAL prompt size including cached tokens; `cacheReadTokens` is - * the portion served from Anthropic's prompt cache (a cache HIT) and - * `cacheWriteTokens` the portion written to it (a cache seed). The "Cache - * Rate" view aggregates these to show the prompt-cache hit rate. Non- - * caching providers report zero for the cache fields. - */ - | { - type: "usage"; - usage: { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - }; - } - | { type: "error"; error: string; statusCode?: number } - | { type: "notice"; message: string } - | { type: "model-changed"; keyId: string; modelId: string } - | { type: "done"; message: ChatMessage } - | { type: "task-list-update"; tasks: TaskItem[] } - | { type: "config-reload" } - | { - type: "tab-created"; - id: string; - title: string; - keyId: string | null; - modelId: string | null; - parentTabId: string | null; - agentSlug?: string | null; - workingDirectory: string | null; - agentModels?: Array<{ key_id: string; model_id: string }> | null; - } - | { type: "message-queued"; tabId: string; messageId: string; message: string } - | { - type: "message-consumed"; - tabId: string; - messageIds: string[]; - /** - * Why the queue was drained: - * - "interrupt": consumed mid-turn, folded into a running turn's tool - * result as a [USER INTERRUPT]. The optimistic bubble collapses into - * that sealed turn. - * - "continuation": consumed between turns to START a new turn. The - * optimistic bubble becomes that new turn's initiating user row. - * Absent ⇒ treat as "interrupt" (back-compat). - */ - reason?: "interrupt" | "continuation"; - } - | { type: "message-cancelled"; tabId: string; messageId: string } - /** - * Conversation-compaction lifecycle (UI-driven, not an agent tool). A - * compaction summarizes a tab's older history into an anchored summary while - * preserving the most recent turns verbatim. - * - * `compaction-started` fires on the temporary placeholder tab when the - * summary request begins. `compaction-complete` fires when the summary has - * been generated and the history relocated: the compacted continuation now - * lives on `sourceTabId` (the canonical id, with its key/model/working-dir - * preserved), the FULL pre-compaction history was moved to `backupTabId`, and - * `tempTabId` (the placeholder) should be discarded by the frontend. - * `compaction-error` reports a failure (or cancellation) on `tempTabId`. - */ - | { type: "compaction-started"; tempTabId: string; sourceTabId: string } - | { - type: "compaction-complete"; - tempTabId: string; - sourceTabId: string; - backupTabId: string; - backupTitle: string; - } - | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string }; - -// ─── Tool Types ────────────────────────────────────────────────── - -export interface ToolExecuteContext { - onOutput?: (data: string, stream: "stdout" | "stderr") => void; - queueCallbacks?: QueueCallbacks; -} - -export interface ToolDefinition { - name: string; - description: string; - parameters: ZodType; - execute: (args: Record<string, unknown>, context?: ToolExecuteContext) => Promise<string>; -} - -// ─── Agent Configuration ───────────────────────────────────────── - -/** - * Canonical, ordered list of reasoning-effort levels — the SINGLE SOURCE OF - * TRUTH for effort values across the whole codebase (core LLM call site, API - * validation, agent TOML persistence, and the frontend UI). Ordered from least - * to most effort. - * - * `none` disables reasoning. `low`/`medium`/`high`/`xhigh` are forwarded - * verbatim to providers that accept them (OpenAI-compatible `reasoning_effort`, - * Anthropic adaptive `effort`) — `xhigh` is accepted by newer OpenAI reasoning - * models. `max` is Dispatch's own top tier, mapped per-provider at the call - * site (e.g. classic-thinking Claude budget tokens). - */ -export const REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const; - -export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; - -/** - * Default effort applied when nothing more specific is configured (no per-model - * effort and no per-tab selection). Resolution order is - * per-model → per-tab → this default. - */ -export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"; - -/** Human-readable labels for each effort level (UI display). */ -export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = { - none: "Off", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "X-High", - max: "Max", -}; - -/** Runtime type guard for narrowing an arbitrary value to a `ReasoningEffort`. */ -export function isReasoningEffort(value: unknown): value is ReasoningEffort { - return typeof value === "string" && (REASONING_EFFORTS as readonly string[]).includes(value); -} - -export interface AgentConfig { - model: string; - apiKey: string; - baseURL: string; - systemPrompt: string; - tools: ToolDefinition[]; - workingDirectory: string; - permissionChecker?: PermissionChecker; - ruleset?: Ruleset; - reasoningEffort?: ReasoningEffort; - provider?: string; - claudeCredentials?: { - accessToken: string; - }; - /** - * Tab ID the agent runs on. Used to scope per-tab side effects, namely - * the tool-output spill directory (`/tmp/dispatch/tool-results/<tabId>/`). - * Optional so legacy callers and tests can construct an Agent without one; - * a fallback ID is generated when absent. - */ - tabId?: string; -} - -// ─── Config Types (dispatch.toml) ──────────────────────────────── - -export interface DispatchConfig { - keys?: KeyDefinition[]; - permissions: Record<string, string | Record<string, string>>; - /** - * Language Server Protocol servers, keyed by an arbitrary server id (e.g. - * `"luau-lsp"`). Resolved by merging the HOME-directory global - * `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the - * `dispatch.toml` in a tab's effective working directory — local entries - * override global ones sharing the same id, and global-only servers stay - * active in every repository. Re-consulted when either config (or the - * directory) changes. Config-driven only — there is no builtin server - * registry and no auto-download; the declared `command[0]` must be on PATH. - */ - lsp?: Record<string, LspServerConfig>; -} - -/** - * A single LSP server entry as expressed in `dispatch.toml`'s `[lsp.<id>]` - * block. Mirrors opencode's custom-server schema so the Roblox Luau config - * (and any other server) is portable between the two. - * - * Example (`dispatch.toml`): - * ```toml - * [lsp.luau-lsp] - * command = ["luau-lsp", "lsp", "--definitions=globalTypes.d.luau", "--docs=api-docs.json"] - * extensions = [".luau"] - * - * [lsp.luau-lsp.initialization.luau-lsp.platform] - * type = "roblox" - * ``` - */ -export interface LspServerConfig { - /** - * Argv to launch the server over stdio. `command[0]` is the executable - * (resolved via PATH); the rest are arguments. Required for every non- - * disabled entry. - */ - command: string[]; - /** - * File extensions (with leading dot, e.g. `".luau"`) this server attaches - * to. Required for custom servers — without it the client never knows which - * files should activate the server. - */ - extensions: string[]; - /** Extra environment variables merged onto `process.env` for the child. */ - env?: Record<string, string>; - /** - * `initializationOptions` forwarded verbatim in the LSP `initialize` - * request (and echoed back for `workspace/configuration` / - * `didChangeConfiguration`). For luau-lsp this carries the - * `{ "luau-lsp": { platform, sourcemap, types, diagnostics, ... } }` block. - */ - initialization?: Record<string, unknown>; - /** When true, the entry is parsed but skipped (no server launched). */ - disabled?: boolean; -} - -export interface KeyDefinition { - id: string; - provider: string; - env?: string; - base_url: string; - /** For "anthropic" provider: path to credentials file (default: ~/.claude/.credentials.json) */ - credentials_file?: string; -} - -export type KeyStatus = "active" | "exhausted"; - -export interface KeyState { - definition: KeyDefinition; - status: KeyStatus; - lastError?: string; - exhaustedAt?: number; -} - -// ─── Skills Types ──────────────────────────────────────────────── - -export type SkillScope = "global" | "project"; -export type SkillDirectory = string; - -export interface SkillDefinition { - name: string; - description: string; - tags: string[]; - content: string; - scope: SkillScope; - source: string; - directory: SkillDirectory; -} - -export interface AgentSkillMapping { - agentType: string; - isOrchestrator: boolean; - skills: string[]; - scope: SkillScope; -} - -// ─── Task List Types ───────────────────────────────────────────── - -export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled"; - -export interface TaskItem { - /** - * Stable positional id used purely for UI keying and the - * `task-list-update` event contract. It is NEVER exposed to the model: - * the `todo` tool is a declarative whole-list write (the model sends the - * entire desired list every call), so there are no ids for the model to - * track. Ids are reassigned positionally on every `setTasks`. - */ - id: string; - content: string; - status: TaskStatus; -} - -// ─── Config Validation ─────────────────────────────────────────── - -export interface ConfigError { - path: string; - message: string; -} - -// ─── Message Queue Types ───────────────────────────────────────── - -export interface QueuedMessage { - id: string; - message: string; - timestamp: number; -} - -export interface QueueCallbacks { - dequeueMessages: () => QueuedMessage[]; - waitForQueuedMessage: () => { promise: Promise<void>; cancel: () => void }; -} - -// ─── Agent Definition Types ────────────────────────────────────── - -export interface AgentModelEntry { - key_id: string; - model_id: string; - /** - * Per-model/key reasoning effort. When set, overrides the per-tab effort - * selector for generations that use this entry (resolution order: - * per-model → per-tab → DEFAULT_REASONING_EFFORT). Omitted when unset. - */ - effort?: ReasoningEffort; -} - -export interface AgentDefinition { - /** Human-readable name */ - name: string; - /** Short description of what this agent does */ - description: string; - /** Skills to auto-include, as "scope:name" strings */ - skills: string[]; - /** Allowed tools (allowlist) */ - tools: string[]; - /** Key+model fallback hierarchy, tried in order */ - models: AgentModelEntry[]; - /** Where the TOML was loaded from: "global" or a directory path */ - scope: string; - /** The slug (filename without .toml) */ - slug: string; - /** Default working directory for this agent (optional, absolute path) */ - cwd?: string; - /** Whether this agent is a subagent (hidden from Chat Settings) */ - is_subagent?: boolean; -} |
