From 394f1ed37ce860da6fdc385769bf29f9737105cd Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 4 Jun 2026 21:21:20 +0900 Subject: chore: genesis — remove all files to rebuild from scratch (arch rewrite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/agent/agent.ts | Bin 66313 -> 0 bytes packages/core/src/agents/index.ts | 11 - packages/core/src/agents/loader.ts | 294 --------- packages/core/src/chunks/append.ts | 314 ---------- packages/core/src/chunks/transform.ts | 271 -------- packages/core/src/compaction/index.ts | 245 -------- packages/core/src/config/index.ts | 9 - packages/core/src/config/loader.ts | 226 ------- packages/core/src/config/schema.ts | 239 ------- packages/core/src/config/watcher.ts | 129 ---- packages/core/src/credentials/anthropic-betas.ts | 24 - packages/core/src/credentials/api-keys.ts | 80 --- packages/core/src/credentials/claude.ts | 694 --------------------- packages/core/src/credentials/copilot.ts | 64 -- packages/core/src/credentials/google.ts | 178 ------ packages/core/src/credentials/index.ts | 52 -- packages/core/src/credentials/opencode.ts | 126 ---- packages/core/src/credentials/store.ts | 185 ------ packages/core/src/db/chunks.ts | 246 -------- packages/core/src/db/index.ts | 177 ------ packages/core/src/db/settings.ts | 22 - packages/core/src/db/tabs.ts | 250 -------- packages/core/src/index.ts | 172 ----- packages/core/src/llm/anthropic-oauth-transform.ts | 153 ----- packages/core/src/llm/debug-logger.ts | 448 ------------- packages/core/src/llm/provider.ts | 180 ------ packages/core/src/lsp/client.ts | 658 ------------------- packages/core/src/lsp/diagnostic.ts | 41 -- packages/core/src/lsp/index.ts | 18 - packages/core/src/lsp/language.ts | 72 --- packages/core/src/lsp/manager.ts | 220 ------- packages/core/src/lsp/server.ts | 68 -- packages/core/src/models/attachments.ts | 151 ----- packages/core/src/models/catalog.ts | 229 ------- packages/core/src/models/index.ts | 24 - packages/core/src/models/registry.ts | 86 --- packages/core/src/notifications/config.ts | 77 --- packages/core/src/notifications/dispatcher.ts | 287 --------- packages/core/src/notifications/index.ts | 36 -- packages/core/src/notifications/ntfy.ts | 149 ----- packages/core/src/notifications/types.ts | 108 ---- packages/core/src/permission/evaluate.ts | 15 - packages/core/src/permission/index.ts | 26 - packages/core/src/permission/service.ts | 76 --- packages/core/src/permission/wildcard.ts | 10 - packages/core/src/skills/index.ts | 7 - packages/core/src/skills/loader.ts | 248 -------- packages/core/src/skills/parser.ts | 60 -- packages/core/src/tools/bash-arity.ts | 48 -- packages/core/src/tools/key-usage.ts | 322 ---------- packages/core/src/tools/list-files.ts | 40 -- packages/core/src/tools/lsp.ts | 135 ---- packages/core/src/tools/path-utils.ts | 55 -- packages/core/src/tools/read-file-slice.ts | 90 --- packages/core/src/tools/read-file.ts | 129 ---- packages/core/src/tools/read-tab.ts | 95 --- packages/core/src/tools/registry.ts | 91 --- packages/core/src/tools/retrieve.ts | 71 --- packages/core/src/tools/run-shell.ts | 176 ------ packages/core/src/tools/search-code.ts | 362 ----------- packages/core/src/tools/send-to-tab.ts | 198 ------ packages/core/src/tools/shell-analyze.ts | 166 ----- packages/core/src/tools/summon.ts | 447 ------------- packages/core/src/tools/task-list.ts | 149 ----- packages/core/src/tools/truncate.ts | 142 ----- packages/core/src/tools/web-search.ts | 106 ---- packages/core/src/tools/write-file.ts | 66 -- packages/core/src/tools/youtube-transcribe.ts | 219 ------- packages/core/src/types/index.ts | 672 -------------------- 69 files changed, 11234 deletions(-) delete mode 100644 packages/core/src/agent/agent.ts delete mode 100644 packages/core/src/agents/index.ts delete mode 100644 packages/core/src/agents/loader.ts delete mode 100644 packages/core/src/chunks/append.ts delete mode 100644 packages/core/src/chunks/transform.ts delete mode 100644 packages/core/src/compaction/index.ts delete mode 100644 packages/core/src/config/index.ts delete mode 100644 packages/core/src/config/loader.ts delete mode 100644 packages/core/src/config/schema.ts delete mode 100644 packages/core/src/config/watcher.ts delete mode 100644 packages/core/src/credentials/anthropic-betas.ts delete mode 100644 packages/core/src/credentials/api-keys.ts delete mode 100644 packages/core/src/credentials/claude.ts delete mode 100644 packages/core/src/credentials/copilot.ts delete mode 100644 packages/core/src/credentials/google.ts delete mode 100644 packages/core/src/credentials/index.ts delete mode 100644 packages/core/src/credentials/opencode.ts delete mode 100644 packages/core/src/credentials/store.ts delete mode 100644 packages/core/src/db/chunks.ts delete mode 100644 packages/core/src/db/index.ts delete mode 100644 packages/core/src/db/settings.ts delete mode 100644 packages/core/src/db/tabs.ts delete mode 100644 packages/core/src/index.ts delete mode 100644 packages/core/src/llm/anthropic-oauth-transform.ts delete mode 100644 packages/core/src/llm/debug-logger.ts delete mode 100644 packages/core/src/llm/provider.ts delete mode 100644 packages/core/src/lsp/client.ts delete mode 100644 packages/core/src/lsp/diagnostic.ts delete mode 100644 packages/core/src/lsp/index.ts delete mode 100644 packages/core/src/lsp/language.ts delete mode 100644 packages/core/src/lsp/manager.ts delete mode 100644 packages/core/src/lsp/server.ts delete mode 100644 packages/core/src/models/attachments.ts delete mode 100644 packages/core/src/models/catalog.ts delete mode 100644 packages/core/src/models/index.ts delete mode 100644 packages/core/src/models/registry.ts delete mode 100644 packages/core/src/notifications/config.ts delete mode 100644 packages/core/src/notifications/dispatcher.ts delete mode 100644 packages/core/src/notifications/index.ts delete mode 100644 packages/core/src/notifications/ntfy.ts delete mode 100644 packages/core/src/notifications/types.ts delete mode 100644 packages/core/src/permission/evaluate.ts delete mode 100644 packages/core/src/permission/index.ts delete mode 100644 packages/core/src/permission/service.ts delete mode 100644 packages/core/src/permission/wildcard.ts delete mode 100644 packages/core/src/skills/index.ts delete mode 100644 packages/core/src/skills/loader.ts delete mode 100644 packages/core/src/skills/parser.ts delete mode 100644 packages/core/src/tools/bash-arity.ts delete mode 100644 packages/core/src/tools/key-usage.ts delete mode 100644 packages/core/src/tools/list-files.ts delete mode 100644 packages/core/src/tools/lsp.ts delete mode 100644 packages/core/src/tools/path-utils.ts delete mode 100644 packages/core/src/tools/read-file-slice.ts delete mode 100644 packages/core/src/tools/read-file.ts delete mode 100644 packages/core/src/tools/read-tab.ts delete mode 100644 packages/core/src/tools/registry.ts delete mode 100644 packages/core/src/tools/retrieve.ts delete mode 100644 packages/core/src/tools/run-shell.ts delete mode 100644 packages/core/src/tools/search-code.ts delete mode 100644 packages/core/src/tools/send-to-tab.ts delete mode 100644 packages/core/src/tools/shell-analyze.ts delete mode 100644 packages/core/src/tools/summon.ts delete mode 100644 packages/core/src/tools/task-list.ts delete mode 100644 packages/core/src/tools/truncate.ts delete mode 100644 packages/core/src/tools/web-search.ts delete mode 100644 packages/core/src/tools/write-file.ts delete mode 100644 packages/core/src/tools/youtube-transcribe.ts delete mode 100644 packages/core/src/types/index.ts (limited to 'packages/core/src') diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts deleted file mode 100644 index 2e2dbb2..0000000 Binary files a/packages/core/src/agent/agent.ts and /dev/null differ 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(); - 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 = { - 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).effort; - models.push({ - key_id: String((m as Record).key_id), - model_id: String((m as Record).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( - 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 } | 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