Compare commits

..

34 Commits

Author SHA1 Message Date
a8e475fdf4 perf(llama): unshadow cache-type + spec-decoding flags for agent opt-in
KV cache quantization (--cache-type-k q4_0) and ngram speculative decoding
(--spec-type ngram-mod) are high-value llama.cpp features that improve VRAM
usage and tokens/sec. Removing them from the shadowing lists allows agents
to enable them via llama_extra_args.
2026-06-07 22:40:23 +00:00
02063072ab chore: add ion package, codesight wiki, work plans, ascli config
New @boocode/ion package (v0.0.1) for inference optimization network.
.codesight/ wiki artifacts for codebase documentation.
.omo/ work plans for openspec cleanup and enhanced file panel.
2026-06-07 22:16:45 +00:00
ec48066a80 chore(infra): Dockerfile updates, MCP config cleanup, dependency lockfile
codecontext Dockerfile and docker-compose adjustments for sidecar build.
MCP example config cleanup (remove deprecated entries). pnpm-lock.yaml
updated for new dependencies.
2026-06-07 22:16:41 +00:00
876c9bcd02 feat(coder,server): audit engine — session audit, guideline compliance, user correction tracking
Implements audit-harness-inspired session lifecycle: audit session
creation/end/recover/report-daily with JSONL buffer and graded context
recovery (L0-L4). Guideline service for behavioral compliance rules
(condition/action model with criticality). Correction service for
persistent user correction tracking across agent sessions.

8 supporting skills: audit-start/end/report-daily/recover + command
variants for slash-command integration.
2026-06-07 22:16:35 +00:00
c132215064 feat(web,server): inference settings UI with per-session inference overrides
Adds Inference tab to SettingsPane with controls for temperature, top-p,
top-k, min-p, and other inference parameters. Server-side route and
provider config wiring to pass overrides through the inference pipeline.
2026-06-07 22:16:29 +00:00
a72f7954b4 feat(web,coder): add analytics + results pages for token usage and run history
New /analytics route: token usage dashboard with aggregate summary,
per-session breakdown, context window stats, and per-category token
distribution. Data served from existing agent_sessions + tool_cost_stats.

New /results route: browsable archive of orchestrator flow runs and
arena battles. Two-tab layout (Analysis Runs / Arena Battles) using
existing API endpoints (no new backend).

Sidebar gains Results (ScrollText icon) and Token Analytics (BarChart3
icon) nav buttons above Settings.
2026-06-07 22:16:25 +00:00
31d8efe66a feat(web): enhanced file panel — side-by-side diff, hide whitespace, inline review
Adds DiffSplitView component for side-by-side diff mode, whitespace-only
change filtering, inline review comments with thread/gutter cell UI, diff
preferences persistence, and write-file API support for in-browser editing.

Backend: hideWhitespace param on git diff endpoint, write_file route.
2026-06-07 22:16:20 +00:00
c935687725 chore(openspec): drop 9 superseded proposals + 11 stub archive files
Drop 9 batch proposals that are superseded by the boocode-lift-analysis
(boocontext-audit, conductor upgrades, self-healing/verify-gate skills):
add-3tier-memory, import-llm-evaluator, import-pregel-engine, plugin-platform,
conductor-evolution, code-intelligence-upgrade, dev-workflow, ui-overhaul,
agent-reliability.

Delete 11 stub archive files (49-66B each, 'Status: Shipped. Archived.' only)
that provide zero documentation value over the existing CHANGELOG.md + git tags.
2026-06-07 22:15:38 +00:00
0d6e9a2413 feat(coder): complete orchestrator advanced patterns
- Approval gate steps pause and await human resolution
- appendStepEvent wired into markStep, failRun, dispatchAgentStep
- Trigger rule unit tests (6 variants)
- New parallel-research flow with one_success trigger
2026-06-07 21:55:47 +00:00
6344105877 feat(server): memory v2 tests and search_memory tool 2026-06-07 21:55:47 +00:00
028c08b4cd docs: add openspec proposals for memory v2 and orchestrator flow patterns 2026-06-07 21:34:35 +00:00
fb52eb3efa feat(coder): orchestrator advanced flow patterns
- TriggerRule type (all_success/one_success/all_done) for parallel deps
- Variable substitution ($stepId.output.field) in agent step prompts
- Approval gate step kind (pauses flow via permission frames)
- flow_step_events table for append-only event-sourced step log
- evaluateTriggerRule pure function in flow-runner-decisions
2026-06-07 21:34:30 +00:00
648a59a563 feat(server): memory v2 — BM25 + local embedding hybrid search
- Bm25Ranker: Okapi BM25 scoring (pure TS, no deps)
- Embedding module: ONNX-based local embeddings via onnxruntime-node
- Hybrid recall: BM25 (30%) + cosine similarity (70%) weighted merge
- Falls back to keyword-only via MEMORY_SEARCH=keyword env var
- extract_memory agent tool for persisting memory entries
2026-06-07 21:34:25 +00:00
7f59f30f2d docs: update code review doc with v2.8 fork-lifts lift sources
- Added 10 new lift source entries (boocontext, TSA, type-inject,
  morph-fast-apply, tokenscope, DCP, qwen-code memory/LSP,
  oh-my-openagent, paseo protocol) under v2.8 fork-lifts section
- Added 9 new rows to the lift catalog table
- Added decisions log entry for v2.8.0-fork-lifts batch
- Bumped last-updated to 2026-06-07
2026-06-07 18:44:12 +00:00
f436021bf9 feat: deferred items — arena token API + UI, ToolShim docs
- Arena API: token_breakdown selected in contestant query
- ArenaPane: token category breakdown bar (s/u/a/t/r) in expanded contestant view
- apps/server/CLAUDE.md: document tool-shim and loop-detectors
2026-06-07 18:41:26 +00:00
bef6bef504 docs: update changelog, roadmap, current focus, and coder CLAUDE.md
- CHANGELOG: v2.8.0-fork-lifts entry covering all 8 integrations
- Roadmap: update shipped header through v2.8.0, bump last-updated date
- CURRENT.md: reflect fork-lifts as last-shipped batch
- apps/coder/CLAUDE.md: document edit-guards behavior and API
2026-06-07 18:05:55 +00:00
87923cb07b feat(coder): add flow-artifacts write helper and boocontext MCP template 2026-06-07 18:05:49 +00:00
c6ecd984c5 feat(coder): add TokenScope analyzer and DB persistence module
- analyzeMessages classifies message parts into system/user/assistant/tools/reasoning
- persistTaskBreakdown writes JSONB to tasks table
- Backfills the token-analysis/ module (contract committed earlier)
- 6 unit tests covering classification, tool calls, reasoning tokens
2026-06-07 18:05:35 +00:00
2a83f61070 feat(coder): add import-drop detection to edit safety guards
- checkDroppedImports detects removed import/require lines in edits
- Runs alongside truncation guard in pending_changes.ts
- Supports ESM imports, CJS require, type imports, side-effect imports
2026-06-07 18:05:30 +00:00
44874f0097 feat: fork lifts phases 3-9 — LSP, DCP, memory, boocontext, protocol, plugins, reliability 2026-06-07 17:58:30 +00:00
1b70d41996 feat(server): add inference reliability - tool-shim and loop detectors
- ToolShim recovers XML/JSON tool calls from plain-text model output
- detectContentRepeat catches same-content loops
- detectToolLoop catches repeated tool invocations
- detectDoomLoop combines both detectors
2026-06-07 17:57:58 +00:00
b64941ad4b feat(coder): add plugin hook host
- Typed hook registry with registerHook/emitHook/clearHooks
- Hooks: tool.execute.before/after, turn.start/end, task.terminal
- SUL patterns only (oh-my-openagent: architecture study, no code copy)
2026-06-07 17:57:53 +00:00
cdc782e044 feat(core): add subagent protocol enhancements
- AgentCapabilitiesSchema with supportsStreaming/Reasoning/Background flags
- supportsStreaming and supportsReasoningStream fields in ProviderSnapshotEntry
- new_task tool: background mode flag for non-blocking subtask dispatch
2026-06-07 17:57:49 +00:00
02bb355a09 feat(server): add institutional memory recall
- File-based memory under .boocode/memory/ (project/user/reference topics)
- Hierarchical 4-scope scan: global → home → project → session
- Keyword/tag relevance matching for query-based recall
- Injected as <boocode-memory> block in system prompt at assembly
- v1 recall-only (extract/dream deferred to v2)
2026-06-07 17:57:44 +00:00
b8b2666fdc feat(server): add DCP clean-room context pruning
- Deduplication: removes consecutive identical tool_call+tool_result pairs
- Purge-errors: removes failed/empty tool results
- Transform orchestrator runs strategies in sequence pre-payload
- Wired into turn.ts before buildMessagesPayload
- Clean-room reimplementation (AGPL reference: behavior only)
2026-06-07 17:57:39 +00:00
ee749d8698 feat(coder): add LSP code intelligence tools
- lsp/ module: types, config, JSON-RPC client, server-manager, operations
- lsp_diagnostics: TypeScript/JavaScript diagnostics for a file
- lsp_goto_definition: find symbol definition at position
- lsp_find_references: find all references to a symbol
- Registered as READ_TOOLS in tool index
2026-06-07 17:57:35 +00:00
bc83475a3d feat(server): add boocontext deep analysis tools and synthesis pipeline
- get_symbol_details: type signature, definition location, usage count
- get_call_graph: callers, callees, transitive references
- get_blast_radius added to SYNTHESIS_TOOLS
2026-06-07 17:57:29 +00:00
214cc32ac2 feat(codecontext): upgrade sidecar to boocontext MCP aggregator
- Multi-stage Dockerfile builds boocontext (Node) + HTTP shim (Go)
- shim.go supports CODECONTEXT_CHILD env var for configurable MCP child
- Adds routes for get_symbol_details, get_call_graph, get_blast_radius
- docker-compose.yml adds env vars for child MCP paths
2026-06-07 17:57:24 +00:00
6b7c2bab1e feat(coder): persist token breakdown in arena decisions and schema 2026-06-07 17:57:19 +00:00
373ba86e5d feat(coder): add edit safety guards against truncation 2026-06-07 17:57:15 +00:00
9106334e70 feat(contracts): add TokenBreakdownSchema and ContestantShape.token_breakdown 2026-06-07 17:57:11 +00:00
cce685b1a7 fix(coder): harden edit-apply pipeline against block duplication
Root cause: two proven corruption mechanisms — (M1) non-idempotent apply
stamped the same block N times when a quantized model re-emitted the same
edit_file call or a turn was retried; (M2) Levenshtein tier 4 was fail-open
with no uniqueness guard, silently splicing into the wrong location.

Fixes applied at every layer of the pipeline:

Matcher (fuzzy-match.ts): raise SIMILARITY_THRESHOLD 0.66 → 0.85; add
AMBIGUITY_EPSILON uniqueness guard — two windows within 0.05 of the top
score → ambiguous, not a guess; add block-anchor gate (≥3-line needles
require first+last line exact match before a window is scored).

Edit planner (pending_changes.ts): extract planEdit() as a pure function;
idempotency guards detect already-applied states (anchored insert re-stamp,
old-gone-but-new-present); findPendingDuplicate() collapses identical
pending rows at queue time so M1 never reaches applyOne.

Atomic writes (pending_changes.ts): temp-file + rename on the same
filesystem so a crash can't leave a half-written source file; realpath()
first so symlinks survive the rename.

Per-file mutex (pending_changes.ts): withFileLock() serializes concurrent
read-modify-write on the same path via a chained-Promise Map.

EOL preservation (pending_changes.ts): normalize CRLF → LF for matching,
restore native line ending on write so Windows-style files stay clean.

Context isolation (inference_context.ts): replace module-level singleton
with AsyncLocalStorage so concurrent inference runs (arena parallel
dispatch, dispatcher poll racing a user message) each get their own
scoped context with no clobbering.

Tests: plan-edit.test.ts (pure planEdit unit tests), extended fuzzy-match
and pending_changes_integration suites, ALS isolation test that proves
overlapping runs get correct session IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 01:44:37 +00:00
dbf1662982 docs: add v2.7.20-arena-pane changelog entry 2026-06-06 23:34:58 +00:00
d6d246c15b feat(web,coder): arena pane — compare 2-6 AI competitors on same prompt
Arena is a new pane kind for competitive AI evaluation. A Battle runs
the same prompt against 2-6 Contestants across two concurrent lanes:
local lane (llama-swap models, serial) and cloud lane (parallel).

Added to all three registries: @boocode/contracts WsFrameSchema,
server InferenceFrame, and web WsFrame.

Backend (apps/coder):
- arena-runner: battle scheduler, lane classifier, benchmark, results
  writer, resume, user winner override
- arena-analyzer: two-stage digest→judge analysis on DEFAULT_MODEL
- arena-decisions: status transitions and resume logic (unit-tested)
- arena-analyzer-helpers: pure helper functions (unit-tested)
- arena-model-call: model call utility for analysis
- arena routes: create/get/list/stop/analyze/cross-examine/winner/diff
- schema: battles, contestants, cross_examinations tables (idempotent)
- remove old /api/arena* routes and tasks.arena_id column

Frontend (apps/web):
- ArenaLauncherDialog: battle type, prompt, contestant selection
- ArenaPane: live roster, streaming output, analysis, cross-exam
- DiffView: unified diff with line-by-line color for coding contests
- Winner override per-row dropdown (Trophy icon)
- battle_updated WS handler for live winner/analysis updates
- arena pane kind in Workspace, ChatTabBar, useSidebar

Cross-app:
- ArenaState and ArenaContestantShape/WsFrame types (contracts)
- battle_* frames in WsFrameSchema, InferenceFrame, and web WsFrame
- manifest.json written per battle results folder
- /Arena added to .gitignore

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:25:29 +00:00
348 changed files with 33649 additions and 479 deletions

12
.ascli.json Normal file
View File

@@ -0,0 +1,12 @@
{
"version": 1,
"binding": {
"apiBaseUrl": "https://agentspace.so",
"claimToken": "5Jr5_HEFEH_4Mc-7_dzUTEhYUWKFC-uOi58RrqMQ7RTGTA01",
"claimUrl": "https://agentspace.so/claim?workspaceId=ws_iTSoXqyy7Mcf&token=5Jr5_HEFEH_4Mc-7_dzUTEhYUWKFC-uOi58RrqMQ7RTGTA01",
"clientId": "ascli",
"createdAt": "2026-06-07T17:39:16.001Z",
"workspaceId": "ws_iTSoXqyy7Mcf",
"workspaceName": "fork-lifts-phases-3-11"
}
}

1439
.codesight/CODESIGHT.md Normal file

File diff suppressed because it is too large Load Diff

71
.codesight/components.md Normal file
View File

@@ -0,0 +1,71 @@
# Components
- **App** — `apps/web/src/App.tsx`
- **AddProjectModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/AddProjectModal.tsx`
- **AgentComposerBar** — props: projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus — `apps/web/src/components/AgentComposerBar.tsx`
- **AgentPicker** — props: projectId, value, onChange — `apps/web/src/components/AgentPicker.tsx`
- **ArenaLauncherDialog** — `apps/web/src/components/ArenaLauncherDialog.tsx`
- **ArtifactPaneHeader** — props: title, defaultTitle, onDownload, downloadDisabled, onClose, onCopy, justCopied, copyDisabled — `apps/web/src/components/ArtifactPaneHeader.tsx`
- **AskUserInputCard** — props: toolCall, toolResult, chatId, apiPrefix — `apps/web/src/components/AskUserInputCard.tsx`
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
- **ModelPicker** — props: value, onChange — `apps/web/src/components/ModelPicker.tsx`
- **NewPaneMenu** — props: onAddPane, disabled, projectId — `apps/web/src/components/NewPaneMenu.tsx`
- **PaneHeaderActions** — props: onNewTab, onSplitPane, onNewOrchestrator, onNewArena, onReopenPane, onShowHistory, onRemovePane, historyActive, className — `apps/web/src/components/PaneHeaderActions.tsx`
- **PermissionCard** — props: prompt, onRespond, busy — `apps/web/src/components/PermissionCard.tsx`
- **ProjectSidebar** — `apps/web/src/components/ProjectSidebar.tsx`
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
- **ToolCallLine** — props: run, insideGroup — `apps/web/src/components/ToolCallLine.tsx`
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
- **MatrixRain** — props: enabled, density, speed, opacity — `apps/web/src/components/fx/MatrixRain.tsx`
- **NeonField** — props: enabled, opacity, speed — `apps/web/src/components/fx/NeonField.tsx`
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
- **TerminalPane** — props: sessionId, paneId, label, active — `apps/web/src/components/panes/TerminalPane.tsx`
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
- **Home** — `apps/web/src/pages/Home.tsx`
- **Project** — `apps/web/src/pages/Project.tsx`
- **Session** — `apps/web/src/pages/Session.tsx`
- **Settings** — `apps/web/src/pages/Settings.tsx`

50
.codesight/config.md Normal file
View File

@@ -0,0 +1,50 @@
# Config
## Environment Variables
- `AUDIT_DOT_DIR` **required** — apps/server/src/services/audit/runs-dir.ts
- `BOOCODE_DATA_DIR` **required** — apps/server/src/routes/inference-settings.ts
- `BOOCODE_TOOLS` **required** — apps/server/src/services/agents.ts
- `BOOCODE_TRUNCATION_DIR` **required** — apps/server/src/services/__tests__/truncate.test.ts
- `BOOCODER_DEV_URL` **required** — apps/web/vite.config.ts
- `BOOCODER_URL` **required** — apps/coder/src/cli.ts
- `BOOTERM_DEV_URL` **required** — apps/web/vite.config.ts
- `BOOTERM_SSH_HOST` **required** — apps/booterm/src/pty/manager.ts
- `BOOTERM_SSH_USER` **required** — apps/booterm/src/pty/manager.ts
- `BOOTSTRAP_ROOT` (has default) — .env.example
- `BRAINSTORM_DIR` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
- `BRAINSTORM_HOST` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
- `BRAINSTORM_OWNER_PID` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
- `BRAINSTORM_PORT` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
- `BRAINSTORM_URL_HOST` **required** — data/skills/superpowers/brainstorming/scripts/server.cjs
- `CODECONTEXT_CHILD` **required** — codecontext/shim.go
- `CODECONTEXT_URL` **required** — apps/server/src/services/codecontext_client.ts
- `CONDUCTOR_MODEL` **required** — conductor/src/dispatch.ts
- `CONDUCTOR_OPENCODE_BIN` **required** — conductor/src/dispatch.ts
- `CONDUCTOR_TIMEOUT_MS` **required** — conductor/src/dispatch.ts
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
- `CONTEXT7_API_KEY` (has default) — .env
- `DATABASE_URL` (has default) — .env.example
- `DEFAULT_MODEL` (has default) — .env.example
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
- `GITEA_BASE_URL` (has default) — .env
- `GITEA_SSH_HOST` (has default) — .env
- `GITEA_TOKEN` (has default) — .env
- `GITEA_USER` (has default) — .env
- `LLAMA_SWAP_URL` (has default) — .env.example
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
- `NODE_ENV` (has default) — .env.example
- `PORT` (has default) — .env.example
- `POSTGRES_PASSWORD` (has default) — .env.example
- `PROJECT_ROOT_WHITELIST` (has default) — .env.example
- `SEARXNG_URL` (has default) — .env.example
- `SKILLS_ROOT` **required** — apps/server/src/services/skills.ts
- `WEB_DIST_PATH` **required** — apps/server/src/index.ts
## Config Files
- `.env.example`
- `Dockerfile`
- `apps/web/vite.config.ts`
- `docker-compose.yml`

37
.codesight/graph.md Normal file
View File

@@ -0,0 +1,37 @@
# Dependency Graph
## Most Imported Files (change these carefully)
- `apps/coder/src/db.ts` — imported by **40** files
- `apps/server/src/types/api.ts` — imported by **28** files
- `apps/server/src/db.ts` — imported by **25** files
- `packages/ion/src/cli/utils.ts` — imported by **24** files
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
- `apps/coder/src/conductor/types.ts` — imported by **14** files
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
- `apps/server/src/services/tools.ts` — imported by **13** files
- `conductor/src/types.ts` — imported by **13** files
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
- `apps/server/src/config.ts` — imported by **12** files
- `apps/coder/src/config.ts` — imported by **11** files
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
- `apps/server/src/services/agents.ts` — imported by **10** files
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
- `apps/server/src/services/broker.ts` — imported by **9** files
- `apps/server/src/services/path_guard.ts` — imported by **9** files
- `apps/server/src/services/inference/payload.ts` — imported by **9** files
## Import Map (who imports what)
- `apps/coder/src/db.ts``apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +35 more
- `apps/server/src/types/api.ts``apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +23 more
- `apps/server/src/db.ts``apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 more
- `packages/ion/src/cli/utils.ts``packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
- `apps/coder/src/services/tools/types.ts``apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
- `apps/coder/src/conductor/types.ts``apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +9 more
- `apps/coder/src/services/agent-backend.ts``apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
- `apps/coder/src/services/acp-tool-snapshot.ts``apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
- `apps/server/src/services/tools/codecontext/factory.ts``apps/server/src/services/tools/codecontext/get_blast_radius.ts`, `apps/server/src/services/tools/codecontext/get_call_graph.ts`, `apps/server/src/services/tools/codecontext/get_codebase_overview.ts`, `apps/server/src/services/tools/codecontext/get_dependencies.ts`, `apps/server/src/services/tools/codecontext/get_file_analysis.ts` +9 more
- `apps/server/src/services/tools.ts``apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +8 more

927
.codesight/libs.md Normal file
View File

@@ -0,0 +1,927 @@
# Libraries
- `apps/booterm/src/auth.ts` — function getUser: (req) => string
- `apps/booterm/src/config.ts` — function loadConfig: () => Config
- `apps/booterm/src/db.ts`
- function getPool: (databaseUrl) => pg.Pool
- function getSessionInfo: (sessionId) => Promise<SessionInfo | null>
- function pingDb: () => Promise<boolean>
- function closeDb: () => Promise<void>
- `apps/booterm/src/pty/manager.ts`
- function sanitizeId: (raw) => string | null
- function tmuxSessionName: (paneId) => string
- function hasSession: (tmuxConfPath, sessionName) => Promise<boolean>
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath) => void
- `apps/coder/src/conductor/contracts.ts`
- function produceContract: (contracts) => string
- function reviewContract: (contracts) => string
- type Contract
- const EVIDENCE_PRODUCE
- const EVIDENCE_REVIEW
- const YAGNI_PRODUCE
- _...1 more_
- `apps/coder/src/conductor/flows/_util.ts` — function q, function repoLine
- `apps/coder/src/conductor/flows/index.ts`
- function describeFlows: () => string
- function getFlow: (name) => Flow | undefined
- const FLOWS: Record<string, Flow>
- const FLOW_NAMES: string[]
- `apps/coder/src/conductor/persona-loader.ts` — function loadPersona: (agent) => Promise<string>, const AGENTS_DIR
- `apps/coder/src/conductor/render.ts` — function slugify: (s) => string
- `apps/coder/src/conductor/spine.ts`
- function readBand: (input) => Band
- function fastNote: (ctx) => string
- function buildSpineFlow: (spine) => Flow
- `apps/coder/src/config.ts` — function loadConfig: () => Config, type Config
- `apps/coder/src/db.ts`
- function getSql: (config) => Sql
- function applySchema: (sql) => Promise<void>
- function pingDb: (sql) => Promise<boolean>
- function closeDb: () => Promise<void>
- type Sql
- `apps/coder/src/plugins/host.ts`
- function registerHook: (name, fn) => void
- function emitHook: (name, ctx) => Promise<any>
- function clearHooks: () => void
- interface ToolHookContext
- interface ToolResultContext
- type HookName
- _...1 more_
- `apps/coder/src/services/acp-client-fs.ts` — function readWorktreeTextFile: (worktreePath, filePath, line?, limit?) => Promise<string>, function writeWorktreeTextFile: (worktreePath, filePath, content) => Promise<void>
- `apps/coder/src/services/acp-client.ts` — function buildAcpClient: (worktreePath, resolveTurn) => void, interface AcpTurnContext
- `apps/coder/src/services/acp-derive.ts`
- function deriveModesFromACP: (fallbackModes, modeState?, configOptions?) => void
- function deriveModelDefinitionsFromACP: (models, configOptions?) => ProviderModel[]
- function findThoughtLevelConfigId: (configOptions) => string | null
- `apps/coder/src/services/acp-dispatch.ts`
- function dispatchViaAcp: (opts) => Promise<AcpDispatchResult>
- interface AcpDispatchResult
- interface AcpDispatchOpts
- `apps/coder/src/services/acp-event-map.ts` — function mapSessionUpdate: (params, priorSnapshots, AcpToolSnapshot>) => void
- `apps/coder/src/services/acp-probe.ts` — function probeAcpProvider: (agent, installPath, cwd) => Promise<AcpProbeResult>, interface AcpProbeResult
- `apps/coder/src/services/acp-spawn.ts`
- function resolveAcpSpawnArgs: (agent) => string[] | null
- function resolveLaunchSpec: (resolved, installPath) => void
- function resolveAcpProbeBinaries: (agent) => string[]
- `apps/coder/src/services/acp-stream.ts` — function createAcpNdJsonStream: (child) => void
- `apps/coder/src/services/acp-tool-snapshot.ts`
- function mergeToolSnapshot: (toolCallId, update, previous?) => AcpToolSnapshot
- function mapToolLifecycleStatus: (status, rawOutput?) => AcpToolLifecycleStatus
- function snapshotToWireToolCall: (snapshot) => void
- function snapshotToPartPayload: (snapshot) => void
- function synthesizeCanceledSnapshots: (snapshots) => AcpToolSnapshot[]
- interface AcpToolSnapshot
- _...2 more_
- `apps/coder/src/services/agent-commands-cache.ts`
- function setTaskCommands: (taskId, commands) => void
- function mergeTaskCommands: (taskId, commands) => void
- function getTaskCommands: (taskId) => AgentCommand[] | null
- function clearTaskCommands: (taskId) => void
- `apps/coder/src/services/agent-pool.ts`
- class AgentPool
- interface AgentPoolOpts
- const OPENCODE_POOL_KEY
- const agentPool
- `apps/coder/src/services/agent-probe.ts` — function probeAgents: (sql, log) => Promise<void>
- `apps/coder/src/services/agent-status-publish.ts` — function publishAgentStatus: (publishFrame, sessionId, chatId, agent, status, reason?, at) => void
- `apps/coder/src/services/agent-turn-persist.ts` — function persistExternalAgentTurn: (sql, assistantMessageId, snapshots, reasoningText) => Promise<void>
- `apps/coder/src/services/arena-analyzer-helpers.ts`
- function buildDigestPrompt: (input) => void
- function buildJudgePrompt: (originalPrompt, digests) => void
- function shouldNameWinner: (succeededCount) => boolean
- function extractWinner: (judgeOutput) => void
- function buildCrossExamPrompt: (opts) => void
- interface ContestantDigestInput
- _...1 more_
- `apps/coder/src/services/arena-analyzer.ts` — function createAnalyzer: (deps) => Analyzer, interface Analyzer
- `apps/coder/src/services/arena-decisions.ts`
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
- function nextLocalContestant: (contestants) => string | null
- function isBattleComplete: (contestants) => boolean
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark
- function sanitizeSlug: (s) => string
- function buildBattleSlug: (battleId, battleType, createdAt) => string
- _...7 more_
- `apps/coder/src/services/arena-model-call.ts` — function arenaModelCall: (opts, 'LLAMA_SWAP_URL'>;
model) => Promise<string>
- `apps/coder/src/services/arena-runner.ts`
- function createBattleRunner: (deps) => BattleRunner
- interface ContestantSpec
- interface BattleStartOpts
- interface BattleRunner
- type DispatchContestantFn
- type OnBattleComplete
- _...1 more_
- `apps/coder/src/services/audit-session.ts`
- function generateSessionId: () => string
- function getCurrentSession: (basePath?) => Promise<string | null>
- function getSessionJson: (sessionId, basePath?) => Promise<SessionJson | null>
- function getIndex: (basePath?) => Promise<IndexJson | null>
- function startSession: (task, basePath?) => Promise<StartSessionResult>
- function endSession: (basePath?) => Promise<EndSessionResult | null>
- _...18 more_
- `apps/coder/src/services/backends/claude-sdk-map.ts`
- function createClaudeSdkMapState: () => ClaudeSdkMapState
- function mapSdkMessage: (msg, state) => AgentEvent[]
- interface ClaudeSdkMapState
- `apps/coder/src/services/backends/claude-sdk-routing.ts` — function claudeSdkBackendEnabled: (env) => boolean, function shouldUseClaudeSdk: (task, env) => boolean
- `apps/coder/src/services/backends/claude-sdk.ts` — class ClaudeSdkBackend, interface ClaudeSdkBackendDeps
- `apps/coder/src/services/backends/claude-session-store.ts` — class PostgresSessionStore
- `apps/coder/src/services/backends/lifecycle-decisions.ts`
- function selectIdleEvictionTargets: (entries, now, ttlMs) => string[]
- function selectLruEvictionTargets: (entries, cap) => string[]
- function decideRestart: (input) => RestartDecision
- function selectOrphanWorktreeTargets: (onDisk, liveWorktreePaths, now, graceMs) => string[]
- interface PoolEntrySnapshot
- interface RestartDecisionInput
- _...7 more_
- `apps/coder/src/services/backends/opencode-event-map.ts`
- function stripDcpTags: (s) => string
- function eventSessionId: (ev) => string | null
- function resolvePartDedupeKey: (part, type) => string | null
- function mapToolStatus: (s) => ToolCallStatus | null
- function toolPartToSnapshot: (part) => AcpToolSnapshot
- function toolCalledSnapshot: (p) => AcpToolSnapshot
- _...7 more_
- `apps/coder/src/services/backends/opencode-server-process.ts`
- function shouldStartServer: (s) => boolean
- class OpenCodeServerSupervisor
- interface ServerDownInfo
- interface SupervisorHooks
- interface OpenCodeServerSupervisorDeps
- `apps/coder/src/services/backends/opencode-server.ts` — class OpenCodeServerBackend, interface OpenCodeServerBackendDeps
- `apps/coder/src/services/backends/opencode-sse.ts`
- function reconnectDecision: (failures, policy) => ReconnectDecision
- function startSessionEventLoop: (state, deps) => void
- function runSessionEventLoop: (state, abort, deps) => Promise<void>
- interface TurnState
- interface SessionState
- interface ReconnectPolicy
- _...4 more_
- `apps/coder/src/services/backends/opencode-usage.ts`
- function stepEndedToUsage: (props) => StepUsage
- interface StepEndedProps
- interface StepUsage
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
- `apps/coder/src/services/backends/turn-guard.ts`
- function armAbortGuard: (g) => void
- function noteTurnActivity: (g) => void
- function consumeTerminal: (g) => 'swallow' | 'settle'
- interface AbortTerminalGuard
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
- `apps/coder/src/services/checkpoints.ts`
- function buildShadowCommitCommand: (worktreePath, id) => string
- function createCheckpoint: (sql, args, opts?) => Promise<
- function restoreCheckpoint: (sql, checkpointId, opts?) => Promise<RestoreCheckpointResult>
- class CheckpointNotFoundError
- interface CreateCheckpointArgs
- interface RestoreCheckpointResult
- _...1 more_
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
- `apps/coder/src/services/correction-service.ts`
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
- function checkContradiction: (action, corrections) => void
- function markPersisted: (correctionId, filePath, basePath?) => Promise<UserCorrectionRecord | null>
- function listCorrections: (basePath?) => Promise<UserCorrectionRecord[]>
- function appendCorrectionToTrail: (trailPath, correction) => Promise<void>
- _...2 more_
- `apps/coder/src/services/dcp-strip.ts`
- function stripDcpTags: (s) => string
- function makeDcpStreamStripper: () => DcpStreamStripper
- interface DcpStreamStripper
- `apps/coder/src/services/dispatcher.ts` — function createDispatcher: (deps) => void
- `apps/coder/src/services/edit-guards-imports.ts` — function checkDroppedImports: (original, updated, filePath) => ImportCheckResult, interface ImportCheckResult
- `apps/coder/src/services/edit-guards.ts`
- function validateEditResult: (original, updated, filePath) => GuardResult
- function formatGuardError: (guard, filePath) => string
- interface GuardResult
- `apps/coder/src/services/finalize-message.ts`
- function classifyTerminalStatus: (opts) => TerminalMessageStatus
- function finalizeStreamingMessage: (sql, publishFrame, frame) => void
- type TerminalMessageStatus
- `apps/coder/src/services/flow-artifacts.ts` — function getArtifactPath: (flowRunId, stepId) => string, function writeFlowArtifact: (flowRunId, stepId, content) => Promise<string>
- `apps/coder/src/services/flow-runner-decisions.ts`
- function manifestSteps: (flow, launchCtx) => Step[]
- function readySteps: (flow, state) => Step[]
- function partitionReady: (ready, ctx) => void
- function isRunComplete: (flow, state) => boolean
- function isStuck: (flow, state) => boolean
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction
- _...5 more_
- `apps/coder/src/services/flow-runner.ts`
- function createFlowRunner: (deps) => FlowRunner
- interface LaunchOpts
- interface FlowRunner
- `apps/coder/src/services/frame-emitter.ts`
- function makeFrameEmitter: (opts) => FrameEmitter
- interface FrameEmitterOpts
- interface FrameEmitter
- `apps/coder/src/services/fuzzy-match.ts`
- function locateMatch: (content, needle) => MatchResult
- type MatchResult
- const SIMILARITY_THRESHOLD
- const AMBIGUITY_EPSILON
- `apps/coder/src/services/guideline-service.ts`
- function createGuideline: (params, basePath?) => Promise<Guideline>
- function listGuidelines: (filter?, basePath?) => Promise<Guideline[]>
- function readGuideline: (id, basePath?) => Promise<Guideline | null>
- function updateGuideline: (id, params, basePath?) => Promise<Guideline | null>
- function deleteGuideline: (id, basePath?) => Promise<boolean>
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
- _...14 more_
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
- `apps/coder/src/services/lsp/client.ts` — class LspClient
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
- `apps/coder/src/services/lsp/operations.ts`
- function openDocument: (client, filePath, content, version) => Promise<void>
- function closeDocument: (client, filePath) => Promise<void>
- function getDiagnostics: (client, filePath, content) => Promise<Diagnostic[]>
- function gotoDefinition: (client, filePath, content, line, character) => Promise<Location | null>
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
- `apps/coder/src/services/net/port-utils.ts`
- function reclaimPort: (port) => void
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
- function freePort: () => Promise<number>
- `apps/coder/src/services/orphan-worktree-reaper.ts`
- function reapOrphanWorktrees: (sql, log, graceMs, now) => void
- function createOrphanWorktreeReaper: (deps) => void
- interface OrphanWorktreeReaperDeps
- interface OrphanReaperResult
- `apps/coder/src/services/pending_changes.ts`
- function planEdit: (content, oldStr, newStr) => EditPlan
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
- function queueCreate: (sql, sessionId, taskId, filePath, content, projectRoot, // See queueEdit) => Promise<PendingChange>
- function queueDelete: (sql, sessionId, taskId, filePath, projectRoot, // See queueEdit) => Promise<PendingChange>
- function applyOne: (sql, changeId, projectRoot) => Promise<ApplyResult>
- function applyAll: (sql, sessionId, projectRoot) => Promise<ApplyResult[]>
- _...6 more_
- `apps/coder/src/services/permission-waiter.ts`
- function setPermissionHooks: (next) => void
- function waitForPermissionResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<RequestPermissionResponse>
- function respondToPermission: (taskId, optionId, updatedInput?, unknown>) => boolean
- function getPendingPermission: (taskId) => PermissionPrompt | null
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
- function cancelPendingPermission: (taskId) => void
- _...3 more_
- `apps/coder/src/services/provider-commands.ts`
- function getManifestCommands: (provider) => AgentCommand[]
- function mergeCommands: (...lists) => AgentCommand[]
- const PROVIDER_COMMANDS: Record<string, AgentCommand[]>
- `apps/coder/src/services/provider-config-registry.ts`
- function buildResolvedRegistry: (builtins, config) => Map<string, ResolvedProviderDef>
- function loadProviderConfig: (path) => Map<string, ResolvedProviderDef>
- function reloadProviderConfig: () => Map<string, ResolvedProviderDef>
- function getResolvedRegistry: () => Map<string, ResolvedProviderDef>
- interface ResolvedProviderDef
- `apps/coder/src/services/provider-config.ts`
- function mergeProviderConfigPatch: (current, patch) => CoderProvidersFile
- function load: (path) => CoderProvidersFile
- function save: (path, config) => void
- `apps/coder/src/services/provider-diagnostic.ts` — function getProviderDiagnostic: (resolved, agentRow, opts) => Promise<string>, interface DiagnosticAgentRow
- `apps/coder/src/services/provider-manifest.ts`
- function getManifestModes: (provider) => ProviderMode[]
- function getManifestDefaultModeId: (provider) => string | null
- function isUnattendedMode: (provider, modeId) => boolean
- interface ProviderManifestEntry
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
- `apps/coder/src/services/provider-snapshot.ts`
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
- function prefixLlamaSwapModels: (models) => ProviderModel[]
- function mergeModels: (...lists) => ProviderModel[]
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
- function clearProviderSnapshotCache: () => void
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined
- _...1 more_
- `apps/coder/src/services/pty-dispatch.ts`
- function dispatchViaPty: (opts) => Promise<DispatchResult>
- interface DispatchResult
- interface PtyDispatchOpts
- `apps/coder/src/services/qwen-settings.ts` — function readQwenSettingsModels: () => Promise<ProviderModel[]>
- `apps/coder/src/services/stream-json-parser.ts`
- function makeStreamJsonState: () => StreamJsonState
- function parseStreamJsonLine: (line, state) => AgentEvent[]
- function makeStreamJsonParser: () => StreamJsonParser
- interface StreamJsonUsage
- interface StreamJsonState
- interface StreamJsonParser
- _...1 more_
- `apps/coder/src/services/token-analysis/analyzer.ts` — function analyzeMessages: (parts) => TokenBreakdown, interface TokenBreakdown
- `apps/coder/src/services/token-analysis/persist.ts`
- function persistTaskBreakdown: (sql, taskId, breakdown) => Promise<void>
- function getTaskBreakdown: (sql, taskId) => Promise<TokenBreakdown | null>
- function analyzeAndPersistTaskBreakdown: (sql, taskId, parts) => Promise<TokenBreakdown>
- `apps/coder/src/services/tools/adapter.ts` — function adaptWriteTool: (tool) => ServerToolDef<any>
- `apps/coder/src/services/tools/inference_context.ts`
- function runWithInferenceContext: (ctx, fn) => void
- function getInferenceContext: () => InferenceContext
- interface InferenceContext
- `apps/coder/src/services/tools/types.ts`
- function asPermissionMode: (id) => PermissionMode | undefined
- interface ToolJsonSchema
- interface ToolContext
- interface ToolDef
- type PermissionMode
- `apps/coder/src/services/tools/write-gate.ts` — function denyReadOnly: (operation) => unknown, function finalizeWrite: (context, projectRoot, change, queuedHint) => Promise<unknown>
- `apps/coder/src/services/worktree-risk.ts` — function checkWorktreeWorkAtRisk: (worktreePath, opts?) => Promise<WorktreeRiskReport>, function stashWorktree: (worktreePath, opts?) => Promise<
- `apps/coder/src/services/worktrees.ts`
- function createWorktree: (projectPath, taskId, opts?) => Promise<string>
- function diffWorktree: (worktreePath, projectPath, opts?) => Promise<string>
- function cleanupWorktree: (projectPath, taskId) => Promise<void>
- function ensureSessionWorktree: (sql, projectPath, sessionId, opts?) => Promise<SessionWorktree>
- function removeSessionWorktree: (sql, projectPath, worktree, opts?) => Promise<void>
- function closeChatBackendState: (sql, chatId, opts?) => Promise<ChatCloseResult>
- _...4 more_
- `apps/coder/src/services/write_guard.ts`
- function isSecretPath: (filePath) => boolean
- function resolveWritePath: (projectRoot, filePath) => string
- class WriteGuardError
- `apps/server/src/config.ts` — function loadConfig: () => Config, type Config
- `apps/server/src/db.ts`
- function getSql: (config) => Sql
- function applySchema: (sql) => Promise<void>
- function pingDb: (sql) => Promise<boolean>
- function closeDb: () => Promise<void>
- type Sql
- `apps/server/src/services/agents.ts`
- function refreshToolNames: () => void
- function matchToolGlob: (toolName, patterns) => boolean
- function slugify: (name) => string
- function parseAgentsMd: (content) => ParseResult
- function isAgentRegistryMarkdown: (content) => boolean
- function getAgentsMtimes: (projectPath) => void
- _...2 more_
- `apps/server/src/services/artifacts.ts`
- function deriveMarkdownSlug: (messageContent) => string
- function deriveHtmlSlug: (payload) => string
- function deriveHtmlTitle: (html) => string | null
- function detectHtmlArtifact: (text) => string | null
- function decideHtmlArtifactWrite: (htmlContent) => HtmlArtifactDecision
- function writeMarkdownArtifact: (message, 'content'>, ctx) => Promise<ArtifactWriteResult>
- _...6 more_
- `apps/server/src/services/audit/corrections.ts`
- function createCorrection: (params) => UserCorrectionRecord
- function findCorrections: (records, unknown>[]) => UserCorrectionRecord[]
- function checkCorrectionConflict: (proposedAction, corrections) => UserCorrectionRecord | null
- interface UserCorrectionRecord
- `apps/server/src/services/audit/guideline-store.ts`
- class GuidelineDocumentStore
- interface GuidelineContent
- interface Guideline
- interface GuidelineDocument
- interface GuidelineUpdateParams
- type GuidelineId
- _...3 more_
- `apps/server/src/services/audit/journey-projection.ts`
- function projectJourneyToGuidelines: (journey, nodes, edges) => ProjectedGuideline[]
- function detectJourneyBacktrack: (journey, nodes, edges, currentNodeId, previousNodeId) => BacktrackCheck
- interface ProjectedGuideline
- interface BacktrackCheck
- `apps/server/src/services/audit/journey-store.ts`
- class JourneyStore
- interface JourneyNode
- interface JourneyEdge
- interface Journey
- type JourneyId
- type JourneyNodeId
- _...1 more_
- `apps/server/src/services/audit/runs-dir.ts`
- function findRunsDir: (projectRoot?) => string
- function ensureRunsDir: (projectRoot?) => string
- function readCurrentSession: (projectRoot?) => string | null
- function writeCurrentSession: (sessionId, projectRoot?) => void
- function clearCurrentSession: (projectRoot?) => void
- function readIndex: (projectRoot?) => IndexFile
- _...7 more_
- `apps/server/src/services/audit/session-manager.ts`
- function generateSessionId: () => string
- function isoNow: () => string
- function createSession: (task, sessionId?, projectRoot?) => string
- function getSessionDir: (sessionId, projectRoot?) => string
- function getActiveSession: (projectRoot?) => SessionJson | null
- function readSession: (sessionId, projectRoot?) => SessionJson | null
- _...9 more_
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
- `apps/server/src/services/broker.ts`
- function createBroker: (log?) => Broker
- interface Broker
- type Frame
- type Listener
- `apps/server/src/services/codecontext_client.ts`
- function callCodecontext: (req, fetcher) => Promise<CodecontextResponse>
- interface CodecontextRequest
- interface CodecontextResponse
- `apps/server/src/services/coder-notify.ts` — function notifyCoderClose: (kind, id, log?, 'debug'>, fetcher) => Promise<boolean>, type CoderCloseKind
- `apps/server/src/services/compaction.ts`
- function usable: (contextLimit) => number
- function isOverflow: (usage, contextLimit) => boolean
- function estimate: (messages) => number
- function turns: (messages) => Turn[]
- function select: (messages, contextLimit, tailTurns) => SelectResult
- function deriveFilesRead: (head) => string[]
- _...8 more_
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
- `apps/server/src/services/file_ops.ts`
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
- function viewFile: (projectRoot, relPath, opts?) => Promise<ViewFileResult>
- function grep: (projectRoot, pattern, opts?) => Promise<GrepResult>
- function findFiles: (projectRoot, pattern?, opts?) => Promise<FindFilesResult>
- interface FileEntry
- interface ListDirResult
- _...4 more_
- `apps/server/src/services/git_diff.ts`
- function parseNameStatus: (output) => void
- function parseNumStatLine: (line) => void
- function splitDiffByFile: (diffText) => Map<string, string>
- function classifyDiffBody: (body, cap) => 'diff' | 'binary' | 'too_large'
- function autoSelectMode: (isDirty) => GitDiffMode
- function canCommit: (files) => boolean
- _...17 more_
- `apps/server/src/services/git_meta.ts` — function getGitMeta: (rootPath) => Promise<GitMeta | null>, interface GitMeta
- `apps/server/src/services/gitea.ts`
- function createGiteaRepo: (cfg, name, options) => Promise<GiteaRepo>
- class GiteaRepoExistsError
- interface GiteaConfig
- interface GiteaRepo
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
- `apps/server/src/services/inference/dcp/messages.ts`
- function toDcpMessages: (parts) => DcpMessage[]
- function fromDcpMessages: (msgs) => any[]
- interface DcpMessage
- `apps/server/src/services/inference/dcp/state.ts`
- function getDcpState: (chatId) => ChatDcpState | undefined
- function setDcpState: (chatId, messageCount) => void
- function clearDcpState: (chatId) => void
- function shouldTransform: (chatId, messageCount) => boolean
- `apps/server/src/services/inference/dcp/strategies/deduplication.ts` — function deduplicate: (messages) => void
- `apps/server/src/services/inference/dcp/strategies/purge-errors.ts` — function purgeErrors: (messages, windowSize) => void
- `apps/server/src/services/inference/dcp/transform.ts`
- function transformMessages: (chatId, messages) => TransformResult
- interface TransformStats
- interface TransformResult
- `apps/server/src/services/inference/error-handler.ts`
- function handleAbortOrError: (ctx, args, accumulated, err) => Promise<void>
- function finalizeStreamedRow: (ctx, opts) => void
- function finalizeEmpty: (ctx, args) => Promise<void>
- function finalizeCompletion: (ctx, args, result, startedAt, session) => Promise<void>
- `apps/server/src/services/inference/llama-args-validator.ts`
- function validateExtraArgs: (args?) => string[]
- function isManagedFlag: (flag) => boolean
- function stripShadowingFlags: (args, opts?) => string[]
- interface StripOptions
- `apps/server/src/services/inference/loop-detectors.ts`
- function detectContentRepeat: (messages) => LoopDetectionResult
- function detectToolLoop: (toolNames) => LoopDetectionResult
- function detectDoomLoop: (messages, toolNames) => LoopDetectionResult
- interface LoopDetectionResult
- `apps/server/src/services/inference/mistake-tracker.ts`
- function freshMistakeState: () => MistakeState
- function recordStep: (state, outcome) => void
- function detectMistakePattern: (state) => 'nudge' | 'escalate' | null
- interface MistakeState
- type FailureKind
- const MISTAKE_THRESHOLD
- _...1 more_
- `apps/server/src/services/inference/parts.ts`
- function insertParts: (sql, parts) => Promise<void>
- function partsFromAssistantMessage: (args) => void
- function partsFromToolMessage: (args) => Omit<PartInsert, 'message_id'>[]
- interface PartInsert
- type PartKind
- `apps/server/src/services/inference/payload.ts`
- function buildMessagesPayload: (session, project, history, agent, log?) => Promise<OpenAiMessage[]>
- function loadContext: (sql, sessionId, chatId) => Promise<
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
- interface OpenAiMessage
- `apps/server/src/services/inference/provider.ts`
- function resolveRoute: (agent, config?) => RoutingInfo
- function upstreamModel: (config, modelId, agent?) => LanguageModel
- interface RoutingInfo
- type InferenceRoute
- `apps/server/src/services/inference/prune.ts`
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
- function prune: (args) => Promise<PruneResult>
- interface PruneResult
- interface PartForPrune
- const PROTECTED_TOKENS
- const PRUNE_TRIGGER_TOKENS
- `apps/server/src/services/inference/sentinel-summaries.ts`
- function runCapHitSummary: (ctx, args, session, project, history, agent, budget) => Promise<void>
- function runDoomLoopSummary: (ctx, args, session, project, history, agent, loop, unknown> }) => Promise<void>
- function runStepCapSummary: (ctx, args, session, project, history, agent, steps, cap) => Promise<void>
- function insertMistakeRecoverySentinel: (ctx, sessionId, chatId, opts) => Promise<void>
- `apps/server/src/services/inference/sentinels.ts`
- function detectDoomLoop: (recentToolCalls) => void
- function isCapHitSentinel: (m) => boolean
- function isDoomLoopSentinel: (m) => boolean
- function isMistakeRecoverySentinel: (m) => boolean
- function isAnySentinel: (m) => boolean
- const DOOM_LOOP_THRESHOLD
- _...1 more_
- `apps/server/src/services/inference/step-decision.ts`
- function decideStep: (input) => PreStepDecision
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
- type PreStepDecision
- type PostToolDecision
- `apps/server/src/services/inference/stream-error-classifier.ts` — function classifyStreamError: (err) => StreamErrorKind, type StreamErrorKind
- `apps/server/src/services/inference/stream-phase-adapter.ts`
- function samplerOptsFromAgent: (agent) => SamplerOpts
- function streamCompletion: (ctx, model, messages, opts, onDelta) => void
- interface StreamAdapterContext
- interface StreamOptions
- type SamplerOpts
- const STALL_TIMEOUT_MS
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
// tool list sent to the LLM, so the model can't even attempt them.
webToolsEnabled) => Promise<StreamResult>
- `apps/server/src/services/inference/tool-call-parser.ts`
- function stripToolMarkup: (text, opts?) => string
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
- interface ParsedCall
- interface ToolCallExtraction
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
- `apps/server/src/services/inference/tool-shim.ts`
- function extractToolCalls: (text) => ParsedToolCall[]
- function hasToolCallMarkup: (text) => boolean
- interface ParsedToolCall
- `apps/server/src/services/inference/tool-suggestions.ts`
- function levenshtein: (a, b) => number
- function suggestToolName: (name, available) => string | null
- function formatUnknownToolError: (name, available) => string
- `apps/server/src/services/inference/turn-config.ts`
- function resolveTurnConfig: (agent) => TurnConfig
- interface TurnConfig
- const MAX_STEPS
- `apps/server/src/services/inference/turn.ts`
- function runAssistantTurn: (ctx, args) => Promise<void>
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
- `apps/server/src/services/mcp-client.ts`
- function initialize: (entries, logger) => Promise<void>
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
- function getTools: () => ToolDef<Record<string, unknown>>[]
- function getMcpServers: () => Array<
- function shutdown: () => Promise<void>
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
- _...2 more_
- `apps/server/src/services/mcp-config.ts`
- function substituteEnvVars: (value, log, unsetVars?) => unknown
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
- interface McpServerEntry
- type McpServerConfig
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
- `apps/server/src/services/memory/paths.ts`
- function getMemoryRoot: (projectRoot) => string
- function getTopicDir: (root, topic) => string
- function ensureMemoryScaffold: (root) => Promise<void>
- type MemoryTopic
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
- `apps/server/src/services/memory/recall.ts` — function rankByRelevance: (query, entries) => MemoryEntry[], function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
- `apps/server/src/services/memory/scan.ts`
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
- interface MemoryScope
- `apps/server/src/services/memory/store.ts` — function readTopicFiles: (root, topic) => Promise<Map<string, string>>, function writeEntry: (root, topic, title, content, tags) => Promise<void>
- `apps/server/src/services/model-context.ts`
- function configureModelContext: (opts) => void
- function getModelContext: (model) => Promise<ModelContext | null>
- function invalidateModelContext: (model?) => void
- interface ModelContext
- `apps/server/src/services/path_guard.ts`
- function resolveProjectRoot: (projectPath) => Promise<string>
- function pathGuard: (projectRoot, requested, extraRoots) => Promise<string>
- class PathScopeError
- `apps/server/src/services/project_bootstrap.ts`
- function sanitizeFolderName: (raw) => string
- function bootstrapProject: (config, log, options) => Promise<BootstrapResult>
- class BootstrapNameError
- class BootstrapCollisionError
- class BootstrapPathError
- interface BootstrapResult
- `apps/server/src/services/read_tab_by_number.ts`
- function executeReadTabByNumber: (input, sql, sessionId) => Promise<string>
- type ReadTabByNumberInputT
- const readTabByNumber: ToolDef<ReadTabByNumberInputT>
- `apps/server/src/services/secret_guard.ts`
- function isSecretPath: (relPath) => boolean
- function filterSecretEntries: (entries, pathOf) => void
- class SecretBlockedError
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
- `apps/server/src/services/skill-invoke.ts`
- function runSkillInvokeTransaction: (sql, args) => Promise<
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
- function buildSkillInvokeUserFrames: (chatId, userMessageId, userText) => SkillInvokeSessionFrame[]
- interface SkillInvokeTransactionResult
- interface SkillInvokeToolCall
- type SkillInvokeSessionFrame
- _...1 more_
- `apps/server/src/services/skills.ts`
- function listSkills: () => Promise<Skill[]>
- function findSkills: (query) => Promise<SkillSummary[]>
- function getSkillBody: (name) => Promise<string | null>
- function getSkillResource: (name, relativePath) => Promise<SkillResourceResult>
- interface Skill
- interface SkillSummary
- _...2 more_
- `apps/server/src/services/synthesisPipeline.ts`
- function runSynthesisPass: (p) => Promise<boolean>
- interface SynthesisParams
- const SYNTHESIS_TOOLS: ReadonlySet<string>
- `apps/server/src/services/system-prompt.ts`
- function loadContainerGuidance: () => Promise<string | null>
- function getContainerGuidance: () => Promise<string | null>
- function _resetContainerGuidanceCacheForTests: () => void
- function _resetPrefixObserverForTests: () => void
- function buildSystemPromptWithFingerprint: (project, session, agent) => Promise<
- function buildSystemPrompt: (project, session, agent) => Promise<string>
- _...2 more_
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
mapArgs) => void
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
- `apps/server/src/services/tools/tiers.ts`
- function resolveToolTier: (tier) => readonly string[]
- const CORE_TOOL_NAMES
- const STANDARD_TOOL_NAMES
- `apps/server/src/services/truncate.ts`
- function storeTruncation: (fullContent) => Promise<string>
- function readTruncation: (id) => Promise<string | null>
- function truncateIfNeeded: (args) => Promise<
- function cleanupTruncations: (args, msg) => void
- const TRUNCATION_DIR
- const TRUNCATION_TTL_MS
- _...1 more_
- `apps/server/src/services/url_guard.ts` — function isPublicUrl: (input) => UrlGuardResult, interface UrlGuardResult
- `apps/server/src/services/web/html-to-md.ts` — function htmlToMarkdown: (sourceHtml) => string
- `apps/server/src/services/web_fetch.ts`
- function executeWebFetch: (input, fetcher) => Promise<WebFetchOutput>
- type WebFetchInputT
- type WebFetchOutput
- const webFetch: ToolDef<WebFetchInputT>
- `apps/server/src/services/web_search.ts`
- function executeWebSearch: (input, searxngUrl, fetcher) => Promise<WebSearchOutput>
- interface WebSearchOutput
- type WebSearchInputT
- const webSearch: ToolDef<WebSearchInputT>
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
- `apps/web/src/api/client.ts`
- class ApiError
- interface AgentSessionInfo
- interface CoderCheckpoint
- interface CoderRestoreResult
- const api
- `apps/web/src/data/acp-provider-catalog.ts`
- function buildAcpProviderConfigPatch: (entry) => ProviderConfigPatch
- interface AcpCatalogEntry
- const ACP_PROVIDER_CATALOG: AcpCatalogEntry[]
- `apps/web/src/hooks/terminal/useTerminalFit.ts`
- function cellSize: (term, container) => void
- function useTerminalFit: ({...}, containerRef, sessionId, paneId }) => TerminalFit
- interface TerminalFit
- `apps/web/src/hooks/terminal/useTerminalSelection.ts`
- function useTerminalSelection: ({...}, containerRef, sessionId, paneId, label, send, }) => TerminalSelection
- interface TerminalSelectionActions
- interface TerminalSelection
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
- function useTerminalSocket: ({...}, sessionId, paneId, fit, getSize, setSize, }) => TerminalSocket
- interface TerminalSocket
- type ConnState
- `apps/web/src/hooks/useActivePane.ts`
- function setActivePaneInfo: (next) => void
- function clearActivePane: () => void
- function useActivePane: () => ActivePaneSnapshot
- interface ActivePaneSnapshot
- `apps/web/src/hooks/useAgentSessions.ts` — function refreshAgentSessions: (sessionId) => Promise<AgentSessionInfo[]>, function useAgentSessions: (sessionId) => void
- `apps/web/src/hooks/useAgentStatus.ts`
- function useAgentStatus: () => void
- interface AgentStatusEntry
- type AgentStatus
- `apps/web/src/hooks/useArtifactDownload.ts` — function useArtifactDownload: (chatId, messageId, format) => void
- `apps/web/src/hooks/useChatStatus.ts`
- function useChatStatus: (chatId) => DerivedStatus
- type RawStatus
- type DerivedStatus
- `apps/web/src/hooks/useChatThroughput.ts`
- function recordUsage: (chatId, data) => void
- function useChatThroughput: (chatId) => ThroughputSample | null
- interface ThroughputSample
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId) => void
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
- `apps/web/src/hooks/usePullToRefresh.ts` — function usePullToRefresh: (onRefresh) => void
- `apps/web/src/hooks/useSessionChats.ts`
- function useSessionChats: (sessionId, opts) => UseSessionChatsResult
- interface UseSessionChatsOpts
- interface UseSessionChatsResult
- `apps/web/src/hooks/useSessionStream.ts` — function useSessionStream: (sessionId) => void
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
- `apps/web/src/hooks/useWorkspacePanes.ts`
- function activePaneChatId: (pane) => string | undefined
- function useWorkspacePanes: (sessionId) => UseWorkspacePanesResult
- interface UseWorkspacePanesResult
- const MAX_PANES
- `apps/web/src/hooks/wsReconnectToast.ts` — function createWsReconnectToast: (opts) => WsReconnectToast, interface WsReconnectToast
- `apps/web/src/lib/anim.ts`
- function getAnimBg: () => boolean
- function setAnimBg: (on) => void
- function setAnimDensity: (v) => void
- function setAnimSpeed: (v) => void
- function setAnimOpacity: (v) => void
- function useAnimBg: () => boolean
- _...3 more_
- `apps/web/src/lib/attachments.ts`
- function looksBinary: (content) => boolean
- function inferLanguage: (filename) => string | null
- function flattenToMessage: (attachments, text) => string
- type Attachment
- const MAX_FILE_SIZE_BYTES
- const PASTE_INLINE_MAX_LINES
- _...1 more_
- `apps/web/src/lib/coder-session.ts` — function isCoderSessionName: (name) => boolean
- `apps/web/src/lib/coder-tools.ts`
- function wireToolCallToRun: (wire) => ToolRun
- function mergeWireToolCall: (existing, incoming, unknown> }) => CoderToolCallWire[]
- interface AcpWireMeta
- interface CoderToolCallWire
- `apps/web/src/lib/format.ts`
- function relTime: (iso) => string
- function formatRelative: (iso) => string
- function formatAgo: (iso) => string
- `apps/web/src/lib/model-label.ts` — function formatModelLabel: (raw) => string
- `apps/web/src/lib/modelName.ts` — function shortenModelName: (model) => string | null
- `apps/web/src/lib/permission-mode.ts`
- function nativeModeForPermission: (mode, modes, defaultModeId) => string | null
- function permissionForModeId: (modeId, modes) => PermissionMode
- function availablePermissionModes: (modes) => Array<
- type PermissionMode
- const PERMISSION_LABELS: Record<PermissionMode, string>
- `apps/web/src/lib/projectUrls.ts` — function giteaUrlFor: (project) => string
- `apps/web/src/lib/slash-command.ts`
- function isSlashCommandToken: (value) => boolean
- function slashQuery: (value) => string
- function parseSlashInput: (text) => void
- function mergeCommandsByName: (...lists) => T[]
- interface SlashCommandItem
- `apps/web/src/lib/terminal-protocol.ts`
- function encodeInput: (text) => Uint8Array
- function encodeResize: (cols, rows) => string
- function parseServerFrame: (data) => ServerControlFrame | null
- type ServerControlFrame
- `apps/web/src/lib/theme.ts`
- function isThemeId: (s) => s is ThemeId
- function applyTheme: (id, mode) => void
- function setTheme: (id, mode) => Promise<void>
- function useTheme: () => ThemeState
- interface ThemeMeta
- type ThemeId
- _...5 more_
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
- `apps/web/src/utils/diff-layout.ts`
- function parseDiff: (diffBody) => ParsedDiffFile[]
- function buildSplitRows: (file) => SplitRow[]
- function reconstructNewContent: (hunks) => string
- interface DiffLine
- interface DiffHunk
- interface ParsedDiffFile
- _...3 more_
- `conductor/src/contracts.ts`
- function produceContract: (contracts) => string
- function reviewContract: (contracts) => string
- type Contract
- const EVIDENCE_PRODUCE
- const EVIDENCE_REVIEW
- const YAGNI_PRODUCE
- _...1 more_
- `conductor/src/dispatch.ts`
- function loadPersona: (agent) => Promise<string>
- function dispatchAgent: (agent, task, opts) => Promise<string>
- function cleanOutput: (raw) => string
- `conductor/src/flow.ts` — function runFlow: (flow, input, opts) => Promise<RunResult>, interface RunOptions
- `conductor/src/flows/_util.ts` — function q, function repoLine
- `conductor/src/flows/index.ts`
- function describeFlows: () => string
- function getFlow: (name) => Flow | undefined
- const FLOWS: Record<string, Flow>
- const FLOW_NAMES: string[]
- `conductor/src/render.ts` — function slugify: (s) => string
- `conductor/src/spine.ts`
- function readBand: (input) => Band
- function fastNote: (ctx) => string
- function buildSpineFlow: (spine) => Flow
- `data/skills/superpowers/systematic-debugging/condition-based-waiting-example.ts`
- function waitForEvent: (threadManager, threadId, eventType, timeoutMs) => Promise<LaceEvent>
- function waitForEventCount: (threadManager, threadId, eventType, count, timeoutMs) => Promise<LaceEvent[]>
- function waitForEventMatch: (threadManager, threadId, predicate) => void
- `packages/ion/src/cli/commands/abandon.ts` — function abandonCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/approve.ts` — function approveCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/cleanup.ts` — function cleanupCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/convert.ts` — function convertCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/list.ts` — function listCommand: (_args, options) => Promise<void>
- `packages/ion/src/cli/commands/reject.ts` — function rejectCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/resume.ts` — function resumeCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/run.ts` — function runCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/runs.ts` — function runsCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/commands/status.ts` — function statusCommand: (_args, options) => Promise<void>
- `packages/ion/src/cli/commands/validate.ts` — function validateCommand: (args, options) => Promise<void>
- `packages/ion/src/cli/index.ts` — function main: (argv) => void
- `packages/ion/src/cli/utils.ts`
- function formatDuration: (ms) => string
- function formatTimestamp: (date) => string
- function truncate: (str, max) => string
- function printTable: (rows, unknown>[], columns) => void
- function printJson: (data) => void
- function parseArgs: (argv) => void
- _...3 more_
- `packages/ion/src/engine/command-validation.ts` — function isValidCommandName: (name) => boolean
- `packages/ion/src/engine/condition-evaluator.ts` — function evaluateCondition: (expression, nodeOutputs, Record<string, unknown>>) => boolean, class ConditionError
- `packages/ion/src/engine/dag-executor.ts`
- function buildTopologicalLayers: (nodes) => DagNode[][]
- function checkTriggerRule: (node, nodeOutputs, NodeOutput>) => 'run' | 'skip'
- function executeNodeInternal: (node, deps, platform, conversationId, cwd, config, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
- function executeScriptNode: (node, cwd, envVars, string>, artifactsDir) => Promise<NodeExecutionResult>
- function handleApprovalNode: (node, deps, platform, conversationId, workflowRunId, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
- function handleLoopNode: (node, deps, platform, conversationId, cwd, config, nodeOutputs, NodeOutput>, workflowVariables, unknown>) => Promise<NodeExecutionResult>
- _...2 more_
- `packages/ion/src/engine/event-emitter.ts`
- function getWorkflowEventEmitter: () => WorkflowEventEmitter
- class WorkflowEventEmitter
- interface WorkflowEventBase
- interface WorkflowStartedEvent
- interface WorkflowCompletedEvent
- interface WorkflowFailedEvent
- _...11 more_
- `packages/ion/src/engine/executor-shared.ts`
- function substituteWorkflowVariables: (template, context) => string
- function buildPromptWithContext: (template, context, issueContext?) => string
- function classifyError: (error) => ErrorClassification
- function safeSendMessage: (platform, conversationId, message, metadata?, unknown>) => Promise<boolean>
- function detectCompletionSignal: (output, until) => boolean
- function stripCompletionTags: (output, until) => string
- _...5 more_
- `packages/ion/src/engine/executor.ts`
- function executeWorkflow: (deps, platform, conversationId, cwd, workflow, userMessage, opts) => Promise<WorkflowExecutionResult>
- function hydrateResumableRun: (deps, candidate) => Promise<HydratedResumableRun>
- function resolveProjectPaths: (_deps, cwd, workflowRunId, codebaseId?) => ProjectPaths
- interface WorkflowExecutionOptions
- interface WorkflowExecutionResult
- interface HydratedResumableRun
- _...1 more_
- `packages/ion/src/engine/model-validation.ts`
- function isLiteralSpec: (spec) => spec is LiteralModelSpec
- function buildAiProfile: (opts) => AiProfile
- function resolveModelSpec: (profile, modelRef) => LiteralModelSpec
- interface LiteralModelSpec
- interface ModelAliasPreset
- interface AiProfileTiers
- _...2 more_
- `packages/ion/src/engine/output-ref.ts`
- function declaredFieldsFromSchema: (outputFormat, unknown> | string | undefined) => Set<string>
- function resolveNodeOutputField: (nodeOutput, unknown>, nodeId, field, declaredFields?) => OutputRefResult
- class OutputRefError
- interface OutputRefResult
- type OutputRefKind
- `packages/ion/src/engine/utils.ts`
- function substituteWorkflowVariables: (template, variables, unknown>) => string
- function substituteNodeOutputRefs: (prompt, nodeOutputs, NodeOutput>, escapedForBash) => string
- function resolveNodeOutputField: (output, field) => string
- function buildPromptWithContext: (prompt, variables, unknown>, nodeOutputs, NodeOutput>, escapedForBash) => string
- function evaluateCondition: (condition, variables, unknown>) => boolean
- function classifyError: (error) => ErrorCategory
- _...10 more_
- `packages/ion/src/format/sop-discovery.ts` — function discoverSopFiles: (cwd, globFn) => Promise<string[]>, type GlobFn
- `packages/ion/src/format/sop-parser.ts`
- function parseSopContent: (markdown) => SopDocument
- interface SopParameter
- interface SopStep
- interface SopDocument
- `packages/ion/src/format/sop-to-yaml.ts` — function convertSopToWorkflowYaml: (sop) => string
- `packages/ion/src/schema/dag-node.ts`
- function isBashNode: (node) => node is BashNode
- function isScriptNode: (node) => node is ScriptNode
- function isLoopNode: (node) => node is LoopNode
- function isApprovalNode: (node) => node is ApprovalNode
- function isCancelNode: (node) => node is CancelNode
- function isPromptNode: (node) => node is PromptNode
- _...27 more_
- `packages/ion/src/store/fs-store.ts` — function createFsStore: (basePath) => IWorkflowStore
- `packages/ion/src/store/pg-store.ts` — function createPostgresStore: (connectionString) => Promise<IWorkflowStore>
- `packages/ion/src/store/sqlite-store.ts` — function createSqliteStore: (dbPath) => Promise<IWorkflowStore>

23
.codesight/middleware.md Normal file
View File

@@ -0,0 +1,23 @@
# Middleware
## auth
- auth — `apps/booterm/src/auth.ts`
- authoring — `apps/coder/src/conductor/flows/authoring.ts`
- turn-guard.test — `apps/coder/src/services/backends/__tests__/turn-guard.test.ts`
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
- authoring — `conductor/src/flows/authoring.ts`
## custom
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
- write_guard_fuzz.test — `apps/coder/src/services/__tests__/write_guard_fuzz.test.ts`
- edit-guards-imports — `apps/coder/src/services/edit-guards-imports.ts`
- write_guard — `apps/coder/src/services/write_guard.ts`
- secret_guard.test — `apps/server/src/services/__tests__/secret_guard.test.ts`
- path_guard — `apps/server/src/services/path_guard.ts`
- secret_guard — `apps/server/src/services/secret_guard.ts`
- url_guard — `apps/server/src/services/url_guard.ts`
## validation
- edit-guards — `apps/coder/src/services/edit-guards.ts`
- path_guard.test — `apps/server/src/services/__tests__/path_guard.test.ts`

141
.codesight/routes.md Normal file
View File

@@ -0,0 +1,141 @@
# Routes
## CRUD Resources
- **`/api/battles`** GET | POST | GET/:id → Battle
- **`/api/runs`** GET | POST | GET/:id → Run
- **`/api/tasks`** GET | POST | GET/:id → Task
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
- **`/api/projects`** GET | POST | GET/:id | PATCH/:id | DELETE/:id → Project
- **`/api/sessions`** GET/:id | PATCH/:id | DELETE/:id → Session
## Other Routes
### fastify
- `GET` `/api/term/health` params()
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
- `GET` `/api/health` params() [auth, db, queue, ai]
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
- `POST` `/api/battles/generate-prompt` params() [auth, db]
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
- `POST` `/api/battles/:id/analyze` params(id) [auth, db]
- `PATCH` `/api/battles/:id/winner` params(id) [auth, db]
- `GET` `/api/battles/:id/contestants/:cid/diff` params(id, cid) [auth, db]
- `POST` `/api/battles/:id/cross-examine` params(id) [auth, db]
- `GET` `/api/sessions/:sessionId/checkpoints` params(sessionId) [auth, db]
- `POST` `/api/sessions/:sessionId/checkpoints/:checkpointId/restore` params(sessionId, checkpointId) [auth, db]
- `GET` `/api/inbox` params() [auth, db]
- `POST` `/api/inbox/:id/retry` params(id) [auth, db]
- `POST` `/api/chats/:chatId/close` params(chatId) [auth, db]
- `POST` `/api/sessions/:sessionId/close` params(sessionId) [auth, db]
- `GET` `/api/sessions/:sessionId/messages` params(sessionId) [auth, db, queue]
- `POST` `/api/sessions/:sessionId/messages` params(sessionId) [auth, db, queue]
- `POST` `/api/chats/:id/answer_user_input` params(id) [auth, db, queue]
- `POST` `/api/sessions/:sessionId/stop` params(sessionId) [auth, db, queue]
- `GET` `/api/sessions/:sessionId/pending` params(sessionId) [auth, db, queue]
- `POST` `/api/sessions/:sessionId/pending/create` params(sessionId) [auth, db, queue]
- `POST` `/api/sessions/:sessionId/pending/apply` params(sessionId) [auth, db, queue]
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
- `GET` `/api/providers/snapshot` params() [db, cache]
- `GET` `/api/providers/config` params() [db, cache]
- `PATCH` `/api/providers/config` params() [db, cache]
- `POST` `/api/providers/refresh` params() [db, cache]
- `GET` `/api/providers/:id/diagnostic` params(id) [db, cache]
- `POST` `/api/runs/:id/cancel` params(id) [auth, db]
- `POST` `/api/sessions/:sessionId/skill_invoke` params(sessionId) [auth, db, queue]
- `GET` `/api/stats/costs` params() [auth, db]
- `POST` `/api/tasks/:id/cancel` params(id) [auth, db, cache, ai]
- `GET` `/api/tasks/:id/permission` params(id) [auth, db, cache, ai]
- `POST` `/api/tasks/:id/permission` params(id) [auth, db, cache, ai]
- `GET` `/api/tasks/:id/commands` params(id) [auth, db, cache, ai]
- `GET` `/api/sessions/:sessionId/worktree-risk` params(sessionId) [auth, db]
- `POST` `/api/sessions/:sessionId/worktree-stash` params(sessionId) [auth, db]
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
- `GET` `/api/ws/user` params() [auth, db]
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
- `GET` `/api/sessions/:id/chats` params(id) [auth, db]
- `POST` `/api/sessions/:id/chats` params(id) [auth, db]
- `PATCH` `/api/chats/:id` params(id) [auth, db]
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db]
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db]
- `POST` `/api/chats/:id/archive` params(id) [auth, db]
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db]
- `DELETE` `/api/chats/:id` params(id) [auth, db]
- `POST` `/api/chats/:id/fork` params(id) [auth, db]
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db]
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
- `ALL` `/api/coder/*` params() [auth]
- `GET` `/api/settings/inference` params() [cache]
- `PATCH` `/api/settings/inference` params() [cache]
- `GET` `/api/sessions/:id/messages` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/messages/:message_id/regenerate` params(id, message_id) [auth, db, queue]
- `POST` `/api/chats/:id/compact` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/stop` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
- `GET` `/api/models` params()
- `POST` `/api/projects/create` params() [auth, db]
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
- `GET` `/api/projects/available` params() [auth, db]
- `GET` `/api/projects/:id/list_dir` params(id) [auth, db]
- `GET` `/api/projects/:id/view_file` params(id) [auth, db]
- `GET` `/api/projects/:id/git` params(id) [auth, db]
- `GET` `/api/projects/:id/git/diff` params(id) [auth, db]
- `POST` `/api/projects/:id/git/stage` params(id) [auth, db]
- `POST` `/api/projects/:id/git/unstage` params(id) [auth, db]
- `POST` `/api/projects/:id/git/commit` params(id) [auth, db]
- `POST` `/api/projects/:id/git/discard` params(id) [auth, db]
- `POST` `/api/projects/:id/write_file` params(id) [auth, db]
- `GET` `/api/projects/:id/files` params(id) [auth, db]
- `GET` `/api/projects/:id/sessions` params(id) [auth, db]
- `POST` `/api/projects/:id/sessions` params(id) [auth, db]
- `PATCH` `/api/sessions/:id/workspace` params(id) [auth, db]
- `POST` `/api/projects/:id/sessions/archive-all` params(id) [auth, db]
- `GET` `/api/projects/:id/sessions/open-count` params(id) [auth, db]
- `POST` `/api/sessions/:id/archive` params(id) [auth, db]
- `POST` `/api/sessions/:id/unarchive` params(id) [auth, db]
- `GET` `/api/settings` params() [db]
- `PATCH` `/api/settings` params() [db]
- `GET` `/api/sidebar` params() [auth, db]
- `GET` `/api/skills` params() [auth, db, queue]
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
- `GET` `/api/tools/cost_stats` params() [auth, db]
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
### go-net-http
- `GET` `/health` params() [queue]
- `POST` `/v1/get_codebase_overview` params() [queue]
- `POST` `/v1/get_file_analysis` params() [queue]
- `POST` `/v1/get_symbol_info` params() [queue]
- `POST` `/v1/search_symbols` params() [queue]
- `POST` `/v1/get_dependencies` params() [queue]
- `POST` `/v1/watch_changes` params() [queue]
- `POST` `/v1/get_semantic_neighborhoods` params() [queue]
- `POST` `/v1/get_framework_analysis` params() [queue]
- `POST` `/v1/get_symbol_details` params() [queue]
- `POST` `/v1/get_call_graph` params() [queue]
- `POST` `/v1/get_blast_radius` params() [queue]
## WebSocket Events
- `WS` `message``apps/booterm/src/ws/attach.ts`
- `WS` `close``apps/booterm/src/ws/attach.ts`
- `WS` `message``apps/coder/src/cli.ts`
- `WS` `error``apps/coder/src/cli.ts`
- `WS` `close``apps/coder/src/cli.ts`
- `WS` `close``apps/coder/src/routes/ws.ts`
- `WS` `error``apps/coder/src/routes/ws.ts`
- `WS` `close``apps/server/src/routes/ws.ts`
- `WS` `error``apps/server/src/routes/ws.ts`

157
.codesight/schema.md Normal file
View File

@@ -0,0 +1,157 @@
# Schema
### pending_changes
- id: uuid (pk)
- session_id: uuid (required, fk)
- task_id: uuid (fk)
- file_path: text (required)
- operation: text (required)
- diff: text (required)
- status: text (required)
### tasks
- id: uuid (pk)
- project_id: uuid (required, fk)
- parent_task_id: uuid (fk)
- state: text (required)
- input: text (required)
- output_summary: text
- agent: text
- model: text
- execution_path: text
- cost_tokens: integer
- started_at: timestamp(tz)
- ended_at: timestamp(tz)
### available_agents
- name: text (pk)
- install_path: text
- version: text
- supports_acp: boolean (required)
- last_probed_at: timestamp(tz)
### agent_sessions
- session_id: uuid (required, fk)
- agent: text (required)
- backend: text (required)
- agent_session_id: text (fk)
- server_port: integer
- status: text (required)
- last_active_at: timestamp(tz)
### worktrees
- id: uuid (pk)
- session_id: uuid (fk)
- project_id: uuid (fk)
- path: text (required)
- branch: text
- base_commit: text
- slug: text
- status: text (required)
### checkpoints
- id: uuid (pk)
- chat_id: uuid (required, fk)
- session_id: uuid (fk)
- worktree_id: uuid (fk)
- message_id: uuid (fk)
### claude_session_entries
- id: bigint(auto) (pk)
- project_key: text (required)
- session_id: text (required, fk)
- subpath: text (required)
### flow_runs
- id: uuid (pk)
- project_id: uuid (required, fk)
- flow_name: text (required)
- band: text (required)
- model: text (required)
- status: text (required)
- input: jsonb (required)
- report: text
- error: text
### flow_steps
- id: uuid (pk)
- run_id: uuid (required, fk)
- step_id: text (required, fk)
- kind: text (required)
- agent: text
- status: text (required)
- task_id: uuid (fk)
- chat_id: uuid (fk)
- input: text
- output: text
- error: text
### battles
- id: uuid (pk)
- project_id: uuid (required, fk)
- battle_type: text (required)
- prompt: text (required)
- status: text (required)
- winner_contestant_id: uuid (fk)
- results_path: text
- error: text
### contestants
- id: uuid (pk)
- battle_id: uuid (required, fk)
- identity: text (required)
- model: text (required)
- lane: text (required)
- task_id: uuid (fk)
- worktree_id: uuid (fk)
- status: text (required)
- duration_ms: integer
- tokens_per_sec: float8
- cost_tokens: integer
- result_path: text
- error: text
### cross_examinations
- id: uuid (pk)
- battle_id: uuid (required, fk)
- identity: text (required)
- model: text (required)
- verdict: text
### projects
- id: uuid (pk)
- name: text (required)
- path: text (required)
- added_at: timestamp(tz) (required)
- last_session_id: uuid (fk)
### sessions
- id: uuid (pk)
- project_id: uuid (required, fk)
- name: text (required)
- model: text (required)
- system_prompt: text (required)
### messages
- id: uuid (pk)
- session_id: uuid (required, fk)
- role: text (required)
- content: text (required)
- status: text (required)
- last_seq: integer (required)
### message_parts
- id: uuid (pk)
- message_id: uuid (required, fk)
- sequence: integer (required)
- kind: text (required)
- payload: jsonb (required)
### settings
- value: jsonb (required)
### chats
- id: uuid (pk)
- session_id: uuid (required, fk)
- name: text
- status: text (required)

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ data/*
!data/mcp.example.json !data/mcp.example.json
!data/coder-providers.example.json !data/coder-providers.example.json
codecontext/fork.tar.gz codecontext/fork.tar.gz
/Arena

View File

@@ -0,0 +1,89 @@
# Draft: openspec-cleanup
## Cross-Reference: Git Tags vs openspec Batches
### Archived Stub Files — Tag Verification
| Stub File | Claims Version | Actual Tag | Verdict |
|---|---|---|---|
| `v1.13.12-skills-audit.md` (57B) | v1.13.12 | `v1.13.14-skills-audit` | **WRONG** — off by 2 versions |
| `v1.13.15-codecontext-synth.md` (62B) | v1.13.15 | `v1.13.15-codecontext-synth` | ✅ correct |
| `v1.13.17-cross-repo-reads.md` (61B) | v1.13.17 | `v1.13.17-cross-repo-reads` | ✅ correct |
| `v1.13.18-codecontext-file-path.md` (66B) | v1.13.18 | `v1.13.18-codecontext-file-path` | ✅ correct |
| `v1.13.20-drop-legacy-cols.md` (61B) | v1.13.20 | `v1.13.20-drop-legacy-cols` | ✅ correct |
| `v1.14-outer-loop.md` (52B) | v1.14 | `v1.14.0-outer-loop` | ⚠️ close (1.14 → 1.14.0) |
| `v1.14.1-mcp-poc.md` (51B) | v1.14.1 | `v1.14.1-mcp-poc` | ✅ correct |
| `v1.14.x-html-artifact-panes.md` (63B) | v1.14.x | `v1.13.19-html-artifact-panes` | **WRONG** — shipped as 1.13.19 |
| `v1.15-mcp-multi.md` (51B) | v1.15 | `v1.15.0-mcp-multi` | ⚠️ close (1.15 → 1.15.0) |
| `v2.0-boocoder.md` (49B) | v2.0 | `v2.0.0` | ⚠️ close (2.0 → 2.0.0) |
| `v2.2-paseo-providers.md` (222B) | v2.2 | `v2.2-paseo-providers` | ✅ correct |
### Archived Folder Entries — Tag Verification
| Archived Folder | Git Tag(s) | Status |
|---|---|---|
| `agent-status-normalize/` | `v2.7.6-agent-status-normalize` | ✅ shipped |
| `claude-sdk-sessionstore/` | `v2.7.5-claude-sdk-sessionstore` | ✅ shipped |
| `contracts-ssot/` | `v2.7.13-contracts-ssot` | ✅ shipped |
| `license-debt-mit/` | `v2.7.0-mit` | ✅ shipped |
| `mistake-tracker-file-ledger/` | `v2.7.4-mistake-tracker-ledger` | ✅ shipped (slug differs slightly) |
| `orchestrator/` | `v2.7.17-orchestrator` | ✅ shipped |
| `sampling-streamjson-tokens/` | `v2.7.3-sampling-streamjson-tokens` | ✅ shipped |
| `v2-3-provider-lifecycle/` | `v2.5.4-*` through `v2.5.13-*` | ✅ shipped (diff version numbering) |
| `v2-6-persistent-agent-sessions/` | `v2.6.4-*`, `v2.6.8-*` | ✅ shipped |
| `write-edit-robustness/` | `v2.7.1-write-edit-robustness` | ✅ shipped |
### Misplaced Proposals in Archived/
| 2026-06-07 Folder | Git Tag? | Actually Shipped? | Should Be |
|---|---|---|---|
| `2026-06-07-boocontext/` | **None** | No | `changes/boocontext/` (partly shipped in v2.8.0) |
| `2026-06-07-eval-sandbox-agent-runtime/` | **None** | No | Merge into `changes/import-*` |
| `2026-06-07-hybrid-workflow-engine/` | **None** | No | Merge into `changes/orchestrator-flow-advanced/` |
| `2026-06-07-memory-context-engineering/` | **None** | No | Merge into `changes/memory-context/` |
| `2026-06-07-port-audit-parlant-patterns/` | **None** | No | Merge into `changes/add-behavioral-engine/` |
## Active Batches — All Uncommitted, All Unshipped
All 22 active batches (changes/*/) have **zero** git tags or commits referencing them. Every batch was created locally on 2026-06-07 and exists only on the filesystem.
## High-Value Prioritization (for Implementation Plan)
### Tier 1: Ship in Current Batch (small scope, high value)
1. **openspec-cleanup** — Fix folder structure: delete stubs, move misplaced proposals, add .openspec.yaml, populate config.yaml
2. **llama-cache-and-spec** — KV cache quantization + ngram speculative decoding (llama-server arg changes only)
3. **results-page** — New `/results` route, uses existing API endpoints
4. **token-analyzer-ui** — New `/analytics` route, uses existing DB data
### Tier 2: Current+ Batch (moderate scope)
5. **enhanced-file-panel** — Side-by-side diff, inline comments, in-browser editing
6. **pty-enhancements** — Exit notifications, session metadata, X-Agent-Flags
### Tier 3: Next Batch (larger scope, foundation work)
7. **memory-v2-hybrid-search** — BM25 + local embedding hybrid search
8. **orchestrator-flow-advanced** — Trigger rules, conditional branching, HITL
9. **omo-paseo-bridge** — OMO subagent visibility in Paseo
### Tier 4: Future Batches (speculative / big effort)
10. **add-behavioral-engine** / **audit-harness-integration** / **import-llm-evaluator** / **import-pregel-engine** — Big integration efforts
11. **code-intelligence-upgrade** / **dev-workflow** / **conductor-evolution** — Platform work
12. **plugin-platform** / **ui-overhaul** / **add-3tier-memory** / **add-type-inject-mcp** — Future
## Scope Boundaries for This Plan
**IN SCOPE:**
- Delete 11 stub files from archived/
- Move 5 misplaced 2026-06-07 proposals from archived/ to changes/ (with dedup)
- Add missing .openspec.yaml to 6 active batches
- Populate openspec/config.yaml with project context
- Implement Tier 1-2 high-value batches:
- llama-cache-and-spec (llama-server args)
- results-page (new route, frontend)
- token-analyzer-ui (new route, frontend + backend)
- enhanced-file-panel (frontend changes)
- pty-enhancements (backend changes)
**OUT OF SCOPE:**
- Tier 3-4 batches (future planning)
- Full behavioral engine or Pregel state machine integration
- Plugin platform architecture

View File

@@ -0,0 +1,485 @@
# Enhanced File Panel — Implementation Plan
## TL;DR
> **Quick Summary**: Add side-by-side diff, hide whitespace, wrap lines, expand all files, inline diff comments, and in-browser file editing to BooCode's right-rail file panel.
>
> **Deliverables**:
> - Enhanced `GitDiffView.tsx` with toolbar (layout/whitespace/wrap/expand-all toggles)
> - Split-layout diff renderer (side-by-side)
> - `useDiffPreferences` hook (localStorage persistence)
> - Inline diff comment components + Zustand store
> - File editing mode in file tree + server write endpoint
> - Server `git diff -w` support
>
> **Estimated Effort**: Medium-Large
> **Parallel Execution**: YES — 4 waves
> **Critical Path**: Wave 1 (server) → Wave 2 (diff preferences + toolbar) → Wave 3 (split layout) → Wave 4 (comments + editing)
---
## Context
### Original Request
User wants to implement these features from Paseo into BooCode's file manager:
1. Unified diff ✅ (exists) / Side by side diff ❌
2. Hide whitespace ❌
3. Wrap long lines ❌
4. Expand all files ❌ (only per-file)
5. Refresh ✅ (exists)
6. Comments on specific diffs ❌
7. File edits (editing in the file browser) ❌
### Research Findings
- **Paseo** (`/opt/forks/paseo`): Best reference for all features. Key files: `diff-pane.tsx`, `diff-layout.ts`, `diff-rendering.ts`, `review/surface.tsx`, `review/store.ts`, `use-changes-preferences/`
- **Existing BooCode files**: `GitDiffView.tsx`, `RightRail.tsx`, `useGitDiff.ts`, `git_diff.ts`, `FileViewerOverlay.tsx`
- Key insight: None of the web references have true inline file editing in the browser — this is new ground
---
## Work Objectives
### Core Objective
Augment the existing file panel with side-by-side diff, whitespace/wrap/expand toggles, inline comments, and inline file editing.
### Definition of Done
- [x] `pnpm -C apps/web build` succeeds with no errors
- [x] `pnpm -C apps/server build` succeeds with no errors
- [ ] Side-by-side diff renders correctly (two aligned columns)
- [ ] Hide whitespace toggles and re-fetches diff
- [ ] Wrap lines toggles between pre / pre-wrap
- [ ] Expand/Collapse all toggles all file diffs
- [ ] Inline comments: click gutter → type → save → display thread
- [ ] File edit: double-click tree → edit → save → file changes on disk
- [ ] All preferences persist across page refresh
### Must Have
- Side-by-side diff view
- Hide whitespace toggle (server param)
- Wrap long lines toggle (CSS)
- Expand/Collapse all file diffs
- Inline diff comments with thread UI
- In-browser file editing with save
- Preference persistence
### Must NOT Have (Guardrails)
- No DB migration (comments are client-side)
- No new WS frames (reuse git_diff_refresh)
- No new `@boocode/contracts` types
- No multi-user comment sharing
- No git push/pull/PR operations
- No inline hunk staging
---
## Verification Strategy
### Test Decision
- **Infrastructure exists**: YES (vitest for server)
- **Automated tests**: Tests-after for new server route + `git_diff.ts` changes
- **Agent-Executed QA**: Playwright for diff interactions, curl for API endpoints
### QA Policy
Every task includes agent-executed scenarios. Evidence saved to `.omo/evidence/`.
---
## Execution Strategy
### Waves
```
Wave 1 (Server — foundation):
├── Task 1: Server: whitespace param in git_diff.ts
├── Task 2: Server: POST /api/projects/:id/write_file endpoint
├── Task 3: Server tests for whitespace + write
└── [tests + typecheck]
Wave 2 (Frontend — preferences + toolbar):
├── Task 4: useDiffPreferences hook (localStorage)
├── Task 5: GitDiffView toolbar (layout/whitespace/wrap/expand-all toggles)
├── Task 6: Wrap lines CSS + hide whitespace re-fetch
└── [pnpm build]
Wave 3 (Frontend — split layout):
├── Task 7: Diff layout utilities (buildSplitDiffRows etc.)
├── Task 8: Side-by-side renderer in GitDiffView
├── Task 9: Line number gutter + alignment
└── [pnpm build]
Wave 4 (Frontend — comments + file editing):
├── Task 10: InlineComment store (Zustand + localStorage)
├── Task 11: InlineReviewGutterCell + InlineReviewEditor
├── Task 12: InlineReviewThread (comment display)
├── Task 13: File editing mode in RightRail file tree
└── [pnpm build + full smoke test]
```
Critical Path: T1 → T2 → T4 → T5 → T7 → T8 → T10 → T11 → T12 → T13
---
## TODOs
- [x] 1. **Server: Add `ignoreWhitespace` param to git diff**
**What to do**:
- In `apps/server/src/services/git_diff.ts`, add `ignoreWhitespace?: boolean` to the `getGitDiff` function signature
- When `ignoreWhitespace` is true, append `'-w'` to the git diff argv call in `getGitDiff` (the main diff command, not name-status)
- Update `GET /api/projects/:id/git/diff` route in `routes/projects.ts` to accept optional query param `whitespace=1`
- The param should be optional (backward compatible) — default false
**Files to modify**:
- `apps/server/src/services/git_diff.ts` — update `getGitDiff()` to accept and use `ignoreWhitespace`
- `apps/server/src/routes/projects.ts` — add `whitespace` query param
**References**:
- Paseo: `useCheckoutDiffQuery({ ignoreWhitespace })` passes to server → `git diff -w`
- Existing `git_diff.ts:36-48` `runGit` function — argv pattern to follow
**QA Scenarios**:
```
Scenario: Diff with whitespace changes respects ignoreWhitespace param
Tool: Bash (curl)
Preconditions: A file exists with whitespace-only changes (extra spaces)
Steps:
1. GET /api/projects/:id/git/diff ⇒ verify diff_body includes whitespace changes
2. GET /api/projects/:id/git/diff?whitespace=1 ⇒ verify diff_body excludes whitespace-only changes
Expected: With whitespace=1, files that only had whitespace changes show as unchanged
Evidence: .omo/evidence/task-1-whitespace.txt
```
- [x] 2. **Server: Add POST /api/projects/:id/write_file endpoint**
**What to do**:
- Add `POST /api/projects/:id/write_file` route in `routes/projects.ts`
- Accept `{ path: string, content: string }` body
- Validate path via existing `pathGuard` helper (same as git discard)
- Write file content atomically: write to `.tmp` then `rename` the file
- Return `{ ok: boolean }` on success
- Reuse the safe file-write pattern from `services/file_ops.ts`
**Files to modify**:
- `apps/server/src/routes/projects.ts` — add POST route
- `apps/web/src/api/client.ts` — add `writeFile` method
- `apps/web/src/api/types.ts` — add write types if needed
**References**:
- `apps/server/src/services/file_ops.ts` — existing file operations pattern
- `apps/server/src/routes/projects.ts:544-592` — git write routes (same security pattern)
- `apps/server/src/services/path_guard.ts` — path validation
**QA Scenarios**:
```
Scenario: Write file content and verify on disk
Tool: Bash (curl)
Preconditions: A project exists with a writable path
Steps:
1. POST /api/projects/:id/write_file { path: "test.txt", content: "hello" }
2. GET /api/projects/:id/view_file?path=test.txt
Expected: Status 200, view_file returns "hello"
Evidence: .omo/evidence/task-2-write.txt
```
- [x] 3. **Frontend: useDiffPreferences hook**
**What to do**:
- Create `apps/web/src/hooks/useDiffPreferences.ts`
- Define `DiffPreferences` interface: `{ layout: 'unified'|'split', wrapLines: boolean, hideWhitespace: boolean }`
- Default: `{ layout: 'unified', wrapLines: false, hideWhitespace: false }`
- Read/write to localStorage key `boocode.diff.preferences`
- Return `{ preferences, updatePreferences, resetPreferences }`
- Zod-validate on read for forward compatibility
**Files to create/modify**:
- Create `apps/web/src/hooks/useDiffPreferences.ts`
**References**:
- `/opt/forks/paseo/packages/app/src/hooks/use-changes-preferences/storage.ts` — exact pattern
- `apps/web/src/hooks/useProjectGit.ts` — hooks pattern in BooCode
**QA Scenarios**:
```
Scenario: Preferences persist across page refresh
Tool: Playwright
Preconditions: Page loaded
Steps:
1. Call updatePreferences({ layout: 'split' })
2. Read localStorage.getItem('boocode.diff.preferences')
3. Reload page, read preferences again
Expected: layout is 'split' after reload
Evidence: .omo/evidence/task-3-prefs.txt
```
- [x] 4. **Frontend: GitDiffView toolbar with all toggles**
**What to do**:
- Add a toolbar row inside `GitDiffView.tsx` between the mode selector and file list
- Controls (left to right):
- **Layout toggle**: two-segment button (Unified | Split) — uses `AlignJustify` / `Columns2` icons
- **Hide whitespace**: toggle button — `Pilcrow` icon, active state highlights
- **Wrap lines**: toggle button — `WrapText` icon
- **Expand/Collapse all**: toggle button — `ListChevronsUpDown` / `ListChevronsDownUp` icons
- **Refresh**: existing button (already present)
- Wire each toggle to the `useDiffPreferences` hook
- Expand all state: compute `allExpanded = files.every(f => expandedPaths.has(f.path))`
- Pass expand state as a new prop or local state
**Files to modify**:
- `apps/web/src/components/GitDiffView.tsx` — add toolbar section, expand-all logic
**References**:
- Paseo `diff-pane.tsx:1114-1273` — `DiffLayoutToggleGroup`, `DiffWhitespaceToggle`, `DiffFilesToolbar`
- openchamber `DiffViewToggle.tsx` — simple toggle pattern
- happy `InlineFileDiff.tsx:196-219` — `DiffStyleToggle` segment control
**QA Scenarios**:
```
Scenario: All toolbar controls render and toggle
Tool: Playwright
Preconditions: Git tab active with changed files
Steps:
1. Verify layout toggle shows "Unified" / "Split" buttons
2. Click "Split" — verify visual change
3. Click "Wrap" — verify wrap toggle
4. Click "Expand all" — verify all files expand
5. Click "Collapse all" — verify all files collapse
Expected: Each toggle works and updates state
Evidence: .omo/evidence/task-4-toolbar.png
```
- [x] 5. **Frontend: Diff layout utilities + side-by-side renderer**
**What to do**:
- Create `apps/web/src/utils/diff-layout.ts` with pure functions:
- `buildNumberedDiffHunks(diffBody: string): NumberedDiffHunk[]` — parse diff text into hunks with old/new line numbers
- `buildUnifiedDiffLines(file): UnifiedDiffDisplayLine[]` — existing behavior
- `buildSplitDiffRows(file): SplitDiffRow[]` — pair removals/additions into left/right rows
- Create `apps/web/src/components/DiffSplitView.tsx` — the side-by-side renderer:
- Two columns (left = deletions, right = additions) with a thin divider
- Each column has its own gutter (line numbers) + code content
- Use Shiki `codeToHtml(language)` for syntax highlighting per side
- Handle empty cells (unpaired lines render as blank)
- In `GitDiffView.tsx`, when `layout === 'split'`, render `DiffSplitView` instead of the unified diff body
**Files to create/modify**:
- Create `apps/web/src/utils/diff-layout.ts`
- Create `apps/web/src/components/DiffSplitView.tsx`
- Modify `apps/web/src/components/GitDiffView.tsx` — add layout branching
**References**:
- `/opt/forks/paseo/packages/app/src/utils/diff-layout.ts` — full algorithm
- `/opt/forks/paseo/packages/app/src/git/diff-pane.tsx:968-989` — split layout rendering
- existing `git_diff.ts` `splitDiffByFile` — already splits unified diff per file
**QA Scenarios**:
```
Scenario: Side-by-side diff renders correctly
Tool: Playwright
Preconditions: Git tab active, files with changes
Steps:
1. Click "Split" layout toggle
2. Verify two columns appear with a divider
3. Verify deleted lines are on left side (red background)
4. Verify added lines are on right side (green background)
5. Verify context lines appear on both sides, aligned
Expected: Layout matches Paseo's split diff
Evidence: .omo/evidence/task-5-splitdiff.png
```
- [x] 6. **Frontend: Inline comment store + Zustand**
**What to do**:
- Create `apps/web/src/stores/useDiffCommentStore.ts`
- Define `DiffComment` interface: `{ id, filePath, side, lineNumber, body, createdAt, updatedAt }`
- Create Zustand store with:
- `commentsByKey: Map<string, DiffComment[]>` keyed by `${sessionId}:${mode}:${filePath}`
- `addComment(key, comment)` / `updateComment(key, id, body)` / `deleteComment(key, id)`
- `loadComments(key)` — load from localStorage
- `persist()` — subscribe to store changes, write to localStorage key `boocode.diff.comments.[key]`
- Export `useDiffCommentStore`
**Files to create**:
- Create `apps/web/src/stores/useDiffCommentStore.ts`
**References**:
- `/opt/forks/paseo/packages/app/src/review/store.ts` — zustand store for comments
- `/opt/forks/paseo/packages/app/src/review/state.ts` — CRUD operations
**QA Scenarios**:
```
Scenario: Comments persist across page refresh
Tool: Playwright
Preconditions: Diff panel open with changes
Steps:
1. Add comment on a diff line
2. Verify comment thread appears
3. Reload page
4. Navigate to same diff
Expected: Comment thread still visible after reload
Evidence: .omo/evidence/task-6-comment-store.txt
```
- [x] 7. **Frontend: InlineReviewGutterCell + InlineReviewEditor**
**What to do**:
- Create `apps/web/src/components/InlineReviewGutterCell.tsx`:
- Replaces the plain line-number display in diff rows
- Shows line number + "+" icon on hover (to start a comment)
- Uses `ReviewableDiffTarget { filePath, side, lineNumber }` for tracking
- Create `apps/web/src/components/InlineReviewEditor.tsx`:
- Textarea with placeholder "Add comment..."
- Save (Ctrl+Enter) / Cancel (Escape) buttons
- Animates in below the target line
- Integrate into `GitDiffView.tsx` — gutter cells render in the diff line view
- Wire to `useDiffCommentStore`
**Files to create/modify**:
- Create `apps/web/src/components/InlineReviewGutterCell.tsx`
- Create `apps/web/src/components/InlineReviewEditor.tsx`
- Modify `apps/web/src/components/GitDiffView.tsx` — integrate gutter cells
**References**:
- Paseo `review/surface.tsx:245-309` — `DiffGutterCell` + `InlineReviewGutterCell`
- Paseo `InlineReviewEditor` pattern
**QA Scenarios**:
```
Scenario: Create inline comment on diff line
Tool: Playwright
Preconditions: Git tab, file expanded
Steps:
1. Hover over a gutter cell
2. Click "+" button
3. Type comment text
4. Click Save (or Ctrl+Enter)
Expected: Comment thread appears below the line
Evidence: .omo/evidence/task-7-comment-create.png
```
- [x] 8. **Frontend: InlineReviewThread component**
**What to do**:
- Create `apps/web/src/components/InlineReviewThread.tsx`:
- Renders below a diff line when comments exist for that target
- Each comment shown as a card: avatar placeholder, body, timestamp, edit/delete actions
- Collapsed state shows comment count badge
- Expanded state shows full thread
- Integrate into `GitDiffView.tsx` below diff line rows
**Files to create/modify**:
- Create `apps/web/src/components/InlineReviewThread.tsx`
- Modify `apps/web/src/components/GitDiffView.tsx` — render thread below lines
**Reference**:
- Paseo `review/surface.tsx:537-573` — `InlineReviewThreadContent`
**QA Scenarios**:
```
Scenario: Comment thread displays and supports edit/delete
Tool: Playwright
Preconditions: Comments exist on a diff line
Steps:
1. Expand comment thread
2. Verify comment body is visible with timestamp
3. Click edit → modify text → save
4. Click delete → verify comment removed
Expected: Full CRUD works on comments
Evidence: .omo/evidence/task-8-thread.png
```
- [x] 9. **Frontend: File editing in the file tree**
**What to do**:
- In `RightRail.tsx`, add a file edit mode:
- Double-click a file in the tree (or context menu "Edit") enters edit mode
- The file row transforms: file name becomes a monospace textarea pre-filled with file content (fetched via existing `api.projects.viewFile`)
- The row shows Save / Cancel buttons
- Save: calls `api.projects.writeFile(projectId, path, content)` — the new endpoint from Task 2
- Cancel: reverts to the original content and exits edit mode
- After save: re-fetch the file tree + emit `git_diff_refresh`
- Only one file editable at a time (close any existing editor before opening new)
- Visual indicator (highlighted row) when in edit mode
**Files to modify**:
- `apps/web/src/components/RightRail.tsx` — add edit mode state, edit UI
- `apps/web/src/api/client.ts` — add `writeFile` method (from Task 2)
- `apps/web/src/components/TreeLevel.tsx` (inline in RightRail) — accept edit mode props
**References**:
- Existing `RightRail.tsx:170-175` `openFile` function — pattern for file interaction
- Existing `FileViewerOverlay.tsx` — Shiki highlighting reference
- Paseo `file-explorer-pane.tsx` — context menu actions pattern
**QA Scenarios**:
```
Scenario: Edit file in file tree and save
Tool: Playwright
Preconditions: Project with a text file
Steps:
1. Double-click a file in the file tree
2. Verify file enters edit mode (textarea replaces filename)
3. Modify content
4. Ctrl+Enter to save
5. Verify success indicator
Expected: File content updated on disk, tree refreshes
Evidence: .omo/evidence/task-9-edit-save.png
Scenario: Cancel file edit reverts changes
Tool: Playwright
Preconditions: File in edit mode
Steps:
1. Modify content in textarea
2. Click Cancel / press Escape
3. Re-open file
Expected: Original content preserved, edit mode exited
Evidence: .omo/evidence/task-9-edit-cancel.txt
```
---
## Final Verification
- [ ] F1. **Plan Compliance Audit** — `oracle`
Verify all Must Have features are implemented, Must NOT Have are absent.
Output: VERDICT
- [ ] F2. **Code Quality** — `unspecified-high`
Run `pnpm -C apps/web build`, `pnpm -C apps/server build`, check for `as any`/`@ts-ignore`/console.log.
Output: VERDICT
- [ ] F3. **Real Manual QA** — `unspecified-high` + `playwright`
Execute all QA scenarios from every task, capture evidence.
Output: Scenarios [N/N pass]
- [ ] F4. **Scope Fidelity** — `deep`
Verify spec matches implementation, no scope creep.
Output: Tasks [N/N compliant]
---
## Commit Strategy
- **1**: `feat(server): add whitespace param to git diff + write_file endpoint`
- **2**: `feat(web): diff preferences hook, toolbar toggles, split layout`
- **3**: `feat(web): inline diff comments with zustand store`
- **4**: `feat(web): in-browser file editing in file tree`
---
## Success Criteria
### Verification Commands
```bash
pnpm -C apps/web build # Must pass
pnpm -C apps/server build # Must pass
```
### Final Checklist
- [ ] Side-by-side diff renders correctly
- [ ] Hide whitespace re-fetches with `-w`
- [ ] Wrap lines toggles CSS
- [ ] Expand/Collapse all toggles
- [ ] Inline comments: create, read, update, delete
- [ ] File editing: read, modify, save, cancel
- [ ] All preferences survive page reload

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,32 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.8.0-fork-lifts — 2026-06-07
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.
**boocontext sidecar (Phase 3):** Upgrades the `codecontext` container from the old Go MCP server to the boocontext Node.js MCP aggregator. Multi-stage Dockerfile builds boocontext from `/opt/forks/boocontext` alongside the HTTP shim. `shim.go` gains `CODECONTEXT_CHILD` env-var support and three new HTTP routes for symbols, callgraph, and blast radius. Three TypeScript tool wrappers (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered on the server, with blast radius added to the synthesis pipeline. Docker-compose env vars configure child MCP paths (tree-sitter-analyzer, type-inject).
**LSP integration (Phase 4):** Six-file `lsp/` module in the coder with config, JSON-RPC stdio client, lazy server-manager (per-project pool, 5-min idle shutdown), and operations (diagnostics, goto-definition, find-references). Three read-only agent tools registered — `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`. TypeScript/JavaScript only in v1.
**DCP clean-room (Phase 5):** Seven-file `dcp/` module in the server inference pipeline. Consecutive identical tool_call+tool_result pairs are deduplicated; failed/empty tool results are purged via configurable window. Orchestrated by `transformMessages()` running before `buildMessagesPayload` in `turn.ts`. Clean-room reimplementation — AGPL source was referenced for behavior only. 10 unit tests.
**Institutional memory (Phase 6):** Eight-file `memory/` module with file-based recall. Hierarchical 4-scope scan (global → home → project → session) under `.boocode/memory/`. Keyword/tag relevance matching at prompt assembly. Injected as a `<boocode-memory>` block in the system prompt. v1 recall-only — extract/dream deferred.
**Subagent protocol (Phase 7):** `AgentCapabilitiesSchema` in contracts with `supportsStreaming`, `supportsReasoningStream`, `supportsBackgroundExecution` flags. `ProviderSnapshotEntry` gains the two streaming capability fields. `new_task` tool gets a `background` mode flag for non-blocking dispatch. Flow-runner already supported per-step model override.
**Plugin host (Phase 8):** Typed hook registry in `plugins/host.ts` with `registerHook`/`emitHook` for five lifecycle events: `tool.execute.before`, `tool.execute.after`, `turn.start`, `turn.end`, `task.terminal`. Patterns-only from oh-my-openagent (SUL — no code copy).
**Inference reliability (Phase 9):** `tool-shim.ts` recovers XML/JSON tool calls from plain-text model output (e.g. Qwen inline format). `loop-detectors.ts` catches content-repeat and tool-loop patterns. Existing doom-loop detection remains — detectors are additive.
**Edit safety guards (Wave 1):** `edit-guards.ts` rejects catastrophic truncation (>60% chars AND >50% lines). `edit-guards-imports.ts` detects dropped import statements. Both run in `pending_changes.ts` immediately before `writeFileAtomic`.
**TokenScope (Wave 2):** `TokenBreakdownSchema` in contracts with system/user/assistant/tools/reasoning categories. `token-analysis/` module with analyzer and DB persistence. `ContestantShape.token_breakdown` field and `token_breakdown` JSONB column on `contestants`/`tasks` tables. Arena `computeBenchmark` accepts and returns token breakdown.
**Build:** Server 649 ✅ Coder 471 ✅ Contracts ✅ — all green.
Adds the **Arena** pane for running the same prompt against 26 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
## v2.7.18-permission-modes — 2026-06-05 ## v2.7.18-permission-modes — 2026-06-05
Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`. Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`.

View File

@@ -78,7 +78,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls. - `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch. - Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch.
- Arena: `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel; each contestant gets its own task + worktree. `GET /api/arena/:id` for results; `POST /api/arena/:id/select/:task_id` picks a winner. - Arena: `POST /api/battles {project_id, battle_type, prompt, contestants}` starts a battle; `GET /api/battles/:id` returns battle + contestants + cross-examinations; `POST /api/battles/:id/stop` cancels; `POST /api/battles/:id/analyze` triggers/re-triggers two-stage digest→judge analysis; `GET /api/battles/:id/analysis` reads `analysis.md`; `POST /api/battles/:id/cross-examine {identity, model}` runs a cross-examination. All `/api/battles*` routes are served by `apps/coder` at port 9502 (proxied through `apps/server` as `/api/coder/battles*`).
## Workflow ## Workflow

67
CONTEXT.md Normal file
View File

@@ -0,0 +1,67 @@
# Context: BooCode
Glossary of the domain language. Terms only — no implementation detail.
## Workspace
- **Pane** — one tile in the multi-pane workspace. Each pane has a *kind*:
Chat (BooChat), Coder (BooCoder), Terminal (BooTerm), Orchestrator, Arena,
plus artifact/settings kinds.
- **Backend** — an AI engine a task is dispatched to: *native* (BooChat
inference on a local llama-swap model) or an *external* CLI agent (Claude Code,
OpenCode, Qwen, Goose). Code sometimes calls this the "agent" (`tasks.agent`).
- **BooChat Agent** (a.k.a. *persona*) — a preset from the `data/AGENTS.md`
registry (e.g. "Code Reviewer", "Debugger"): a system prompt + tool whitelist +
sampling knobs that runs **on the native backend** with a chosen model.
Distinct from a Backend — this is the overloaded sense of "agent" the UI's
Agent picker selects.
## Arena
A way to run the **same prompt** against several AI competitors at once and pick
the best result.
- **Battle** — one Arena run. Dated. Produces a results folder at
`/<project-root>/Arena/<dated-battle>/`. (The earlier API-only feature called
this an "arena"; a Battle is one such run.)
- **Battle Type** — what is being compared:
- *Coding* — Contestants change code; a result is the **diff** they produced
(plus their explanation). Each Contestant works in its own worktree.
- *Q&A* — Contestants answer a prompt; a result is the **text answer**. No
code changes.
- **Contestant** — one competitor in a Battle, given the Battle's prompt. What
defines a Contestant depends on Battle Type:
- *Coding* — a **Backend + Model** (e.g. Claude Code + opus, native BooCode +
35b). Each works in its own isolated git **worktree** (a branched on-disk
copy of the project). Contestants do not see each other's work.
- *Q&A* — a **BooChat Agent (persona) + Model** (e.g. Debugger + 35b), running
on the native backend only. No worktree (no code changes).
The same model can appear under two Contestants, so a Contestant's identity is
the (backend-or-persona, model) pair, not the model alone.
- **Benchmark** — per-Contestant performance captured during a Battle. Wall-clock
**duration** is recorded for every Contestant; **throughput** (tokens/sec) is
recorded only for local (llama-swap) models, which are the ones the speed
comparison is meaningful for.
- **Arena results folder** (`/<project-root>/Arena/<dated-battle>/`) — where a
Battle's *results* are written (not the working copies — those stay in each
Contestant's worktree). Holds the per-Contestant result and the final
analysis.
- **Lane** — how a Battle's Contestants are scheduled. The *local lane* holds
every llama-swap-backed Contestant and runs them strictly one at a time (the
local server can only load one model at a time, which also keeps their speed
Benchmark fair). The *cloud lane* holds cloud-backed Contestants (Claude Code,
OpenCode-on-cloud) and runs them all in parallel. The two lanes run
concurrently with each other.
- **Analysis** — an end-of-Battle judgement of the Contestants' results,
produced by the default BooChat model, naming a **Winner**.
- **Cross-examination** — an after-the-Battle step where a chosen model (from any
agent) is pointed at the Battle's results to interrogate / compare them.

View File

@@ -1,9 +1,9 @@
# Current focus # Current focus
Last updated: 2026-06-05 Last updated: 2026-06-07
- **Last shipped:** `v2.7.18-permission-modes` (2026-06-05) — unified Plan/Ask/Bypass permission picker in the BooCoder composer (incl. native-BooCode auto-apply on Bypass). - **Last shipped:** `v2.8.0-fork-lifts` (2026-06-07) — eight fork-lift integrations from `/opt/forks`: boocontext sidecar, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards and TokenScope analyzer/persist module.
- **Branch:** `main` - **Branch:** `main`
- **In progress:** nothing committed — dogfooding the Orchestrator to surface the next real backlog. Claude Agent-SDK backend enabled (`CLAUDE_SDK_BACKEND`). Optional/exploratory: verify-gate ensembler over pending changes. - **In progress:** nothing committed — all phases 3-9 of fork-lifts-mit epic are shipped. Optional/exploratory: verify-gate ensembler over pending changes; web Arena token UI display.
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only. See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.

View File

@@ -37,3 +37,10 @@
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`. - **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`). - **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
## Edit safety guards (v2.8)
- **`services/edit-guards.ts`** — `validateEditResult(original, updated, filePath)` runs in `pending_changes.ts` immediately before `writeFileAtomic`. Rejects catastrophic truncation (>60% char loss AND >50% line loss). Throws a `formatGuardError` message that percolates to the agent as a visible error.
- **`services/edit-guards-imports.ts`** — `checkDroppedImports(original, updated, filePath)` detects removed import/require lines. Called alongside the truncation guard.
- Both guards run on the `/apply` path only (not on queue). Re-queued identical edits re-validate at apply time.
- Guard functions are pure — no DB or filesystem access. Easy to unit-test.

View File

@@ -24,6 +24,7 @@ import {
} from './planning.js'; } from './planning.js';
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js'; import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
import { codeReview } from './code-review.js'; import { codeReview } from './code-review.js';
import { parallelResearch } from './parallel-research.js';
const spines: Spine[] = [ const spines: Spine[] = [
// analysis / research // analysis / research
@@ -53,7 +54,7 @@ const spines: Spine[] = [
stakeholderSummary, stakeholderSummary,
]; ];
const bespoke: Flow[] = [codeReview]; const bespoke: Flow[] = [codeReview, parallelResearch];
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke]; const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];

View File

@@ -0,0 +1,59 @@
import type { Flow, Step, StepContext } from '../types.js';
const q = (ctx: StepContext) => String(ctx.input.question);
/**
* Parallel research flow — dispatches 3 research agents simultaneously,
* then synthesizes the result on the first one to complete.
*/
export const parallelResearch: Flow = {
name: 'parallel-research',
description: 'Research from 3 angles in parallel, synthesize results on first completion',
steps: [
{
id: 'angle-web',
kind: 'agent',
agent: 'research-analyst',
run: (ctx) =>
`Research the following question from a web / prior-art perspective:\n\n${q(ctx)}`,
},
{
id: 'angle-code',
kind: 'agent',
agent: 'codebase-explorer',
deps: [],
run: (ctx) =>
`Research the following question from a codebase analysis perspective:\n\n${q(ctx)}`,
},
{
id: 'angle-security',
kind: 'agent',
agent: 'adversarial-security-analyst',
deps: [],
run: (ctx) =>
`Research the following question from a security perspective:\n\n${q(ctx)}`,
},
{
id: 'synthesize',
kind: 'code',
deps: ['angle-web', 'angle-code', 'angle-security'],
trigger_rule: 'one_success',
run: (ctx) => {
const web = ctx.results['angle-web'];
const code = ctx.results['angle-code'];
const security = ctx.results['angle-security'];
const parts = [
'# Parallel Research Synthesis',
'',
web ? `## Web Angle\n${web}` : '## Web Angle\n*(not yet completed)*',
code ? `## Code Angle\n${code}` : '## Code Angle\n*(not yet completed)*',
security ? `## Security Angle\n${security}` : '## Security Angle\n*(not yet completed)*',
];
return parts.join('\n\n');
},
},
],
render: (ctx) => {
return ctx.results['synthesize'] ?? 'No synthesis produced.';
},
};

View File

@@ -38,7 +38,9 @@ export interface StepContext {
readonly model?: string; readonly model?: string;
} }
export type StepKind = 'agent' | 'code'; export type StepKind = 'agent' | 'code' | 'approval';
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
export interface Step { export interface Step {
/** unique id within the flow; other steps depend on it by this id */ /** unique id within the flow; other steps depend on it by this id */
@@ -46,6 +48,8 @@ export interface Step {
kind: StepKind; kind: StepKind;
/** ids that must complete (or skip) before this step runs */ /** ids that must complete (or skip) before this step runs */
deps?: string[]; deps?: string[];
/** how dependency satisfaction is evaluated (default: all_success) */
trigger_rule?: TriggerRule;
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */ /** for kind:'agent' — the persona file name under conductor/agents (no .md) */
agent?: string; agent?: string;
/** /**

View File

@@ -13,7 +13,7 @@ import type { WsFrame } from '@boocode/contracts/ws-frames';
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility. // v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
import { WRITE_TOOLS } from './services/tools/index.js'; import { WRITE_TOOLS } from './services/tools/index.js';
import { adaptWriteTool } from './services/tools/adapter.js'; import { adaptWriteTool } from './services/tools/adapter.js';
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js'; import { runWithInferenceContext } from './services/tools/inference_context.js';
// Routes // Routes
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js'; import { registerSkillRoutes } from './routes/skills.js';
@@ -23,21 +23,25 @@ import { registerAgentSessionRoutes } from './routes/agent-sessions.js';
import { registerTaskRoutes } from './routes/tasks.js'; import { registerTaskRoutes } from './routes/tasks.js';
import { registerInboxRoutes } from './routes/inbox.js'; import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js'; import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerRunsRoutes } from './routes/runs.js'; import { registerRunsRoutes } from './routes/runs.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js'; import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js'; import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerLifecycleRoutes } from './routes/lifecycle.js'; import { registerLifecycleRoutes } from './routes/lifecycle.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe // Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js'; import { createDispatcher } from './services/dispatcher.js';
// Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's // Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's
// onTaskTerminal hook. // onTaskTerminal hook.
import { createFlowRunner } from './services/flow-runner.js'; import { createFlowRunner } from './services/flow-runner.js';
// Arena: DB-backed battle-runner; also advances on the onTaskTerminal hook.
import { createBattleRunner, type DispatchContestantFn } from './services/arena-runner.js';
import { createAnalyzer } from './services/arena-analyzer.js';
import { agentPool } from './services/agent-pool.js'; import { agentPool } from './services/agent-pool.js';
import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js'; import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js';
import { probeAgents } from './services/agent-probe.js'; import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js'; import { getProviderSnapshot, persistProbedModels, fetchLlamaSwapModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js'; import { setPermissionHooks } from './services/permission-waiter.js';
import { publishAgentStatus } from './services/agent-status-publish.js'; import { publishAgentStatus } from './services/agent-status-publish.js';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
@@ -171,22 +175,27 @@ async function main() {
} }
); );
// Wrap the inference runner to set/clear the write-tool context around each run. // Wrap the inference runner to bind the write-tool context around each run.
// The inference runner calls enqueue() which fires asynchronously — we hook // enqueue() starts its async loop synchronously, so wrapping the call in
// into the enqueue to set context before the run starts. // runWithInferenceContext propagates the per-run context (sql, sessionId, the
// Plan/Ask/Bypass gate) through every awaited tool execution — and concurrent
// runs (a user message racing a dispatcher-polled native task) each get their
// own, instead of clobbering a shared global.
const inferenceApi = { const inferenceApi = {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => { enqueue: (
// Set the inference context so write tools can access sql + sessionId. sessionId: string,
// The context persists for the duration of the inference run. Since chatId: string,
// BooCoder is single-user and runs one inference at a time per session, assistantId: string,
// this module-level state is safe. user: string,
setInferenceContext({ sql, sessionId, taskId: null }); permissionMode?: 'plan' | 'ask' | 'bypass',
) => {
runWithInferenceContext({ sql, sessionId, taskId: null, permissionMode }, () => {
inference.enqueue(sessionId, chatId, assistantId, user); inference.enqueue(sessionId, chatId, assistantId, user);
});
}, },
cancel: async (sessionId: string, chatId: string) => { cancel: async (sessionId: string, chatId: string) => {
const result = await inference.cancel(sessionId, chatId); // No context to clear — AsyncLocalStorage scopes it to each run's own chain.
clearInferenceContext(); return inference.cancel(sessionId, chatId);
return result;
}, },
hasActive: (chatId: string) => inference.hasActive(chatId), hasActive: (chatId: string) => inference.hasActive(chatId),
}; };
@@ -220,31 +229,119 @@ async function main() {
// Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's // Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's
// onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its // onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its
// terminal callback can be wired in. Its launch() is driven by the runs route // terminal callback can be wired in.
// (a later phase); resume on startup is a later phase too.
const flowRunner = createFlowRunner({ sql, broker, log: app.log, config }); const flowRunner = createFlowRunner({ sql, broker, log: app.log, config });
// Phase 4: dispatcher — polls tasks table and runs inference. onTaskTerminal // Arena SEAM (a): build the local-model set from the live llama-swap model list.
// notifies the flow-runner when a step's task settles (D-2). // Both bare IDs ('qwen3.6-35b') and prefixed IDs ('llama-swap/qwen3.6-35b') are
// included so opencode-style prefixed contestants and native-style bare contestants
// both classify correctly as local.
const localModelsList = await fetchLlamaSwapModels(config).catch(() => []);
const localModels = new Set([
...localModelsList.map((m) => m.id),
...localModelsList.map((m) => `llama-swap/${m.id}`),
]);
// Arena dispatch function — Phase 4 SEAM (b).
// Coding: insert a tasks row with agent=identity (null for native/boocode);
// the dispatcher creates a worktree and runs the external agent (or native).
// Q&A: pre-create a session with agent_id stamped to the persona slug so native
// inference loads the persona's system_prompt + tools from AGENTS.md;
// task.session_id is pre-set so runNativeInference reuses the session.
const dispatchContestant: DispatchContestantFn = async ({
projectId,
prompt,
identity,
model,
battleType,
}) => {
if (battleType === 'qa') {
const sessionName = `Arena Q&A [${identity}]: ${prompt.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, agent_id, status)
VALUES (${projectId}, ${sessionName}, ${model}, ${identity}, 'open')
RETURNING id
`;
const [task] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, model, session_id)
VALUES (${projectId}, ${prompt}, ${model}, ${session!.id})
RETURNING id
`;
return { taskId: task!.id, sessionId: session!.id };
}
// Coding: boocode = native inference (no external agent); any other identity
// is an external agent name (claude, opencode, qwen, goose) that maps to
// available_agents and gets its own per-task worktree via runExternalAgent.
// Session is created lazily by the dispatcher, so sessionId is unknown here.
const agentName = identity === 'boocode' ? null : identity;
const [task] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, model)
VALUES (${projectId}, ${prompt}, ${agentName}, ${model})
RETURNING id
`;
return { taskId: task!.id, sessionId: null };
};
// Arena analyzer: two-stage digest→judge (v1). Pluggable seam — a v2 Han
// Orchestrator flow can replace this without schema changes.
const analyzer = createAnalyzer({
sql,
broker,
log: app.log,
config,
localModels,
});
// Arena battle-runner: notified on the same onTaskTerminal hook as the flow-runner.
const battleRunner = createBattleRunner({
sql,
broker,
log: app.log,
dispatch: dispatchContestant,
onBattleComplete: (battleId) => {
void analyzer.analyze(battleId);
},
onCrossExamStart: ({ battleId, crossExamId, identity, model }) => {
void analyzer.crossExamine(battleId, crossExamId, { identity, model });
},
localModels,
});
// Compose onTaskTerminal: both flow-runner and battle-runner are notified.
// Each ignores tasks it doesn't own (flow-runner checks flow_steps.task_id;
// battle-runner checks contestants.task_id).
const onTaskTerminal = (taskId: string, state: string): void => {
flowRunner.handleTaskTerminal(taskId, state);
battleRunner.handleTaskTerminal(taskId, state);
};
// Phase 4: dispatcher — polls tasks table and runs inference. The composed
// onTaskTerminal hook notifies both the flow-runner and the battle-runner when
// any task settles.
const dispatcher = createDispatcher({ const dispatcher = createDispatcher({
sql, sql,
inference: inferenceApi, inference: inferenceApi,
broker, broker,
log: app.log, log: app.log,
config, config,
onTaskTerminal: flowRunner.handleTaskTerminal, onTaskTerminal,
}); });
dispatcher.start(); dispatcher.start();
// Phase 5: re-advance any flow_runs that were 'running' when the service last // Re-advance in-flight flow_runs and battles after a coder restart. Both run
// stopped (D-9). Runs AFTER dispatcher.start() so re-dispatched 'pending' tasks // AFTER dispatcher.start() so re-dispatched 'pending' tasks are picked up.
// are picked up by the dispatcher's startup poll.
void flowRunner.initResume().catch((err) => { void flowRunner.initResume().catch((err) => {
app.log.error( app.log.error(
{ err: err instanceof Error ? err.message : String(err) }, { err: err instanceof Error ? err.message : String(err) },
'flow-runner: initResume failed', 'flow-runner: initResume failed',
); );
}); });
void battleRunner.initResume().catch((err) => {
app.log.error(
{ err: err instanceof Error ? err.message : String(err) },
'arena: initResume failed',
);
});
// v2.6 Phase 3: configure + start the agent-pool lifecycle sweep (idle-TTL + // v2.6 Phase 3: configure + start the agent-pool lifecycle sweep (idle-TTL +
// LRU-cap eviction of warm backends, plus each backend's proactive health probe) // LRU-cap eviction of warm backends, plus each backend's proactive health probe)
@@ -281,11 +378,12 @@ async function main() {
registerTaskRoutes(app, sql, inferenceApi, dispatcher.cancelExternalTask); registerTaskRoutes(app, sql, inferenceApi, dispatcher.cancelExternalTask);
registerInboxRoutes(app, sql); registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql); registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerRunsRoutes(app, sql, flowRunner, dispatcher.cancelExternalTask); registerRunsRoutes(app, sql, flowRunner, dispatcher.cancelExternalTask);
registerArenaRoutes(app, sql, battleRunner, dispatcher.cancelExternalTask, config);
registerProviderRoutes(app, sql, config); registerProviderRoutes(app, sql, config);
registerWorktreeSafetyRoutes(app, sql); registerWorktreeSafetyRoutes(app, sql);
registerLifecycleRoutes(app, sql); registerLifecycleRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);
// Graceful shutdown // Graceful shutdown

View File

@@ -0,0 +1,42 @@
export type HookName =
| 'tool.execute.before'
| 'tool.execute.after'
| 'turn.start'
| 'turn.end'
| 'task.terminal';
export interface ToolHookContext {
tool: string;
args: Record<string, unknown>;
projectRoot: string;
sessionId: string;
}
export interface ToolResultContext extends ToolHookContext {
result: unknown;
}
export type PluginHook = (ctx: any) => Promise<any>;
const hooks = new Map<HookName, PluginHook[]>();
export function registerHook(name: HookName, fn: PluginHook): void {
const list = hooks.get(name) || [];
list.push(fn);
hooks.set(name, list);
}
export async function emitHook(name: HookName, ctx: any): Promise<any> {
const list = hooks.get(name);
if (!list) return ctx;
let current = ctx;
for (const fn of list) {
const result = await fn(current);
if (result !== undefined) current = result;
}
return current;
}
export function clearHooks(): void {
hooks.clear();
}

View File

@@ -0,0 +1,78 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions.
// v1 — global view only (no per-project or per-user filtering).
export interface AnalyticsSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
session_count: number;
}
export interface SessionAnalyticsRow {
session_id: string;
session_name: string;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
last_active_at: string | null;
}
export interface TokenBreakdownAgg {
category: string;
total_tokens: number;
}
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/analytics/summary — aggregate totals across all agent_sessions.
app.get('/api/analytics/summary', async () => {
const [row] = await sql<AnalyticsSummary[]>`
SELECT
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
COUNT(DISTINCT c.session_id)::INT AS session_count
FROM agent_sessions a
JOIN chats c ON c.id = a.chat_id
`;
return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 };
});
// GET /api/analytics/sessions — per-session token/cost breakdown.
app.get('/api/analytics/sessions', async () => {
const rows = await sql<SessionAnalyticsRow[]>`
SELECT
c.session_id AS session_id,
s.name AS session_name,
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
MAX(a.last_active_at) AS last_active_at
FROM agent_sessions a
JOIN chats c ON c.id = a.chat_id
JOIN sessions s ON s.id = c.session_id
GROUP BY c.session_id, s.name
ORDER BY MAX(a.last_active_at) DESC NULLS LAST
`;
return { sessions: rows };
});
// GET /api/analytics/token-breakdown — aggregate token_breakdown categories
// across all tasks that carry the JSONB field.
app.get('/api/analytics/token-breakdown', async () => {
const rows = await sql<{ category: string; total_tokens: number }[]>`
SELECT
key AS category,
SUM((value->>0)::BIGINT)::BIGINT AS total_tokens
FROM tasks,
LATERAL jsonb_each(token_breakdown)
WHERE token_breakdown IS NOT NULL
AND jsonb_typeof(token_breakdown) = 'object'
GROUP BY key
ORDER BY total_tokens DESC
`;
return { categories: rows };
});
}

View File

@@ -1,136 +1,412 @@
/** /**
* v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents. * Arena routes — HTTP surface for the Battle UI.
* *
* POST /api/arena — create an arena with 2-5 contestants * POST /api/battles — launch a battle
* GET /api/arena/:id — get all tasks in an arena * GET /api/battles?project_id= — list battles for a project
* POST /api/arena/:id/select/:task_id — mark a task as the arena winner * GET /api/battles/:id — one battle + contestants + cross-exams
* POST /api/battles/:id/stop — cancel a running battle
* POST /api/battles/:id/analyze — trigger analysis (Phase 5 fills the logic)
* POST /api/battles/:id/cross-examine — start a cross-examination (Phase 5 fills the logic)
*
* Mirrors the shape of runs.ts (Orchestrator routes). Battle creation delegates to
* the battle-runner; cancellation calls cancelBattle then aborts in-flight tasks
* via the dispatcher's cancelExternalTask.
*/ */
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { BattleRunner } from '../services/arena-runner.js';
import type { ExternalCancelFn } from './tasks.js';
import { arenaModelCall } from '../services/arena-model-call.js';
const ContestantSchema = z.object({ // ─── Validation schemas ───────────────────────────────────────────────────────
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(), const UuidParam = z.string().uuid();
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(), const ContestantInput = z.object({
identity: z.string().min(1).max(200),
model: z.string().min(1).max(200),
}); });
const CreateArenaBody = z.object({ const CreateBattleBody = z.object({
project_id: z.string().uuid(), project_id: z.string().uuid(),
input: z.string().min(1).max(64_000), battle_type: z.enum(['coding', 'qa']),
contestants: z.array(ContestantSchema).min(2).max(5), prompt: z.string().min(1).max(64_000),
contestants: z
.array(ContestantInput)
.min(2, 'at least 2 contestants required')
.max(6, 'at most 6 contestants allowed'),
}); });
interface TaskRow { const ListBattlesQuery = z.object({
id: string; project_id: z.string().uuid(),
agent: string | null; });
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
state: string;
}
export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void { const CrossExamineBody = z.object({
// POST /api/arena — create a new arena identity: z.string().min(1).max(200),
app.post('/api/arena', async (req, reply) => { model: z.string().min(1).max(200),
const parsed = CreateArenaBody.safeParse(req.body); });
const SetWinnerBody = z.object({
winner_contestant_id: z.string().uuid().nullable(),
});
// ─── Route registration ───────────────────────────────────────────────────────
const GeneratePromptBody = z.object({
description: z.string().min(1).max(2_000),
});
export function registerArenaRoutes(
app: FastifyInstance,
sql: Sql,
battleRunner: BattleRunner,
cancelExternal: ExternalCancelFn,
config: Config,
): void {
// POST /api/battles/generate-prompt — draft a fuller battle prompt from a
// short description using the default BooChat model. One-shot, non-streaming.
// Must be registered BEFORE /api/battles/:id so the literal 'generate-prompt'
// path is not mistaken for a UUID param.
app.post('/api/battles/generate-prompt', async (req, reply) => {
const parsed = GeneratePromptBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
reply.code(400); reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const { project_id, input, contestants } = parsed.data; const { description } = parsed.data;
const arenaId = crypto.randomUUID();
const tasks: TaskRow[] = []; try {
for (const contestant of contestants) { const prompt = await arenaModelCall({
const [task] = await sql<TaskRow[]>` config,
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id) model: config.DEFAULT_MODEL,
VALUES ( system: [
${project_id}, 'You are a battle-prompt writer for an AI Arena.',
${input}, 'The user gives you a short description of a coding or Q&A challenge.',
${contestant.agent ?? null}, 'Expand it into a clear, self-contained prompt (26 sentences) that any AI model can act on.',
${contestant.model ?? null}, 'Include specific acceptance criteria where helpful.',
${contestant.mode_id ?? null}, 'Output ONLY the prompt — no preamble, no labels, no meta-commentary.',
${contestant.thinking_option_id ?? null}, ].join(' '),
${arenaId} user: description,
) maxTokens: 400,
RETURNING id, agent, model, mode_id, thinking_option_id, state temperature: 0.6,
`; });
tasks.push(task!); return { prompt };
} catch (err) {
app.log.warn(
{ err: err instanceof Error ? err.message : String(err) },
'arena generate-prompt: model call failed',
);
reply.code(502);
return { error: 'model call failed' };
} }
});
// POST /api/battles — launch a battle
app.post('/api/battles', async (req, reply) => {
const parsed = CreateBattleBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, battle_type, prompt, contestants } = parsed.data;
// Reject duplicate (identity, model) pairs up front — the schema UNIQUE
// constraint would catch it too, but an early 422 is friendlier.
const seen = new Set<string>();
for (const c of contestants) {
const key = `${c.identity}::${c.model}`;
if (seen.has(key)) {
reply.code(422);
return {
error: 'duplicate_contestant',
message: `duplicate contestant: identity="${c.identity}" model="${c.model}"`,
};
}
seen.add(key);
}
// Verify project exists
const [proj] = await sql<{ id: string }[]>`SELECT id FROM projects WHERE id = ${project_id}`;
if (!proj) {
reply.code(404);
return { error: 'project not found' };
}
const { battleId } = await battleRunner.startBattle({
projectId: project_id,
battleType: battle_type,
prompt,
contestants,
});
reply.code(201); reply.code(201);
return { return { battle_id: battleId };
arena_id: arenaId,
tasks: tasks.map((t) => ({
id: t.id,
agent: t.agent,
model: t.model,
mode_id: t.mode_id,
thinking_option_id: t.thinking_option_id,
state: t.state,
})),
};
}); });
// GET /api/arena/:arena_id — list all tasks in an arena // GET /api/battles?project_id= — list battles, most-recent-first
app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => { app.get('/api/battles', async (req, reply) => {
const { arena_id } = req.params; const parsed = ListBattlesQuery.safeParse(req.query);
if (!parsed.success) {
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(arena_id)) {
reply.code(400); reply.code(400);
return { error: 'invalid arena_id format' }; return { error: 'invalid query', details: parsed.error.flatten() };
} }
const tasks = await sql` const battles = await sql`
SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id SELECT id, project_id, battle_type, prompt, status,
FROM tasks winner_contestant_id, results_path, error,
WHERE arena_id = ${arena_id} created_at, updated_at
ORDER BY created_at FROM battles
WHERE project_id = ${parsed.data.project_id}
ORDER BY created_at DESC
LIMIT 100
`; `;
if (tasks.length === 0) { return { battles };
reply.code(404);
return { error: 'arena not found' };
}
return { arena_id, tasks };
}); });
// POST /api/arena/:arena_id/select/:task_id — mark the winner // GET /api/battles/:id — one battle + its contestants + cross-examinations
app.post<{ Params: { arena_id: string; task_id: string } }>( app.get<{ Params: { id: string } }>('/api/battles/:id', async (req, reply) => {
'/api/arena/:arena_id/select/:task_id', const parsedId = UuidParam.safeParse(req.params.id);
async (req, reply) => { if (!parsedId.success) {
const { arena_id, task_id } = req.params; reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
// Verify the task belongs to this arena const [battle] = await sql<{
const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>` id: string;
SELECT id, state, arena_id FROM tasks WHERE id = ${task_id} project_id: string;
battle_type: string;
prompt: string;
status: string;
winner_contestant_id: string | null;
results_path: string | null;
error: string | null;
created_at: unknown;
updated_at: unknown;
}[]>`
SELECT id, project_id, battle_type, prompt, status,
winner_contestant_id, results_path, error,
created_at, updated_at
FROM battles WHERE id = ${id}
`; `;
if (rows.length === 0) { if (!battle) {
reply.code(404); reply.code(404);
return { error: 'task not found' }; return { error: 'battle not found' };
} }
const task = rows[0]!; const contestants = await sql`
if (task.arena_id !== arena_id) { SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
reply.code(409); status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
return { error: 'task does not belong to this arena' }; created_at, updated_at
} FROM contestants
WHERE battle_id = ${id}
// Mark as selected via output_summary prefix (lightweight — no schema change) ORDER BY created_at ASC
await sql`
UPDATE tasks
SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]')
WHERE id = ${task_id}
`; `;
return { selected: true, task_id, arena_id }; const crossExaminations = await sql`
SELECT id, battle_id, identity, model, verdict, created_at
FROM cross_examinations
WHERE battle_id = ${id}
ORDER BY created_at ASC
`;
return { battle, contestants, cross_examinations: crossExaminations };
});
// POST /api/battles/:id/stop — cancel a running battle
app.post<{ Params: { id: string } }>('/api/battles/:id/stop', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
} }
); const id = parsedId.data;
const [row] = await sql<{ id: string; status: string }[]>`
SELECT id, status FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (row.status !== 'running') {
reply.code(409);
return { error: `cannot stop battle in status '${row.status}'` };
}
const { cancelled, taskIds } = await battleRunner.cancelBattle(id);
if (!cancelled) {
reply.code(409);
return { error: 'battle is no longer running' };
}
// Abort any in-flight dispatcher tasks (cloud contestants running externally).
for (const taskId of taskIds) {
cancelExternal(taskId);
}
return { cancelled: true };
});
// GET /api/battles/:id/analysis — read analysis.md from the battle's results_path
app.get<{ Params: { id: string } }>('/api/battles/:id/analysis', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const [row] = await sql<{ results_path: string | null }[]>`
SELECT results_path FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (!row.results_path) {
reply.code(404);
return { error: 'analysis not ready' };
}
try {
const text = await readFile(join(row.results_path, 'analysis.md'), 'utf8');
return { text };
} catch {
reply.code(404);
return { error: 'analysis not ready' };
}
});
// POST /api/battles/:id/analyze — trigger or re-trigger analysis
app.post<{ Params: { id: string } }>('/api/battles/:id/analyze', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const [row] = await sql<{ id: string; status: string }[]>`
SELECT id, status FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (row.status === 'running') {
reply.code(409);
return { error: 'battle is still running — wait for all contestants to finish' };
}
const result = await battleRunner.triggerAnalysis(id);
if (!result.triggered) {
reply.code(404);
return { error: 'battle not found' };
}
reply.code(202);
return { triggered: true };
});
// PATCH /api/battles/:id/winner — manually set or clear the winner.
// Validates the contestant belongs to the battle; publishes battle_updated so
// the pane badge reflects the override immediately. Human is authoritative.
app.patch<{ Params: { id: string } }>('/api/battles/:id/winner', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const parsed = SetWinnerBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const result = await battleRunner.setWinner(parsedId.data, parsed.data.winner_contestant_id);
if (!result.ok) {
if (result.notFound) { reply.code(404); return { error: 'battle not found' }; }
if (result.invalidContestant) { reply.code(422); return { error: 'contestant not found in this battle' }; }
reply.code(500); return { error: 'unknown error' };
}
return { ok: true };
});
// GET /api/battles/:id/contestants/:cid/diff — read the diff.patch for a coding contestant.
app.get<{ Params: { id: string; cid: string } }>('/api/battles/:id/contestants/:cid/diff', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
const parsedCid = UuidParam.safeParse(req.params.cid);
if (!parsedId.success || !parsedCid.success) {
reply.code(400);
return { error: 'invalid id' };
}
const [contestant] = await sql<{ result_path: string | null }[]>`
SELECT result_path FROM contestants
WHERE id = ${parsedCid.data} AND battle_id = ${parsedId.data}
`;
if (!contestant) {
reply.code(404);
return { error: 'contestant not found' };
}
if (!contestant.result_path) {
reply.code(404);
return { error: 'diff not available' };
}
try {
const text = await readFile(join(contestant.result_path, 'diff.patch'), 'utf8');
return { diff: text };
} catch {
reply.code(404);
return { error: 'diff not available' };
}
});
// POST /api/battles/:id/cross-examine — start a cross-examination
app.post<{ Params: { id: string } }>('/api/battles/:id/cross-examine', async (req, reply) => {
const parsedId = UuidParam.safeParse(req.params.id);
if (!parsedId.success) {
reply.code(400);
return { error: 'invalid id' };
}
const id = parsedId.data;
const parsed = CrossExamineBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const [row] = await sql<{ id: string; status: string }[]>`
SELECT id, status FROM battles WHERE id = ${id}
`;
if (!row) {
reply.code(404);
return { error: 'battle not found' };
}
if (row.status === 'running') {
reply.code(409);
return { error: 'battle is still running — cross-examine after all contestants finish' };
}
const { crossExamId } = await battleRunner.startCrossExam(id, {
identity: parsed.data.identity,
model: parsed.data.model,
});
reply.code(202);
return { cross_exam_id: crossExamId };
});
} }

View File

@@ -4,7 +4,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import { resolveChatId } from './chat-resolve.js'; import { resolveChatId } from './chat-resolve.js';
import { applyAll } from '../services/pending_changes.js'; import { asPermissionMode } from '../services/tools/types.js';
const AnswerUserInputBody = z.object({ const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1), tool_call_id: z.string().min(1),
@@ -44,7 +44,13 @@ const SendBody = z.object({
}); });
interface InferenceApi { interface InferenceApi {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>; cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean; hasActive: (chatId: string) => boolean;
} }
@@ -246,36 +252,16 @@ export function registerMessageRoutes(
RETURNING id RETURNING id
`; `;
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default'); // Native BooCode permission gate (plan/ask/bypass) — threaded into the
// write-tool context so create/edit/delete and apply_pending honor it.
// Bypass permission mode (native BooCode): auto-apply staged edits to disk // Plan = read-only, Ask = stage to the queue (agent can't self-apply),
// once the turn settles. `enqueue` registers synchronously, so hasActive is // Bypass = apply each write immediately. Other mode ids (e.g. an external
// true immediately; poll until it clears, apply, then re-publish // fallback's native mode) leave the gate undefined = legacy behavior.
// message_complete so the DiffPanel reflects the now-applied (non-pending) req.log.info(
// state. Best-effort — failures stay in the pending queue for manual apply. { provider, mode_id, permissionMode: asPermissionMode(mode_id), chatId },
if (mode_id === 'bypass') { 'native enqueue — permission gate',
const projectId = sessionRows[0]!.project_id; );
const assistantId = assistantMsg!.id; inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default', asPermissionMode(mode_id));
void (async () => {
try {
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${projectId}`;
if (!proj?.path) return;
for (let i = 0; i < 1200 && inference.hasActive(chatId); i++) {
await new Promise((r) => setTimeout(r, 1000));
}
const applied = await applyAll(sql, sessionId, proj.path);
if (applied.length > 0) {
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as unknown as WsFrame);
}
} catch {
/* best-effort auto-apply — leave staged changes for manual apply */
}
})();
}
reply.code(202); reply.code(202);
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id }; return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };

View File

@@ -54,9 +54,6 @@ DO $$ BEGIN
END IF; END IF;
END $$; END $$;
-- v2.0.5: arena support — group tasks into competitive arenas.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;
-- Human inbox: tasks needing attention -- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed'); SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
@@ -81,6 +78,7 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
DROP VIEW IF EXISTS human_inbox; DROP VIEW IF EXISTS human_inbox;
ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values; ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values;
ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path; ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path;
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
CREATE OR REPLACE VIEW human_inbox AS CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed'); SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
@@ -157,7 +155,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path)
DROP TABLE IF EXISTS session_worktrees; DROP TABLE IF EXISTS session_worktrees;
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and -- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
-- skills route set it from the frontend tab; session-less creators (arena, MCP, -- skills route set it from the frontend tab; session-less creators (MCP,
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat. -- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
@@ -271,7 +269,7 @@ ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk
CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk')); CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk'));
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes, -- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same -- new_task tool, MCP server) fires pg_notify('tasks_new') in the same
-- transaction, so the dispatcher reacts immediately instead of waiting for the -- transaction, so the dispatcher reacts immediately instead of waiting for the
-- fallback poll. Postgres holds the notification until COMMIT, so the listener -- fallback poll. Postgres holds the notification until COMMIT, so the listener
-- always sees the committed row. A trigger covers all insert paths with no -- always sees the committed row. A trigger covers all insert paths with no
@@ -357,3 +355,86 @@ DO $$ BEGIN
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled')); CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled'));
END IF; END IF;
END $$; END $$;
-- Arena: battles + contestants + cross_examinations.
-- project_id carries no FK (matches tasks.project_id + flow_runs.project_id convention).
-- winner_contestant_id FK is deferred (forward reference): added via guarded ALTER below.
CREATE TABLE IF NOT EXISTS battles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
battle_type TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
winner_contestant_id UUID,
results_path TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT battles_type_chk CHECK (battle_type IN ('coding', 'qa')),
CONSTRAINT battles_status_chk CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled'))
);
CREATE TABLE IF NOT EXISTS contestants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
battle_id UUID NOT NULL REFERENCES battles(id) ON DELETE CASCADE,
identity TEXT NOT NULL,
model TEXT NOT NULL,
lane TEXT NOT NULL,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'queued',
duration_ms INTEGER,
tokens_per_sec DOUBLE PRECISION,
cost_tokens INTEGER,
result_path TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT contestants_lane_chk CHECK (lane IN ('local', 'cloud')),
CONSTRAINT contestants_status_chk CHECK (status IN ('queued', 'running', 'done', 'error')),
UNIQUE (battle_id, identity, model)
);
CREATE TABLE IF NOT EXISTS cross_examinations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
battle_id UUID NOT NULL REFERENCES battles(id) ON DELETE CASCADE,
identity TEXT NOT NULL,
model TEXT NOT NULL,
verdict TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- Add the winner FK now that contestants exists.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'battles_winner_contestant_id_fkey') THEN
ALTER TABLE battles ADD CONSTRAINT battles_winner_contestant_id_fkey
FOREIGN KEY (winner_contestant_id) REFERENCES contestants(id) ON DELETE SET NULL;
END IF;
END $$;
-- battles query (GET /api/battles?project_id=).
CREATE INDEX IF NOT EXISTS battles_project_created_idx ON battles(project_id, created_at DESC);
-- Lane-scheduler advance scans (contestants WHERE battle_id = ? AND status = ?).
CREATE INDEX IF NOT EXISTS contestants_battle_status_idx ON contestants(battle_id, status);
-- onTaskTerminal callback: look up the contestant owning a completed task.
CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
-- Cross-examination listing per battle.
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
-- TokenScope: per-category token breakdown on arena contestants and tasks.
ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
-- Orchestrator flow step events (append-only event log for resume/replay).
CREATE TABLE IF NOT EXISTS flow_step_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES flow_runs(id),
step_id VARCHAR(64) NOT NULL,
event VARCHAR(32) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);

View File

@@ -0,0 +1,254 @@
import { describe, it, expect } from 'vitest';
import {
buildDigestPrompt,
buildJudgePrompt,
buildCrossExamPrompt,
extractWinner,
shouldNameWinner,
type ContestantDigest,
type ContestantDigestInput,
} from '../arena-analyzer-helpers.js';
// ─── shouldNameWinner ─────────────────────────────────────────────────────────
describe('shouldNameWinner', () => {
it('returns false with 0 succeeded contestants', () => {
expect(shouldNameWinner(0)).toBe(false);
});
it('returns false with exactly 1 succeeded contestant', () => {
expect(shouldNameWinner(1)).toBe(false);
});
it('returns true with exactly 2 succeeded contestants', () => {
expect(shouldNameWinner(2)).toBe(true);
});
it('returns true with more than 2 succeeded contestants', () => {
expect(shouldNameWinner(3)).toBe(true);
expect(shouldNameWinner(6)).toBe(true);
});
});
// ─── extractWinner ────────────────────────────────────────────────────────────
describe('extractWinner', () => {
it('extracts identity and model from a WINNER: line', () => {
const output = 'Some analysis\n\nWINNER: claude/opus-4-5\n\nMore text.';
expect(extractWinner(output)).toEqual({ identity: 'claude', model: 'opus-4-5' });
});
it('is case-insensitive for the WINNER keyword', () => {
expect(extractWinner('winner: boocode/qwen3.6-35b')).toEqual({
identity: 'boocode',
model: 'qwen3.6-35b',
});
expect(extractWinner('Winner: opencode/some-model')).toEqual({
identity: 'opencode',
model: 'some-model',
});
});
it('returns null when NO_WINNER is declared', () => {
expect(extractWinner('WINNER: NO_WINNER')).toBeNull();
expect(extractWinner('winner: no_winner')).toBeNull();
});
it('returns null when no WINNER line is present', () => {
expect(extractWinner('Just some analysis text with no verdict.')).toBeNull();
expect(extractWinner('')).toBeNull();
});
it('returns null when the WINNER line has no slash separator', () => {
expect(extractWinner('WINNER: justidentity')).toBeNull();
});
it('returns null when the WINNER line is empty after the colon', () => {
expect(extractWinner('WINNER:')).toBeNull();
expect(extractWinner('WINNER: ')).toBeNull();
});
it('handles leading and trailing whitespace around the slash parts', () => {
const result = extractWinner('WINNER: claude / opus-4-5 ');
expect(result).toEqual({ identity: 'claude', model: 'opus-4-5' });
});
it('picks the first WINNER line when multiple are present', () => {
const output = 'WINNER: claude/opus-4-5\nWINNER: opencode/other-model';
expect(extractWinner(output)).toEqual({ identity: 'claude', model: 'opus-4-5' });
});
it('handles model names that contain slashes by splitting at the first slash only', () => {
// edge case: model name with a slash — should still split at first slash
// identity = 'native', model = 'llama-swap/qwen3.6'
const result = extractWinner('WINNER: native/llama-swap/qwen3.6');
expect(result).toEqual({ identity: 'native', model: 'llama-swap/qwen3.6' });
});
});
// ─── buildDigestPrompt ────────────────────────────────────────────────────────
describe('buildDigestPrompt', () => {
const base: ContestantDigestInput = {
identity: 'claude',
model: 'opus-4-5',
resultMd: '# Output\n\nSome result content.',
benchmarkLine: '12000ms',
};
it('returns an object with non-empty system and user strings', () => {
const { system, user } = buildDigestPrompt(base);
expect(system.length).toBeGreaterThan(0);
expect(user.length).toBeGreaterThan(0);
});
it('includes the contestant identity and model in the user prompt', () => {
const { user } = buildDigestPrompt(base);
expect(user).toContain('claude');
expect(user).toContain('opus-4-5');
});
it('includes the benchmark line in the user prompt', () => {
const { user } = buildDigestPrompt(base);
expect(user).toContain('12000ms');
});
it('includes the result.md content in the user prompt', () => {
const { user } = buildDigestPrompt(base);
expect(user).toContain('Some result content.');
});
it('includes the diff.patch when provided', () => {
const input: ContestantDigestInput = { ...base, diffPatch: '--- a/foo.ts\n+++ b/foo.ts\n+added' };
const { user } = buildDigestPrompt(input);
expect(user).toContain('added');
expect(user).toContain('```diff');
});
it('omits the diff section when diffPatch is undefined', () => {
const { user } = buildDigestPrompt(base);
expect(user).not.toContain('```diff');
});
it('truncates resultMd longer than 8000 characters', () => {
const longResult = 'x'.repeat(10_000);
const { user } = buildDigestPrompt({ ...base, resultMd: longResult });
// The truncated content must not exceed 8000 chars in the sliced section.
// We just check the total user string doesn't balloon unreasonably.
expect(user.length).toBeLessThan(15_000);
});
it('truncates diffPatch longer than 5000 characters', () => {
const longDiff = '+' + 'x'.repeat(10_000);
const { user } = buildDigestPrompt({ ...base, diffPatch: longDiff });
expect(user.length).toBeLessThan(16_000);
});
});
// ─── buildJudgePrompt ─────────────────────────────────────────────────────────
describe('buildJudgePrompt', () => {
const digests: ContestantDigest[] = [
{ identity: 'claude', model: 'opus-4-5', digest: 'Good result.', benchmarkLine: '5000ms' },
{ identity: 'opencode', model: 'qwen3.6', digest: 'Decent result.', benchmarkLine: '8000ms' },
];
it('includes the original prompt in the user section', () => {
const { user } = buildJudgePrompt('Write a sorting algorithm', digests);
expect(user).toContain('Write a sorting algorithm');
});
it('includes each contestant heading in the user section', () => {
const { user } = buildJudgePrompt('prompt', digests);
expect(user).toContain('claude');
expect(user).toContain('opus-4-5');
expect(user).toContain('opencode');
expect(user).toContain('qwen3.6');
});
it('includes each contestant digest text', () => {
const { user } = buildJudgePrompt('prompt', digests);
expect(user).toContain('Good result.');
expect(user).toContain('Decent result.');
});
it('instructs the model to name a WINNER when 2+ digests are provided', () => {
const { system } = buildJudgePrompt('prompt', digests);
expect(system).toContain('WINNER:');
});
it('instructs the model NOT to name a winner when fewer than 2 digests are provided', () => {
const oneDigest = digests.slice(0, 1);
const { system } = buildJudgePrompt('prompt', oneDigest);
expect(system).toContain('NO_WINNER');
expect(system).not.toContain('WINNER: <identity>');
});
it('instructs NO_WINNER when digests list is empty', () => {
const { system } = buildJudgePrompt('prompt', []);
expect(system).toContain('NO_WINNER');
});
it('truncates originalPrompt longer than 2000 characters', () => {
const longPrompt = 'p'.repeat(5_000);
const { user } = buildJudgePrompt(longPrompt, digests);
// Should not contain more than 2000 chars of the prompt.
const promptSection = user.split('# Contestant Digests')[0] ?? '';
expect(promptSection.length).toBeLessThan(3_000);
});
});
// ─── buildCrossExamPrompt ─────────────────────────────────────────────────────
describe('buildCrossExamPrompt', () => {
const digests: ContestantDigest[] = [
{ identity: 'claude', model: 'opus-4-5', digest: 'Strong result.', benchmarkLine: '5000ms' },
{ identity: 'boocode', model: 'qwen3.6-35b', digest: 'Decent result.', benchmarkLine: '12000ms' },
];
const baseOpts = {
originalPrompt: 'Write a sorting algorithm.',
digests,
analysisContent: '# Arena Analysis\n\nClaude did better.\n\nWINNER: claude/opus-4-5',
proposedWinner: 'claude/opus-4-5',
examinerIdentity: 'goose',
examinerModel: 'gpt-4o',
};
it('includes the examiner identity and model in the system prompt', () => {
const { system } = buildCrossExamPrompt(baseOpts);
expect(system).toContain('goose');
expect(system).toContain('gpt-4o');
});
it('includes the original prompt in the user section', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('Write a sorting algorithm.');
});
it('includes each contestant digest', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('Strong result.');
expect(user).toContain('Decent result.');
});
it('includes the proposed analysis content', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('Claude did better.');
});
it('includes the proposed winner when set', () => {
const { user } = buildCrossExamPrompt(baseOpts);
expect(user).toContain('claude/opus-4-5');
});
it('notes that no winner was proposed when proposedWinner is null', () => {
const { user } = buildCrossExamPrompt({ ...baseOpts, proposedWinner: null });
expect(user).toContain('No winner was proposed');
});
it('instructs the examiner to provide a VERDICT line', () => {
const { system } = buildCrossExamPrompt(baseOpts);
expect(system).toContain('VERDICT:');
});
});

View File

@@ -0,0 +1,350 @@
import { describe, it, expect } from 'vitest';
import {
classifyLane,
nextLocalContestant,
isBattleComplete,
computeBenchmark,
sanitizeSlug,
buildBattleSlug,
buildContestantDir,
reconcileContestantResume,
reconcileContestants,
type ContestantSlot,
} from '../arena-decisions.js';
// Local models = what the llama-swap server actually serves.
const LOCAL_MODELS: ReadonlySet<string> = new Set([
'qwen3.6-35b-a3b-mxfp4',
'qwen2.5-coder-7b',
]);
// ─── classifyLane ────────────────────────────────────────────────────────────
describe('classifyLane', () => {
it('classifies qa battles as local regardless of identity or model', () => {
expect(classifyLane('qa', 'boocode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
expect(classifyLane('qa', 'claude', 'claude-opus-4-5', LOCAL_MODELS)).toBe('local');
expect(classifyLane('qa', 'Debugger', 'cloud-model', new Set())).toBe('local');
expect(classifyLane('qa', 'opencode', 'any-model', LOCAL_MODELS)).toBe('local');
});
it('classifies coding contestants as local when model is in localModels', () => {
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
expect(classifyLane('coding', 'opencode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
expect(classifyLane('coding', 'qwen', 'qwen2.5-coder-7b', LOCAL_MODELS)).toBe('local');
});
it('classifies coding contestants as cloud when model is not in localModels', () => {
expect(classifyLane('coding', 'claude', 'claude-opus-4-5', LOCAL_MODELS)).toBe('cloud');
expect(classifyLane('coding', 'opencode', 'claude-opus-4-5', LOCAL_MODELS)).toBe('cloud');
expect(classifyLane('coding', 'goose', 'gpt-4o', LOCAL_MODELS)).toBe('cloud');
expect(classifyLane('coding', 'qwen', 'unknown-remote-model', LOCAL_MODELS)).toBe('cloud');
});
it('uses the injected localModels set, not a hardcoded list', () => {
const custom = new Set(['my-local-model']);
expect(classifyLane('coding', 'any-agent', 'my-local-model', custom)).toBe('local');
expect(classifyLane('coding', 'boocode', 'other-model', custom)).toBe('cloud');
});
it('defaults to cloud for an empty localModels set', () => {
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', new Set())).toBe('cloud');
expect(classifyLane('coding', 'native', 'any-local-model', new Set())).toBe('cloud');
});
});
// ─── nextLocalContestant ─────────────────────────────────────────────────────
describe('nextLocalContestant', () => {
it('returns null for an empty list', () => {
expect(nextLocalContestant([])).toBeNull();
});
it('returns null when no local contestants are queued', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'local', status: 'running' },
{ id: 'c2', lane: 'cloud', status: 'queued' },
];
expect(nextLocalContestant(slots)).toBeNull();
});
it('returns the first queued local contestant in order', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'local', status: 'done' },
{ id: 'c2', lane: 'local', status: 'queued' },
{ id: 'c3', lane: 'local', status: 'queued' },
];
expect(nextLocalContestant(slots)).toBe('c2');
});
it('skips done/error local contestants and cloud contestants', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'cloud', status: 'queued' },
{ id: 'c2', lane: 'local', status: 'error' },
{ id: 'c3', lane: 'local', status: 'queued' },
];
expect(nextLocalContestant(slots)).toBe('c3');
});
it('returns null when all local contestants are done or error', () => {
const slots: ContestantSlot[] = [
{ id: 'c1', lane: 'local', status: 'done' },
{ id: 'c2', lane: 'local', status: 'error' },
];
expect(nextLocalContestant(slots)).toBeNull();
});
});
// ─── isBattleComplete ────────────────────────────────────────────────────────
describe('isBattleComplete', () => {
it('returns false for an empty list', () => {
expect(isBattleComplete([])).toBe(false);
});
it('returns true when all contestants are done', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'done' }])).toBe(true);
});
it('returns true when all contestants are error', () => {
expect(isBattleComplete([{ status: 'error' }, { status: 'error' }])).toBe(true);
});
it('returns true for a mixed done/error result', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'error' }, { status: 'done' }])).toBe(true);
});
it('returns false while any contestant is still running', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'running' }])).toBe(false);
});
it('returns false while any contestant is still queued', () => {
expect(isBattleComplete([{ status: 'done' }, { status: 'queued' }])).toBe(false);
});
});
// ─── computeBenchmark ────────────────────────────────────────────────────────
describe('computeBenchmark', () => {
const t0 = new Date('2026-06-06T10:00:00.000Z');
const t1 = new Date('2026-06-06T10:00:05.000Z'); // +5 000ms
it('computes duration in ms for both lanes', () => {
const local = computeBenchmark(t0, t1, 100, 'local');
expect(local.durationMs).toBe(5000);
const cloud = computeBenchmark(t0, t1, null, 'cloud');
expect(cloud.durationMs).toBe(5000);
});
it('computes tokens/sec for local lane when costTokens is known', () => {
const bench = computeBenchmark(t0, t1, 500, 'local');
expect(bench.tokensPerSec).toBeCloseTo(100, 5); // 500 / 5 = 100 tok/s
});
it('omits tokens/sec for cloud lane regardless of costTokens', () => {
const bench = computeBenchmark(t0, t1, 500, 'cloud');
expect(bench.tokensPerSec).toBeNull();
});
it('omits tokens/sec for local lane when costTokens is null', () => {
const bench = computeBenchmark(t0, t1, null, 'local');
expect(bench.tokensPerSec).toBeNull();
});
it('returns durationMs = 0 and null tokensPerSec when timestamps are equal', () => {
const bench = computeBenchmark(t0, t0, 100, 'local');
expect(bench.durationMs).toBe(0);
expect(bench.tokensPerSec).toBeNull();
});
it('clamps negative duration to 0 (clock skew)', () => {
const bench = computeBenchmark(t1, t0, 50, 'local');
expect(bench.durationMs).toBe(0);
expect(bench.tokensPerSec).toBeNull();
});
it('includes token breakdown when provided', () => {
const breakdown = {
system: 10,
user: 20,
assistant: 30,
tools: 40,
reasoning: 5,
total: 105,
};
const bench = computeBenchmark(t0, t1, 500, 'local', breakdown);
expect(bench.tokenBreakdown).toEqual(breakdown);
});
it('defaults token breakdown to null when omitted', () => {
const bench = computeBenchmark(t0, t1, 500, 'local');
expect(bench.tokenBreakdown).toBeNull();
});
});
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
describe('sanitizeSlug', () => {
it('lowercases and preserves alphanumeric + hyphens', () => {
expect(sanitizeSlug('claude')).toBe('claude');
expect(sanitizeSlug('claude-opus-4-5')).toBe('claude-opus-4-5');
});
it('replaces spaces and special characters with hyphens', () => {
expect(sanitizeSlug('Code Reviewer')).toBe('code-reviewer');
expect(sanitizeSlug('native/boocode')).toBe('native-boocode');
expect(sanitizeSlug('qwen2.5-coder-35b')).toBe('qwen2-5-coder-35b');
});
it('collapses consecutive non-alphanumeric runs to a single hyphen', () => {
expect(sanitizeSlug('foo bar---baz')).toBe('foo-bar-baz');
});
it('strips leading and trailing hyphens', () => {
expect(sanitizeSlug('---foo---')).toBe('foo');
});
it('truncates to 64 characters', () => {
const long = 'a'.repeat(100);
expect(sanitizeSlug(long).length).toBe(64);
});
});
// ─── buildBattleSlug ─────────────────────────────────────────────────────────
describe('buildBattleSlug', () => {
it('builds a deterministic dated slug from id, type, and createdAt', () => {
const id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const createdAt = new Date('2026-06-06T12:00:00.000Z');
const slug = buildBattleSlug(id, 'coding', createdAt);
expect(slug).toBe('2026-06-06-coding-a1b2c3d4');
});
it('includes the battle type in the slug', () => {
const id = 'aaaaaaaa-0000-0000-0000-000000000000';
const createdAt = new Date('2026-01-01T00:00:00.000Z');
expect(buildBattleSlug(id, 'qa', createdAt)).toContain('-qa-');
expect(buildBattleSlug(id, 'coding', createdAt)).toContain('-coding-');
});
it('uses the first 8 hex chars of the uuid (dashes stripped)', () => {
const id = 'deadbeef-0000-0000-0000-000000000000';
const slug = buildBattleSlug(id, 'coding', new Date('2026-06-06T00:00:00Z'));
expect(slug.endsWith('-deadbeef')).toBe(true);
});
});
// ─── buildContestantDir ──────────────────────────────────────────────────────
describe('buildContestantDir', () => {
it('joins sanitized identity and model with a hyphen', () => {
expect(buildContestantDir('claude', 'claude-opus-4-5')).toBe('claude-claude-opus-4-5');
});
it('sanitizes both parts independently', () => {
expect(buildContestantDir('Code Reviewer', 'qwen2.5-35b')).toBe('code-reviewer-qwen2-5-35b');
});
});
// ─── reconcileContestantResume ───────────────────────────────────────────────
describe('reconcileContestantResume', () => {
it('keeps non-running contestants regardless of task state', () => {
for (const status of ['queued', 'done', 'error']) {
expect(reconcileContestantResume(status, 'tid', 'completed')).toBe('keep');
expect(reconcileContestantResume(status, null, null)).toBe('keep');
}
});
it('re-dispatches a running contestant with no task_id', () => {
expect(reconcileContestantResume('running', null, null)).toBe('re-dispatch');
});
it('re-dispatches a running contestant whose task row is absent', () => {
expect(reconcileContestantResume('running', 'tid', null)).toBe('re-dispatch');
});
it('marks done when the task completed before the terminal callback ran', () => {
expect(reconcileContestantResume('running', 'tid', 'completed')).toBe('mark-done');
});
it('marks error when the task failed', () => {
expect(reconcileContestantResume('running', 'tid', 'failed')).toBe('mark-error');
});
it('marks cancelled when the task was cancelled', () => {
expect(reconcileContestantResume('running', 'tid', 'cancelled')).toBe('mark-cancelled');
});
it('keeps a running contestant whose task is pending (dispatcher handles it)', () => {
expect(reconcileContestantResume('running', 'tid', 'pending')).toBe('keep');
});
it('re-dispatches when the task is stuck running (process died)', () => {
expect(reconcileContestantResume('running', 'tid', 'running')).toBe('re-dispatch');
});
it('re-dispatches when the task is blocked (permission dialog gone on restart)', () => {
expect(reconcileContestantResume('running', 'tid', 'blocked')).toBe('re-dispatch');
});
});
// ─── reconcileContestants ────────────────────────────────────────────────────
describe('reconcileContestants', () => {
it('returns one decision per contestant', () => {
const contestants = [
{ contestantId: 'c1', taskId: null, status: 'done' },
{ contestantId: 'c2', taskId: 't1', status: 'running' },
{ contestantId: 'c3', taskId: 't2', status: 'running' },
];
const taskStates = new Map([['t1', 'completed'], ['t2', 'running']]);
const decisions = reconcileContestants(contestants, taskStates);
expect(decisions).toHaveLength(3);
expect(decisions[0]).toEqual({ contestantId: 'c1', action: 'keep' });
expect(decisions[1]).toEqual({ contestantId: 'c2', action: 'mark-done' });
expect(decisions[2]).toEqual({ contestantId: 'c3', action: 're-dispatch' });
});
it('re-dispatches a running contestant whose taskId is absent from taskStates', () => {
const contestants = [{ contestantId: 'c1', taskId: 'orphan', status: 'running' }];
const decisions = reconcileContestants(contestants, new Map());
expect(decisions[0]?.action).toBe('re-dispatch');
});
it('re-dispatches a running contestant with null taskId', () => {
const contestants = [{ contestantId: 'c1', taskId: null, status: 'running' }];
const decisions = reconcileContestants(contestants, new Map());
expect(decisions[0]?.action).toBe('re-dispatch');
});
it('returns empty array for no contestants', () => {
expect(reconcileContestants([], new Map())).toEqual([]);
});
it('keeps a running contestant whose task is pending', () => {
const contestants = [{ contestantId: 'c1', taskId: 't1', status: 'running' }];
const taskStates = new Map([['t1', 'pending']]);
const decisions = reconcileContestants(contestants, taskStates);
expect(decisions[0]?.action).toBe('keep');
});
it('handles a mixed battle: done/queued kept, stale running re-dispatched', () => {
const contestants = [
{ contestantId: 'c1', taskId: 't1', status: 'done' },
{ contestantId: 'c2', taskId: null, status: 'queued' },
{ contestantId: 'c3', taskId: 't2', status: 'running' },
{ contestantId: 'c4', taskId: 't3', status: 'running' },
];
const taskStates = new Map([
['t1', 'completed'],
['t2', 'running'], // stuck — process dead
['t3', 'pending'], // dispatcher will handle
]);
const decisions = reconcileContestants(contestants, taskStates);
expect(decisions.find((d) => d.contestantId === 'c1')?.action).toBe('keep');
expect(decisions.find((d) => d.contestantId === 'c2')?.action).toBe('keep');
expect(decisions.find((d) => d.contestantId === 'c3')?.action).toBe('re-dispatch');
expect(decisions.find((d) => d.contestantId === 'c4')?.action).toBe('keep');
});
});

View File

@@ -161,6 +161,52 @@ describe('locateMatch — strategy 4: Levenshtein', () => {
}); });
}); });
describe('locateMatch — strategy 4: fail-closed on ambiguity (corruption guard)', () => {
it('refuses (ambiguous) when two equally-similar anchored blocks both clear the bar', () => {
// The repetitive-file case that duplicated blocks: two blocks share the same
// first+last anchor lines and their middle lines are EQUALLY similar to the
// (drifted) needle. Tier 4 must refuse rather than splice over one of them.
const content = [
'const x = {',
' total = aa;',
'};',
'const x = {',
' total = bb;',
'};',
].join('\n');
const needle = ['const x = {', ' total = ab;', '};'].join('\n');
const result = locateMatch(content, needle);
expect(result.kind).toBe('ambiguous');
});
it('refuses a below-threshold near-miss that the old 0.66 floor would have spliced', () => {
// ~0.7 similar: under the raised 0.85 floor this is now not_found, so the
// caller surfaces a correctable error instead of corrupting the file.
const content = 'const grandTotalAmount = a + b;\n';
const needle = 'const totalValue = a + b;';
const result = locateMatch(content, needle);
expect(result).toEqual({ kind: 'not_found' });
});
it('still matches a single genuine high-similarity drift uniquely', () => {
const content = 'const total = sum + tax;\n';
const needle = 'const totals = sum + tax;'; // one-char typo, ~0.96
const result = locateMatch(content, needle);
expect(result.kind).toBe('fuzzy');
const { start, end } = span(result);
expect(content.slice(start, end)).toBe('const total = sum + tax;');
});
it('requires an exact first+last line anchor for multi-line needles', () => {
// First line drifted too far to anchor → no window is scored → not_found,
// even though the middle lines are identical.
const content = ['function compute() {', ' return a + b;', ' return done;', '}'].join('\n');
const needle = ['totally different opener', ' return a + b;', '}'].join('\n');
const result = locateMatch(content, needle);
expect(result).toEqual({ kind: 'not_found' });
});
});
describe('locateMatch — edge cases', () => { describe('locateMatch — edge cases', () => {
it('returns not_found for an empty needle', () => { it('returns not_found for an empty needle', () => {
expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' }); expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' });

View File

@@ -83,6 +83,53 @@ describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () =>
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false); expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
}); });
it('re-emitted identical edits dedupe at queue and never duplicate on apply', async () => {
// Regression: the 2-3x block-stamping corruption. An anchored insert queued
// three times (a local model re-emitting the same tool call) must collapse to
// ONE pending row and apply exactly once.
await queueCreate(sql, testSessionId, null, 'dup.js', '<script>\nrender();\n', projectRoot)
.then((c) => applyOne(sql, c.id, projectRoot));
const oldStr = '<script>';
const newStr = '<script>\nconst recordFormats = ["gif"];';
const a = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const b = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const c = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
// All three calls return the SAME pending row (deduped).
expect(b.id).toBe(a.id);
expect(c.id).toBe(a.id);
await applyOne(sql, a.id, projectRoot);
let content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
// Even a fresh, separately-queued identical edit re-applied is a no-op, not a stamp.
const again = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const res = await applyOne(sql, again.id, projectRoot);
expect(res.success).toBe(true);
content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
});
it('preserves CRLF line endings on edit', async () => {
await queueCreate(sql, testSessionId, null, 'crlf.txt', 'line one\r\nline two\r\nline three\r\n', projectRoot)
.then((c) => applyOne(sql, c.id, projectRoot));
const edit = await queueEdit(sql, testSessionId, null, 'crlf.txt', 'line two', 'line TWO', projectRoot);
const res = await applyOne(sql, edit.id, projectRoot);
expect(res.success).toBe(true);
const content = await readFile(resolve(testDir, 'crlf.txt'), 'utf8');
expect(content).toBe('line one\r\nline TWO\r\nline three\r\n');
});
it('refuses an edit that matches multiple locations instead of corrupting', async () => {
await queueCreate(sql, testSessionId, null, 'ambig.js', 'x=1;\ny=2;\nx=1;\n', projectRoot)
.then((ch) => applyOne(sql, ch.id, projectRoot));
const edit = await queueEdit(sql, testSessionId, null, 'ambig.js', 'x=1;', 'x=9;', projectRoot);
const res = await applyOne(sql, edit.id, projectRoot);
expect(res.success).toBe(false);
expect(res.error).toMatch(/matches 2 locations/);
});
it('rewindOne → verify reverted', async () => { it('rewindOne → verify reverted', async () => {
// Setup: create and apply a file // Setup: create and apply a file
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot); const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { planEdit } from '../pending_changes.js';
// planEdit is the pure core of applyOne's edit splice. These tests pin the
// idempotency guards that stop the "block stamped 2-3x" corruption: applying the
// same queued edit more than once must be a no-op, never a duplicate.
describe('planEdit — normal application', () => {
it('applies a unique exact edit', () => {
const content = 'a\nfoo\nb\n';
const plan = planEdit(content, 'foo', 'bar');
expect(plan).toEqual({ kind: 'apply', updated: 'a\nbar\nb\n' });
});
it('reports ambiguous when old_string occurs more than once', () => {
const content = 'foo\nx\nfoo\n';
const plan = planEdit(content, 'foo', 'bar');
expect(plan).toEqual({ kind: 'ambiguous', count: 2 });
});
it('reports not_found when old_string is absent and new is not present', () => {
const content = 'alpha\nbeta\n';
const plan = planEdit(content, 'gamma that is clearly nowhere', 'delta');
expect(plan).toEqual({ kind: 'not_found' });
});
});
describe('planEdit — idempotency (the corruption guard)', () => {
it('treats a re-applied anchored insert as already-applied (no duplicate)', () => {
// The exact mechanism that tripled `const recordFormats` in settings.html:
// an anchored insert (old=anchor, new=anchor+block) where the anchor still
// matches uniquely after the first apply.
const oldStr = '<script>';
const newStr = '<script>\nconst recordFormats = ["gif","mp4"];';
const before = '<script>\nfunction render() {}\n</script>\n';
const first = planEdit(before, oldStr, newStr);
expect(first.kind).toBe('apply');
const after = first.kind === 'apply' ? first.updated : '';
expect((after.match(/const recordFormats/g) || []).length).toBe(1);
// Re-applying the identical edit to the already-edited content is a no-op.
const second = planEdit(after, oldStr, newStr);
expect(second).toEqual({ kind: 'noop', reason: 'already-applied' });
});
it('treats an edit whose old_string is gone but new_string is present as already-applied', () => {
const content = 'const total = sum + tax;\n';
const plan = planEdit(content, 'const subtotal = sum;', 'const total = sum + tax;');
expect(plan).toEqual({ kind: 'noop', reason: 'already-applied' });
});
it('treats a no-change splice as a noop', () => {
const content = 'a\nfoo\nb\n';
const plan = planEdit(content, 'foo', 'foo');
expect(plan).toEqual({ kind: 'noop', reason: 'identical' });
});
it('does not duplicate across three repeated applications', () => {
const oldStr = 'function f() {';
const newStr = 'function f() {\n const x = 1;';
let content = 'function f() {\n return x;\n}\n';
for (let i = 0; i < 3; i++) {
const plan = planEdit(content, oldStr, newStr);
if (plan.kind === 'apply') content = plan.updated;
}
expect((content.match(/const x = 1;/g) || []).length).toBe(1);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { evaluateTriggerRule } from '../flow-runner-decisions.js';
describe('evaluateTriggerRule', () => {
it('all_success requires all deps done', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a', 'b']), new Set(), new Set())).toBe(true);
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set())).toBe(false);
});
it('one_success fires on first completion', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(), 'one_success')).toBe(true);
expect(evaluateTriggerRule(['a', 'b'], new Set(), new Set(), new Set(), 'one_success')).toBe(false);
});
it('all_done includes skipped deps', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(['b']), new Set(), 'all_done')).toBe(true);
});
it('all_success treats excluded deps as satisfied', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(['b']))).toBe(true);
});
it('defaults to all_success', () => {
expect(evaluateTriggerRule(['a'], new Set(['a']), new Set(), new Set())).toBe(true);
expect(evaluateTriggerRule(['a'], new Set(), new Set(), new Set())).toBe(false);
});
it('returns true for empty deps', () => {
expect(evaluateTriggerRule([], new Set(), new Set(), new Set())).toBe(true);
});
});

View File

@@ -0,0 +1,191 @@
/**
* Pure, side-effect-free helpers for the Arena analyzer.
* No DB, no IO, no network — safe to unit-test directly.
*
* Covers: digest-prompt assembly, judge-prompt assembly, winner extraction
* from the judge output, the <2-survivors no-winner rule, and the
* cross-examination prompt.
*/
// ─── Shared types ─────────────────────────────────────────────────────────────
export interface ContestantDigestInput {
identity: string;
model: string;
resultMd: string;
diffPatch?: string;
benchmarkLine: string;
}
export interface ContestantDigest {
identity: string;
model: string;
digest: string;
benchmarkLine: string;
}
// ─── Digest stage ─────────────────────────────────────────────────────────────
/**
* Build the system + user prompts for the per-contestant digest call.
* The digest is a short structured summary; it keeps each call's context small
* so the downstream judge only sees digests (not raw diffs).
*/
export function buildDigestPrompt(input: ContestantDigestInput): { system: string; user: string } {
const system =
'You are an expert technical analyst evaluating the output of an AI coding or Q&A battle. ' +
'Produce a concise structured digest (under 300 words, Markdown bullet points) covering: ' +
'(1) correctness and quality, (2) completeness, (3) notable strengths, (4) notable weaknesses or issues. ' +
'Do not reference the battle or other contestants — focus only on this submission.';
const parts: string[] = [
`# Contestant: ${input.identity} / ${input.model}`,
`\nBenchmark: ${input.benchmarkLine}`,
'\n## Result\n',
input.resultMd.slice(0, 8_000),
];
if (input.diffPatch) {
parts.push('\n## Code Changes (diff)\n```diff');
parts.push(input.diffPatch.slice(0, 5_000));
parts.push('```');
}
return { system, user: parts.join('\n') };
}
// ─── Judge stage ──────────────────────────────────────────────────────────────
/**
* Build the system + user prompts for the comparative judge call.
* Receives contestant digests (NOT raw diffs) to keep context bounded.
*
* The judge output must contain a line starting with WINNER: or NO_WINNER.
* The caller extracts it with extractWinner().
*/
export function buildJudgePrompt(
originalPrompt: string,
digests: ContestantDigest[],
): { system: string; user: string } {
const canName = shouldNameWinner(digests.length);
const winnerInstruction = canName
? 'After your comparative analysis, name the best submission on its own line in this exact format:\n' +
'WINNER: <identity>/<model>\n' +
'where <identity> and <model> exactly match the heading above. No other text on that line.'
: 'Fewer than 2 contestants succeeded. Do NOT name a winner. Write the following on its own line:\nNO_WINNER';
const system =
'You are an expert judge for an AI battle. You have received digest summaries of each ' +
"contestant's work on the same task. Write a comparative analysis, then follow these instructions:\n" +
winnerInstruction;
const parts: string[] = [
'# Original Task Prompt\n',
originalPrompt.slice(0, 2_000),
'\n# Contestant Digests\n',
];
for (const d of digests) {
parts.push(`\n## ${d.identity} / ${d.model}`);
parts.push(`Benchmark: ${d.benchmarkLine}`);
parts.push(d.digest);
}
parts.push(
'\n# Instructions\nCompare the contestants and follow the winner-naming instructions above.',
);
return { system, user: parts.join('\n') };
}
// ─── No-winner rule ───────────────────────────────────────────────────────────
/**
* Returns true when enough contestants succeeded to name a winner.
* Rule: at least 2 must have produced a result. With 0 or 1 success the
* analysis must NOT name a winner (no meaningful comparison possible).
*/
export function shouldNameWinner(succeededCount: number): boolean {
return succeededCount >= 2;
}
// ─── Winner extraction ────────────────────────────────────────────────────────
/**
* Parse the judge's text output and extract the declared winner.
* Looks for a line matching: WINNER: <identity>/<model>
* Returns null when no valid winner line is found, or when the line contains
* NO_WINNER.
*
* The parse is lenient on surrounding whitespace and case for the keyword.
*/
export function extractWinner(judgeOutput: string): { identity: string; model: string } | null {
for (const line of judgeOutput.split('\n')) {
const trimmed = line.trim();
if (!trimmed.toUpperCase().startsWith('WINNER:')) continue;
const rest = trimmed.slice('WINNER:'.length).trim();
if (rest.toUpperCase() === 'NO_WINNER' || rest === '') return null;
const slashIdx = rest.indexOf('/');
if (slashIdx === -1) return null;
const identity = rest.slice(0, slashIdx).trim();
const model = rest.slice(slashIdx + 1).trim();
if (identity && model) return { identity, model };
}
return null;
}
// ─── Cross-examination stage ──────────────────────────────────────────────────
/**
* Build the system + user prompts for a cross-examination call.
* The cross-examiner sees the original prompt, contestant digests, and the
* proposed analysis, and is asked to challenge the result.
*/
export function buildCrossExamPrompt(opts: {
originalPrompt: string;
digests: ContestantDigest[];
analysisContent: string;
proposedWinner: string | null;
examinerIdentity: string;
examinerModel: string;
}): { system: string; user: string } {
const system =
`You are ${opts.examinerIdentity} (model: ${opts.examinerModel}), acting as an independent ` +
'cross-examiner in an AI battle. Your role is to critically challenge the proposed analysis ' +
'and winner, then give your own verdict. Be rigorous but fair. ' +
'End your response with your verdict on its own line:\n' +
'VERDICT: <identity>/<model> — if you agree or disagree with the proposed winner but can name one\n' +
'VERDICT: NO_WINNER — if no clear winner exists';
const parts: string[] = [
'# Original Task Prompt\n',
opts.originalPrompt.slice(0, 2_000),
'\n# Contestant Digests\n',
];
for (const d of opts.digests) {
parts.push(`\n## ${d.identity} / ${d.model}`);
parts.push(`Benchmark: ${d.benchmarkLine}`);
parts.push(d.digest);
}
parts.push('\n# Proposed Analysis\n');
parts.push(opts.analysisContent.slice(0, 5_000));
if (opts.proposedWinner) {
parts.push(`\n*(Proposed winner: ${opts.proposedWinner})*`);
} else {
parts.push('\n*(No winner was proposed — fewer than 2 contestants succeeded.)*');
}
parts.push(
'\n# Your Cross-Examination\n' +
'Challenge the analysis above, then give your independent verdict (VERDICT: … on its own line).',
);
return { system, user: parts.join('\n') };
}

View File

@@ -0,0 +1,496 @@
/**
* Arena Analyzer — pluggable seam for battle analysis and cross-examination.
*
* The Analyzer interface is the plug point: a v2 Han Orchestrator flow can
* replace the v1 two-stage digest→judge implementation without a schema change.
*
* v1 implementation uses DEFAULT_MODEL via direct llama-swap calls (arenaModelCall):
* Digest stage — one call per succeeded contestant, concurrent; produces a
* bounded summary of each result (result.md + diff.patch for
* coding, result.md for Q&A).
* Judge stage — one call with all digests + the original prompt; writes
* analysis.md, names a winner (unless < 2 succeeded), and
* updates battles.winner_contestant_id.
*
* Cross-examination:
* Local model — direct arenaModelCall to llama-swap with the chosen model.
* Cloud model — inserts a tasks row (triggers the dispatcher via pg_notify);
* polls for completion; reads output_summary as the verdict.
* In both cases the verdict is written to cross_examinations.verdict, appended
* to <resultsPath>/cross-exam.md, and a battle_updated frame is published.
*
* Never throws — all errors are caught, logged, and swallowed so the caller
* (arena-runner's onBattleComplete / onCrossExamStart) is never wedged.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { FastifyBaseLogger } from 'fastify';
import type { Config } from '../config.js';
import type { BattleType } from '@boocode/contracts/arena';
import { arenaModelCall } from './arena-model-call.js';
import {
buildDigestPrompt,
buildJudgePrompt,
buildCrossExamPrompt,
extractWinner,
shouldNameWinner,
type ContestantDigest,
} from './arena-analyzer-helpers.js';
// ─── Public interface ─────────────────────────────────────────────────────────
/** Pluggable analysis seam — swap to a Han Orchestrator flow in v2. */
export interface Analyzer {
/** Run the two-stage digest→judge analysis for a completed battle. */
analyze(battleId: string): Promise<void>;
/**
* Run a cross-examination for an already-inserted cross_examinations row.
* The result is written back to that row and a battle_updated frame is published.
*/
crossExamine(
battleId: string,
crossExamId: string,
opts: { identity: string; model: string },
): Promise<void>;
}
// ─── Internal DB row types ────────────────────────────────────────────────────
interface BattleRow {
id: string;
project_id: string;
battle_type: BattleType;
prompt: string;
status: string;
results_path: string | null;
winner_contestant_id: string | null;
}
interface ContestantRow {
id: string;
identity: string;
model: string;
lane: string;
status: string;
result_path: string | null;
duration_ms: number | null;
tokens_per_sec: number | null;
}
// ─── Factory ──────────────────────────────────────────────────────────────────
interface AnalyzerDeps {
sql: Sql;
broker: Broker;
log: FastifyBaseLogger;
config: Pick<Config, 'LLAMA_SWAP_URL' | 'DEFAULT_MODEL'>;
/** Model IDs served by local llama-swap — cross-exam routing uses this. */
localModels: ReadonlySet<string>;
}
export function createAnalyzer(deps: AnalyzerDeps): Analyzer {
const { sql, broker, log, config, localModels } = deps;
// ─── analyze ──────────────────────────────────────────────────────────────
async function analyze(battleId: string): Promise<void> {
try {
await runAnalysis(battleId);
} catch (err) {
log.error(
{ err: errMsg(err), battleId },
'arena-analyzer: analysis failed',
);
}
}
async function runAnalysis(battleId: string): Promise<void> {
const battle = await loadBattle(battleId);
if (!battle) {
log.warn({ battleId }, 'arena-analyzer: battle not found');
return;
}
const contestants = await loadContestants(battleId);
const succeeded = contestants.filter((c) => c.status === 'done' && c.result_path);
log.info(
{ battleId, total: contestants.length, succeeded: succeeded.length },
'arena-analyzer: starting analysis',
);
// Digest stage — concurrent, one call per succeeded contestant.
const digests = (
await Promise.all(succeeded.map((c) => digestContestant(battle, c)))
).filter((d): d is ContestantDigest => d !== null);
// Failed contestants are noted in the analysis even if they produced no digest.
const failedNotes = contestants
.filter((c) => c.status === 'error')
.map((c) => `- **${c.identity} / ${c.model}**: failed (no result)\n`);
// Judge stage — single call with all digests.
const { analysisText, winner } = await judgeContestants(battle, digests, failedNotes);
// Write analysis.md to the battle results folder.
const resultsPath = battle.results_path;
if (resultsPath) {
await mkdir(resultsPath, { recursive: true });
await writeFile(join(resultsPath, 'analysis.md'), analysisText, 'utf8');
}
// Resolve the winner to a contestant id and update the battle row.
let winnerId: string | null = null;
if (winner && shouldNameWinner(succeeded.length)) {
const winnerContestant = contestants.find(
(c) => c.identity === winner.identity && c.model === winner.model,
);
if (winnerContestant) {
winnerId = winnerContestant.id;
await sql`
UPDATE battles
SET winner_contestant_id = ${winnerId}, updated_at = clock_timestamp()
WHERE id = ${battleId}
`;
log.info({ battleId, winnerId, identity: winner.identity, model: winner.model }, 'arena-analyzer: winner set');
} else {
log.warn({ battleId, winner }, 'arena-analyzer: judge named a winner not found in contestants');
}
}
publishUser({
type: 'battle_updated',
battle_id: battleId,
winner_contestant_id: winnerId,
analysis_ready: true,
});
log.info({ battleId }, 'arena-analyzer: analysis complete');
}
// ─── crossExamine ─────────────────────────────────────────────────────────
async function crossExamine(
battleId: string,
crossExamId: string,
opts: { identity: string; model: string },
): Promise<void> {
try {
await runCrossExam(battleId, crossExamId, opts);
} catch (err) {
log.error(
{ err: errMsg(err), battleId, crossExamId },
'arena-analyzer: cross-exam failed',
);
}
}
async function runCrossExam(
battleId: string,
crossExamId: string,
opts: { identity: string; model: string },
): Promise<void> {
const battle = await loadBattle(battleId);
if (!battle) {
log.warn({ battleId }, 'arena-analyzer: battle not found for cross-exam');
return;
}
const contestants = await loadContestants(battleId);
// Re-read the digests (if contestants have results) for context.
const succeeded = contestants.filter((c) => c.status === 'done' && c.result_path);
const digests = (
await Promise.all(succeeded.map((c) => digestContestant(battle, c)))
).filter((d): d is ContestantDigest => d !== null);
// Read analysis.md for the proposed analysis content.
let analysisContent = '';
if (battle.results_path) {
analysisContent = await readFile(
join(battle.results_path, 'analysis.md'), 'utf8',
).catch(() => '');
}
// Resolve proposed winner label.
let proposedWinner: string | null = null;
if (battle.winner_contestant_id) {
const w = contestants.find((c) => c.id === battle.winner_contestant_id);
if (w) proposedWinner = `${w.identity}/${w.model}`;
}
const { system, user } = buildCrossExamPrompt({
originalPrompt: battle.prompt,
digests,
analysisContent,
proposedWinner,
examinerIdentity: opts.identity,
examinerModel: opts.model,
});
log.info({ battleId, crossExamId, identity: opts.identity, model: opts.model }, 'arena-analyzer: running cross-exam');
const verdict = await executeModelCall({
battleId,
projectId: battle.project_id,
identity: opts.identity,
model: opts.model,
system,
user,
});
// Persist verdict and append to cross-exam.md.
await sql`
UPDATE cross_examinations
SET verdict = ${verdict}
WHERE id = ${crossExamId}
`;
if (battle.results_path) {
const crossExamPath = join(battle.results_path, 'cross-exam.md');
const section =
`\n---\n\n# Cross-Examination by ${opts.identity} / ${opts.model}\n\n` +
`${verdict}\n`;
await writeFile(crossExamPath, section, { flag: 'a', encoding: 'utf8' });
}
publishUser({
type: 'battle_updated',
battle_id: battleId,
cross_exam_id: crossExamId,
});
log.info({ battleId, crossExamId }, 'arena-analyzer: cross-exam complete');
}
// ─── Model call routing ───────────────────────────────────────────────────
/**
* Route a one-shot model call to llama-swap (local) or the task dispatcher
* (cloud). Cloud dispatch inserts a tasks row and polls for completion.
*/
async function executeModelCall(opts: {
battleId: string;
projectId: string;
identity: string;
model: string;
system: string;
user: string;
}): Promise<string> {
const isLocal = localModels.has(opts.model) || localModels.has(`llama-swap/${opts.model}`);
if (isLocal) {
return arenaModelCall({
config,
model: opts.model,
system: opts.system,
user: opts.user,
maxTokens: 2_000,
temperature: 0.3,
});
}
// Cloud path: dispatch through the task system and poll for completion.
return executeCloudModelCall(opts);
}
async function executeCloudModelCall(opts: {
projectId: string;
identity: string;
model: string;
system: string;
user: string;
}): Promise<string> {
// The cross-exam prompt is the full input to the external agent. We embed
// the system prompt as a preamble in the user message (external agents don't
// take a separate system arg through the tasks dispatcher).
const input = `${opts.system}\n\n${opts.user}`;
// For well-known external agents, stamp the agent name so the dispatcher
// routes via PTY/ACP. For unknown identities fall back to native inference
// (agent = null → DEFAULT_MODEL text generation).
const knownAgents = new Set(['claude', 'opencode', 'qwen', 'goose']);
const agentName = knownAgents.has(opts.identity) ? opts.identity : null;
const [task] = await sql<{ id: string }[]>`
INSERT INTO tasks (project_id, input, agent, model)
VALUES (${opts.projectId}, ${input}, ${agentName}, ${opts.model})
RETURNING id
`;
const taskId = task!.id;
log.info({ taskId, identity: opts.identity, model: opts.model }, 'arena-analyzer: cloud cross-exam task dispatched');
// Poll until terminal (up to 5 minutes).
const timeoutMs = 5 * 60 * 1_000;
const pollMs = 2_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(pollMs);
const [row] = await sql<{ state: string; output_summary: string | null }[]>`
SELECT state, output_summary FROM tasks WHERE id = ${taskId}
`;
if (!row) break;
if (row.state === 'completed') return row.output_summary ?? '';
if (row.state === 'failed' || row.state === 'cancelled') {
throw new Error(`cross-exam task ${row.state}: ${row.output_summary ?? ''}`);
}
}
throw new Error(`cloud cross-exam task timed out after ${timeoutMs / 1000}s`);
}
// ─── Digest helper ────────────────────────────────────────────────────────
async function digestContestant(
battle: BattleRow,
c: ContestantRow,
): Promise<ContestantDigest | null> {
if (!c.result_path) return null;
const resultMd = await readFile(join(c.result_path, 'result.md'), 'utf8').catch(() => '');
let diffPatch: string | undefined;
if (battle.battle_type === 'coding') {
diffPatch = await readFile(join(c.result_path, 'diff.patch'), 'utf8').catch(
() => undefined,
);
}
const benchmarkLine = formatBenchmarkLine(c);
const { system, user } = buildDigestPrompt({
identity: c.identity,
model: c.model,
resultMd,
diffPatch,
benchmarkLine,
});
let digest: string;
try {
digest = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system,
user,
maxTokens: 500,
temperature: 0.3,
});
} catch (err) {
log.warn(
{ err: errMsg(err), identity: c.identity, model: c.model },
'arena-analyzer: digest call failed — skipping contestant',
);
return null;
}
return { identity: c.identity, model: c.model, digest, benchmarkLine };
}
// ─── Judge helper ─────────────────────────────────────────────────────────
async function judgeContestants(
battle: BattleRow,
digests: ContestantDigest[],
failedNotes: string[],
): Promise<{ analysisText: string; winner: { identity: string; model: string } | null }> {
const { system, user } = buildJudgePrompt(battle.prompt, digests);
let judgeOutput = '';
try {
judgeOutput = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system,
user,
maxTokens: 2_000,
temperature: 0.3,
});
} catch (err) {
log.error({ err: errMsg(err), battleId: battle.id }, 'arena-analyzer: judge call failed');
judgeOutput = '*(Judge call failed — no comparison produced.)*';
}
const winner = shouldNameWinner(digests.length) ? extractWinner(judgeOutput) : null;
const sections: string[] = [
`# Arena Analysis`,
`\n**Battle type:** ${battle.battle_type}`,
];
if (failedNotes.length > 0) {
sections.push('\n## Failed Contestants\n');
sections.push(...failedNotes);
}
if (digests.length > 0) {
sections.push('\n## Contestant Digests\n');
for (const d of digests) {
sections.push(`### ${d.identity} / ${d.model}`);
sections.push(`*Benchmark: ${d.benchmarkLine}*\n`);
sections.push(d.digest);
}
}
sections.push("\n## Judge's Verdict\n");
sections.push(judgeOutput);
if (winner) {
sections.push(`\n## Winner\n**${winner.identity} / ${winner.model}**`);
} else {
const reason =
digests.length < 2
? 'fewer than 2 contestants produced results'
: 'no clear winner identified';
sections.push(`\n## Winner\n*No winner named (${reason}).*`);
}
return { analysisText: sections.join('\n'), winner };
}
// ─── DB helpers ───────────────────────────────────────────────────────────
async function loadBattle(battleId: string): Promise<BattleRow | null> {
const [b] = await sql<BattleRow[]>`
SELECT id, project_id, battle_type, prompt, status, results_path, winner_contestant_id
FROM battles WHERE id = ${battleId}
`;
return b ?? null;
}
async function loadContestants(battleId: string): Promise<ContestantRow[]> {
return sql<ContestantRow[]>`
SELECT id, identity, model, lane, status, result_path, duration_ms, tokens_per_sec
FROM contestants WHERE battle_id = ${battleId}
ORDER BY created_at ASC
`;
}
// ─── Misc helpers ─────────────────────────────────────────────────────────
function formatBenchmarkLine(c: ContestantRow): string {
const parts: string[] = [];
if (c.duration_ms !== null) parts.push(`${c.duration_ms}ms`);
if (c.tokens_per_sec !== null) parts.push(`${c.tokens_per_sec.toFixed(1)} tok/s`);
return parts.length > 0 ? parts.join(', ') : 'no benchmark';
}
function publishUser(frame: Record<string, unknown>): void {
broker.publishUserFrame('default', frame as unknown as WsFrame);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return { analyze, crossExamine };
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

View File

@@ -0,0 +1,188 @@
/**
* Pure scheduling and classification decisions for the Arena battle-runner.
* No database, no IO. Mirrors the pattern of flow-runner-decisions.ts.
*
* Vocabulary:
* local lane — llama-swap-backed contestants, run strictly one at a time
* cloud lane — cloud-backed contestants, run all in parallel
*
* A contestant's status lifecycle:
* queued → running → done | error
*/
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
// ─── Lane classification ──────────────────────────────────────────────────────
/**
* Classify a contestant into a lane.
*
* Q&A contestants always run on the native (llama-swap) backend → local.
* Coding contestants: their MODEL is checked against the localModels set
* (all model IDs served by the local llama-swap server). This means an
* opencode or qwen contestant pointed at a local model counts as local,
* which correctly captures GPU-contention and fair benchmarking (ADR 0001).
*
* @param battleType 'coding' | 'qa'
* @param identity backend name (coding) or persona name (qa) — not used for lane logic
* @param model the contestant's model id
* @param localModels set of model IDs served by the local llama-swap server
*/
export function classifyLane(
battleType: BattleType,
_identity: string,
model: string,
localModels: ReadonlySet<string>,
): ContestantLane {
if (battleType === 'qa') return 'local';
return localModels.has(model) ? 'local' : 'cloud';
}
// ─── Local-lane queue ─────────────────────────────────────────────────────────
export interface ContestantSlot {
id: string;
lane: ContestantLane;
status: string;
}
/**
* The next queued local contestant to dispatch — the first 'queued' contestant
* in the local lane, in creation order (caller must supply rows in created_at ASC).
* Returns null when the local queue is empty or all local slots are non-queued.
*/
export function nextLocalContestant(contestants: readonly ContestantSlot[]): string | null {
for (const c of contestants) {
if (c.lane === 'local' && c.status === 'queued') return c.id;
}
return null;
}
// ─── Battle completion ────────────────────────────────────────────────────────
/**
* True when every contestant has reached a terminal state (done | error).
* Returns false for an empty list — a battle with no contestants never completes.
*/
export function isBattleComplete(contestants: readonly { status: string }[]): boolean {
if (contestants.length === 0) return false;
return contestants.every((c) => c.status === 'done' || c.status === 'error');
}
// ─── Benchmark ────────────────────────────────────────────────────────────────
export interface Benchmark {
durationMs: number;
tokensPerSec: number | null;
tokenBreakdown: TokenBreakdown | null;
}
/**
* Compute the benchmark for a contestant.
* Wall-clock duration is captured for every contestant; tokens/sec is only
* meaningful for local (llama-swap) contestants where the model has sole
* access to the GPU and the measurement is fair.
*/
export function computeBenchmark(
startedAt: Date,
endedAt: Date,
costTokens: number | null,
lane: ContestantLane,
tokenBreakdown: TokenBreakdown | null = null,
): Benchmark {
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
const tokensPerSec =
lane === 'local' && costTokens !== null && durationMs > 0
? (costTokens / durationMs) * 1000
: null;
return { durationMs, tokensPerSec, tokenBreakdown };
}
// ─── Slug / path helpers ──────────────────────────────────────────────────────
/**
* Sanitize a string for use as a directory name component.
* Lowercases, replaces non-alphanumeric runs with '-', trims leading/trailing
* dashes, and caps at 64 characters.
*/
export function sanitizeSlug(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
/**
* Build the dated battle slug used as the Arena results folder name.
* Format: YYYY-MM-DD-<battleType>-<first-8-hex-of-uuid>
* Deterministic: callers can rebuild it from (id, type, created_at) on resume.
*/
export function buildBattleSlug(battleId: string, battleType: BattleType, createdAt: Date): string {
const date = createdAt.toISOString().slice(0, 10);
const shortId = battleId.replace(/-/g, '').slice(0, 8);
return `${date}-${battleType}-${shortId}`;
}
/**
* Build the per-contestant results directory name within a battle folder.
* Format: <sanitized-identity>-<sanitized-model>
*/
export function buildContestantDir(identity: string, model: string): string {
return `${sanitizeSlug(identity)}-${sanitizeSlug(model)}`;
}
// ─── Resume reconciliation ────────────────────────────────────────────────────
export type ContestantResumeAction =
| 'keep'
| 're-dispatch'
| 'mark-done'
| 'mark-error'
| 'mark-cancelled';
export interface ContestantResumeDecision {
contestantId: string;
action: ContestantResumeAction;
}
/**
* Decide what to do with ONE contestant during startup resume.
* Mirrors reconcileResumeStep from flow-runner-decisions.ts.
*
* @param status contestants.status
* @param taskId contestants.task_id (null when not yet dispatched)
* @param taskState tasks.state for taskId, or null if the task row is absent
*/
export function reconcileContestantResume(
status: string,
taskId: string | null,
taskState: string | null,
): ContestantResumeAction {
if (status !== 'running') return 'keep';
if (!taskId || taskState === null) return 're-dispatch';
switch (taskState) {
case 'completed': return 'mark-done';
case 'failed': return 'mark-error';
case 'cancelled': return 'mark-cancelled';
case 'pending': return 'keep'; // dispatcher startup poll will run it normally
default: return 're-dispatch'; // 'running'/'blocked' — process is dead
}
}
/**
* Reconcile every contestant of an in-flight battle for startup resume.
* Returns one decision per contestant. Pure — no IO.
*/
export function reconcileContestants(
contestants: ReadonlyArray<{ contestantId: string; taskId: string | null; status: string }>,
taskStates: ReadonlyMap<string, string>,
): ContestantResumeDecision[] {
return contestants.map((c) => ({
contestantId: c.contestantId,
action: reconcileContestantResume(
c.status,
c.taskId,
c.taskId ? (taskStates.get(c.taskId) ?? null) : null,
),
}));
}

View File

@@ -0,0 +1,70 @@
/**
* One-shot model completion for the Arena analyzer.
*
* Calls the local llama-swap server directly for a single non-streaming
* completion. Used for the digest and judge stages (always DEFAULT_MODEL)
* and for local-model cross-examinations (any local model).
*
* Mirrors apps/server/src/services/task-model.ts but targets the coder's
* config shape and uses a longer timeout appropriate for analysis calls.
*/
import type { Config } from '../config.js';
const TIMEOUT_MS = 120_000;
export async function arenaModelCall(opts: {
config: Pick<Config, 'LLAMA_SWAP_URL'>;
model: string;
system: string;
user: string;
maxTokens?: number;
temperature?: number;
}): Promise<string> {
const { config, model, system, user } = opts;
const maxTokens = opts.maxTokens ?? 2_000;
const temperature = opts.temperature ?? 0.3;
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
max_tokens: maxTokens,
temperature,
stream: false,
chat_template_kwargs: { enable_thinking: false },
}),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`llama-swap responded ${res.status}: ${text.slice(0, 200)}`);
}
const data = (await res.json()) as {
choices?: Array<{
message?: { content?: string; reasoning_content?: string };
}>;
};
const choice = data.choices?.[0]?.message;
if (!choice) return '';
const content = (choice.content ?? '').trim();
if (content.length > 0) return content;
// For thinking-mode models the answer sometimes only lands in reasoning_content.
const reasoning = (choice.reasoning_content ?? '').trim();
if (reasoning.length > 0) {
const lines = reasoning.split('\n').filter((l) => l.trim().length > 0);
return lines[lines.length - 1] ?? '';
}
return '';
}

View File

@@ -0,0 +1,895 @@
/**
* Arena battle-runner — DB-backed execution engine for Arena battles.
*
* Mirrors flow-runner.ts but implements the Arena's two-lane scheduler instead
* of the Orchestrator's wave scheduler. Persists to battles/contestants tables
* (not flow_runs/flow_steps). Each contestant is dispatched as a real tasks row
* via an injected DispatchContestantFn (Phase 4 wires this to the dispatcher).
* Advances on the dispatcher's onTaskTerminal hook.
*
* Scheduling:
* - Cloud lane: all contestants start immediately, in parallel.
* - Local lane: contestants run strictly one at a time (serial queue). Only
* the first local contestant runs at start; the next is dispatched when the
* current one terminates. Both lanes run concurrently with each other.
*
* Results:
* Written to <projectRoot>/Arena/<battleSlug>/<identity>-<model>/
* Coding: result.md + diff.patch (from the contestant's worktree).
* Q&A: result.md with the text answer.
*
* Analyzer seam:
* onBattleComplete is called when all contestants are terminal. Phase 5 wires
* this to the two-stage digest→judge analyzer. A failed contestant does NOT
* abort the battle — others continue and the analyzer judges survivors.
*/
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { FastifyBaseLogger } from 'fastify';
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { diffWorktree } from './worktrees.js';
import {
buildBattleSlug,
buildContestantDir,
classifyLane,
computeBenchmark,
isBattleComplete,
nextLocalContestant,
reconcileContestants,
type ContestantResumeAction,
type ContestantSlot,
} from './arena-decisions.js';
// ─── Public types ─────────────────────────────────────────────────────────────
export interface ContestantSpec {
/** Backend name (coding) or persona name (qa). */
identity: string;
model: string;
}
export interface BattleStartOpts {
projectId: string;
battleType: BattleType;
prompt: string;
/** 26 contestants. Duplicate (identity, model) pairs are rejected by the schema UNIQUE constraint. */
contestants: ContestantSpec[];
}
/**
* Injected dispatch function — Phase 4 wires this to the real task inserter.
* Must INSERT a tasks row and return its id. The arena-runner sets the
* contestant's task_id and status after this call.
* `sessionId` is returned when already known (Q&A pre-creates the session);
* null for coding contestants whose session is created lazily by the dispatcher.
*/
export type DispatchContestantFn = (opts: {
projectId: string;
contestantId: string;
prompt: string;
identity: string;
model: string;
battleType: BattleType;
}) => Promise<{ taskId: string; sessionId: string | null }>;
/**
* Called once when every contestant in a battle has reached a terminal state.
* Phase 5 wires this to the two-stage digest→judge analyzer.
* Must never throw — the caller swallows errors.
*/
export type OnBattleComplete = (battleId: string) => void;
/**
* Called after a cross_examinations row has been inserted, with its id.
* Phase 5 wires this to the analyzer's cross-examination runner.
* Must never throw — the caller swallows errors.
*/
export type OnCrossExamStart = (opts: {
battleId: string;
crossExamId: string;
identity: string;
model: string;
}) => void;
export interface BattleRunner {
/** Start a battle: persist it + its contestants, classify lanes, dispatch initial wave. */
startBattle(opts: BattleStartOpts): Promise<{ battleId: string }>;
/**
* Wire to createDispatcher({ onTaskTerminal }). Fires when ANY task settles;
* the runner ignores tasks it doesn't own. Never throws.
*/
handleTaskTerminal(taskId: string, state: string): void;
/**
* Re-advance any battles still marked 'running' after a coder restart.
* Mirrors flow-runner's initResume (D-9). Never throws.
*/
initResume(): Promise<void>;
/**
* Cancel a running battle. Marks it and all non-terminal contestants cancelled,
* publishes frames, and returns the task_ids of in-flight contestants so the
* route can abort them via the dispatcher's cancelExternalTask.
*/
cancelBattle(battleId: string): Promise<{ cancelled: boolean; taskIds: string[] }>;
/**
* Trigger analysis for a completed (or manually re-analyzed) battle.
* Phase 5 wires this to the two-stage digest→judge analyzer. For now, calls
* the injected onBattleComplete seam directly.
*/
triggerAnalysis(battleId: string): Promise<{ triggered: boolean }>;
/**
* Start a cross-examination on a battle. Inserts a cross_examinations row and
* invokes the analyzer seam. Phase 5 fills the actual verdict logic.
*/
startCrossExam(
battleId: string,
opts: { identity: string; model: string },
): Promise<{ crossExamId: string }>;
/**
* Manually set (or clear) the winner. Validates the contestant belongs to the
* battle, updates battles.winner_contestant_id, and publishes a battle_updated
* frame so the pane reflects the override immediately.
*/
setWinner(battleId: string, winnerId: string | null): Promise<{
ok: boolean;
notFound?: boolean;
invalidContestant?: boolean;
}>;
}
// ─── Internal row shapes ──────────────────────────────────────────────────────
interface ContestantRow {
id: string;
battle_id: string;
identity: string;
model: string;
lane: ContestantLane;
task_id: string | null;
worktree_id: string | null;
status: string;
}
interface BattleRow {
id: string;
project_id: string;
battle_type: BattleType;
prompt: string;
status: string;
results_path: string | null;
created_at: Date;
}
// ─── Deps / factory ───────────────────────────────────────────────────────────
interface Deps {
sql: Sql;
broker: Broker;
log: FastifyBaseLogger;
dispatch: DispatchContestantFn;
onBattleComplete: OnBattleComplete;
/**
* Called after a cross_examinations row is inserted. Phase 5 wires this to
* the analyzer's cross-examination runner. Optional: absent → no cross-exam
* logic runs (stub behaviour for tests).
*/
onCrossExamStart?: OnCrossExamStart;
/**
* Model IDs served by the local llama-swap server. Used for lane classification:
* a contestant whose model is in this set runs in the local lane (serial, GPU-fair).
* Q&A contestants are always local regardless of this set.
* Defaults to an empty set → all coding contestants go to the cloud lane.
*/
localModels?: ReadonlySet<string>;
}
const DEFAULT_LOCAL_MODELS: ReadonlySet<string> = new Set();
export function createBattleRunner(deps: Deps): BattleRunner {
const { sql, broker, log, dispatch, onBattleComplete, onCrossExamStart } = deps;
const localModels = deps.localModels ?? DEFAULT_LOCAL_MODELS;
// Serialize local-lane advance per battle so two near-simultaneous terminal
// callbacks don't double-dispatch the next local contestant.
const advanceChain = new Map<string, Promise<void>>();
// Delta bridge: per-contestant broker unsubscribe functions.
// 'terminated' sentinel prevents a late-arriving setupDeltaBridge from
// registering a subscription that would never be cleaned up.
const deltaUnsubs = new Map<string, (() => void) | 'terminated'>();
function publishUser(frame: Record<string, unknown>): void {
broker.publishUserFrame('default', frame as unknown as WsFrame);
}
/**
* Subscribe to the contestant's inference session and forward delta frames
* to the user channel as contestant_updated{delta}. Polls for session_id
* when not immediately known (coding contestants whose session is created
* lazily by the dispatcher). Unsubscribes on termination or max retries.
*/
async function setupDeltaBridge(
battleId: string,
contestantId: string,
taskId: string,
knownSessionId: string | null,
): Promise<void> {
let sessionId = knownSessionId;
if (!sessionId) {
// Coding contestant: session_id is written by the dispatcher just before
// inference starts. Poll until it appears or the contestant terminates.
for (let i = 0; i < 50; i++) {
if (deltaUnsubs.get(contestantId) === 'terminated') return;
const [row] = await sql<{ session_id: string | null }[]>`
SELECT session_id FROM tasks WHERE id = ${taskId}
`.catch(() => []);
if (row?.session_id) { sessionId = row.session_id; break; }
await new Promise((r) => setTimeout(r, 200));
}
}
if (!sessionId) return;
if (deltaUnsubs.get(contestantId) === 'terminated') return;
const unsub = broker.subscribe(sessionId, (frame) => {
if (frame.type === 'delta') {
const deltaContent = (frame as unknown as { content?: unknown }).content;
if (typeof deltaContent === 'string') {
publishUser({
type: 'contestant_updated',
battle_id: battleId,
contestant_id: contestantId,
delta: deltaContent,
});
}
}
});
const existing = deltaUnsubs.get(contestantId);
if (existing === 'terminated') {
unsub();
} else {
deltaUnsubs.set(contestantId, unsub);
}
}
function teardownDeltaBridge(contestantId: string): void {
const entry = deltaUnsubs.get(contestantId);
if (typeof entry === 'function') {
entry();
deltaUnsubs.delete(contestantId);
} else {
deltaUnsubs.set(contestantId, 'terminated');
}
}
// ─── startBattle ────────────────────────────────────────────────────────────
async function startBattle(opts: BattleStartOpts): Promise<{ battleId: string }> {
if (opts.contestants.length < 2 || opts.contestants.length > 6) {
throw new Error(`battle requires 26 contestants; got ${opts.contestants.length}`);
}
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${opts.projectId}`;
if (!proj) throw new Error(`project not found: ${opts.projectId}`);
// Insert the battle row as 'running'; update results_path once we have the id.
const [battle] = await sql<{ id: string; created_at: Date }[]>`
INSERT INTO battles (project_id, battle_type, prompt, status)
VALUES (${opts.projectId}, ${opts.battleType}, ${opts.prompt}, 'running')
RETURNING id, created_at
`;
const battleId = battle!.id;
const battleSlug = buildBattleSlug(battleId, opts.battleType, battle!.created_at);
const resultsPath = join(proj.path, 'Arena', battleSlug);
await sql`
UPDATE battles SET results_path = ${resultsPath}, updated_at = clock_timestamp()
WHERE id = ${battleId}
`;
// Insert all contestant rows with lane classification.
const contestantRows: Array<{ id: string; identity: string; model: string; lane: ContestantLane }> = [];
for (const spec of opts.contestants) {
const lane = classifyLane(opts.battleType, spec.identity, spec.model, localModels);
const [row] = await sql<{ id: string }[]>`
INSERT INTO contestants (battle_id, identity, model, lane, status)
VALUES (${battleId}, ${spec.identity}, ${spec.model}, ${lane}, 'queued')
RETURNING id
`;
contestantRows.push({ id: row!.id, identity: spec.identity, model: spec.model, lane });
}
// Write initial manifest so the results folder is always populated.
await writeManifest(
battleId, resultsPath, opts.battleType, opts.prompt, battle!.created_at,
contestantRows.map((c) => ({ identity: c.identity, model: c.model, lane: c.lane })),
null,
).catch((err) => {
log.warn({ err: errMsg(err), battleId }, 'arena-runner: initial manifest write failed');
});
publishUser({
type: 'battle_started',
battle_id: battleId,
battle_type: opts.battleType,
prompt: opts.prompt,
contestants: contestantRows.map((c) => ({
id: c.id,
identity: c.identity,
model: c.model,
lane: c.lane,
})),
});
// Dispatch: cloud lane starts all contestants in parallel; local lane starts
// only the first queued contestant (serial queue).
let localStarted = false;
for (const c of contestantRows) {
if (c.lane === 'cloud') {
await dispatchContestant(battleId, opts.projectId, opts.battleType, opts.prompt, c);
} else if (!localStarted) {
await dispatchContestant(battleId, opts.projectId, opts.battleType, opts.prompt, c);
localStarted = true;
// remaining local contestants stay 'queued' until this one finishes
}
}
return { battleId };
}
async function dispatchContestant(
battleId: string,
projectId: string,
battleType: BattleType,
prompt: string,
c: { id: string; identity: string; model: string; lane: ContestantLane },
): Promise<void> {
const { taskId, sessionId } = await dispatch({
projectId,
contestantId: c.id,
prompt,
identity: c.identity,
model: c.model,
battleType,
});
await sql`
UPDATE contestants
SET task_id = ${taskId}, status = 'running', updated_at = clock_timestamp()
WHERE id = ${c.id}
`;
publishContestantFrame(battleId, c.id, { status: 'running' });
// Start the delta bridge in the background; unsubscribe when the contestant
// terminates (teardownDeltaBridge called in handleTaskTerminal).
void setupDeltaBridge(battleId, c.id, taskId, sessionId ?? null);
}
// ─── local-lane advance (serialized per battle) ───────────────────────────
function advanceLocalLane(battleId: string): Promise<void> {
const prev = advanceChain.get(battleId) ?? Promise.resolve();
const next = prev
.catch(() => {})
.then(() =>
advanceLocalLaneInner(battleId).catch((err) => {
log.error({ err: errMsg(err), battleId }, 'arena-runner: advanceLocalLane failed');
}),
);
advanceChain.set(battleId, next);
void next.finally(() => {
if (advanceChain.get(battleId) === next) advanceChain.delete(battleId);
});
return next;
}
async function advanceLocalLaneInner(battleId: string): Promise<void> {
const battle = await loadBattle(battleId);
if (!battle || battle.status !== 'running') return;
const contestants = await loadContestants(battleId);
const slots: ContestantSlot[] = contestants.map((c) => ({
id: c.id,
lane: c.lane,
status: c.status,
}));
// Nothing to do if the local lane is still busy.
const localRunning = slots.some((c) => c.lane === 'local' && c.status === 'running');
if (localRunning) return;
const nextId = nextLocalContestant(slots);
if (!nextId) return; // local queue is exhausted
const next = contestants.find((c) => c.id === nextId)!;
await dispatchContestant(battleId, battle.project_id, battle.battle_type, battle.prompt, {
id: next.id,
identity: next.identity,
model: next.model,
lane: next.lane,
});
}
// ─── handleTaskTerminal ───────────────────────────────────────────────────
function handleTaskTerminal(taskId: string, state: string): void {
void (async () => {
// Look up which contestant owns this task (contestants_task_id_idx).
const [row] = await sql<ContestantRow[]>`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id, status
FROM contestants WHERE task_id = ${taskId}
`;
if (!row) return; // not an arena task — ignore
if (row.status !== 'running') return; // already settled (idempotent)
const battle = await loadBattle(row.battle_id);
// Pull the task row for benchmark + output.
const [task] = await sql<{
chat_id: string | null;
started_at: Date | null;
ended_at: Date | null;
cost_tokens: number | null;
}[]>`SELECT chat_id, started_at, ended_at, cost_tokens FROM tasks WHERE id = ${taskId}`;
const endedAt = task?.ended_at ?? new Date();
if (state === 'completed') {
const startedAt = task?.started_at ?? endedAt;
const bench = computeBenchmark(startedAt, endedAt, task?.cost_tokens ?? null, row.lane);
const output = task?.chat_id ? await readChatOutput(task.chat_id) : '';
const resultPath = battle
? await writeContestantResults(battle, row, output, bench).catch((err) => {
log.warn({ err: errMsg(err), contestantId: row.id }, 'arena-runner: result write failed');
return null;
})
: null;
await sql`
UPDATE contestants
SET status = 'done',
duration_ms = ${Math.round(bench.durationMs)},
tokens_per_sec = ${bench.tokensPerSec},
cost_tokens = ${task?.cost_tokens ?? null},
result_path = ${resultPath},
updated_at = clock_timestamp()
WHERE id = ${row.id} AND status = 'running'
`;
teardownDeltaBridge(row.id);
// Check if this was the last contestant.
const allContestants = await loadContestants(row.battle_id);
const battleDone = isBattleComplete(allContestants);
publishContestantFrame(row.battle_id, row.id, {
status: 'done',
duration_ms: Math.round(bench.durationMs),
...(bench.tokensPerSec !== null ? { tokens_per_sec: bench.tokensPerSec } : {}),
...(battleDone ? { battle_status: 'completed' } : {}),
});
if (battleDone) {
await completeBattle(row.battle_id);
} else if (row.lane === 'local') {
void advanceLocalLane(row.battle_id);
}
} else {
// failed or cancelled — the contest continues; this contestant is error.
const errorMsg = state === 'cancelled' ? 'cancelled' : `task ${state}`;
await sql`
UPDATE contestants
SET status = 'error', error = ${errorMsg}, updated_at = clock_timestamp()
WHERE id = ${row.id} AND status = 'running'
`;
teardownDeltaBridge(row.id);
const allContestants = await loadContestants(row.battle_id);
const battleDone = isBattleComplete(allContestants);
publishContestantFrame(row.battle_id, row.id, {
status: 'error',
error: errorMsg,
...(battleDone ? { battle_status: 'completed' } : {}),
});
if (battleDone) {
await completeBattle(row.battle_id);
} else if (row.lane === 'local') {
void advanceLocalLane(row.battle_id);
}
}
})().catch((err) => {
log.error({ err: errMsg(err), taskId }, 'arena-runner: handleTaskTerminal failed');
});
}
// ─── battle finalization ──────────────────────────────────────────────────
async function completeBattle(battleId: string): Promise<void> {
const updated = await sql`
UPDATE battles SET status = 'completed', updated_at = clock_timestamp()
WHERE id = ${battleId} AND status = 'running'
`;
if (updated.count === 0) return; // already terminal (race guard)
log.info({ battleId }, 'arena-runner: battle completed');
// Update manifest with finished_at timestamp.
const completedBattle = await loadBattle(battleId);
if (completedBattle?.results_path) {
const contestants = await loadContestants(battleId);
await writeManifest(
battleId,
completedBattle.results_path,
completedBattle.battle_type,
completedBattle.prompt,
completedBattle.created_at,
contestants.map((c) => ({ identity: c.identity, model: c.model, lane: c.lane })),
new Date(),
).catch((err) => {
log.warn({ err: errMsg(err), battleId }, 'arena-runner: manifest update failed');
});
}
onBattleComplete(battleId);
}
// ─── manifest writer ─────────────────────────────────────────────────────
async function writeManifest(
battleId: string,
resultsPath: string,
battleType: BattleType,
prompt: string,
createdAt: Date,
contestants: Array<{ identity: string; model: string; lane: ContestantLane }>,
finishedAt: Date | null,
): Promise<void> {
await mkdir(resultsPath, { recursive: true });
const manifest = {
id: battleId,
battle_type: battleType,
prompt,
contestants,
created_at: createdAt.toISOString(),
finished_at: finishedAt?.toISOString() ?? null,
};
await writeFile(join(resultsPath, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
}
// ─── results writer ───────────────────────────────────────────────────────
async function writeContestantResults(
battle: BattleRow,
contestant: { identity: string; model: string; lane: ContestantLane; worktree_id: string | null },
output: string,
bench: { durationMs: number; tokensPerSec: number | null },
): Promise<string> {
const resultsPath = await getOrBuildResultsPath(battle);
if (!resultsPath) throw new Error('cannot resolve results path for battle ' + battle.id);
const contestantDir = buildContestantDir(contestant.identity, contestant.model);
const dir = join(resultsPath, contestantDir);
await mkdir(dir, { recursive: true });
const benchLines = [
`duration: ${bench.durationMs}ms`,
bench.tokensPerSec != null ? `tokens/sec: ${bench.tokensPerSec.toFixed(1)}` : null,
]
.filter(Boolean)
.join('\n');
const resultMd =
`# ${contestant.identity} / ${contestant.model}\n\n` +
`## Benchmark\n\n${benchLines}\n\n` +
`## Output\n\n${output}\n`;
await writeFile(join(dir, 'result.md'), resultMd, 'utf8');
if (battle.battle_type === 'coding' && contestant.worktree_id) {
const [wt] = await sql<{ path: string; base_commit: string | null }[]>`
SELECT path, base_commit FROM worktrees WHERE id = ${contestant.worktree_id}
`;
if (wt) {
const [proj] = await sql<{ path: string }[]>`
SELECT path FROM projects WHERE id = ${battle.project_id}
`;
if (proj) {
const diff = await diffWorktree(wt.path, proj.path, {
baseRef: wt.base_commit ?? undefined,
}).catch(() => '');
await writeFile(join(dir, 'diff.patch'), diff, 'utf8');
}
}
}
return dir;
}
/** Resolve or rebuild results_path for a battle (handles crash-before-UPDATE). */
async function getOrBuildResultsPath(battle: BattleRow): Promise<string | null> {
if (battle.results_path) return battle.results_path;
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${battle.project_id}`;
if (!proj) return null;
const slug = buildBattleSlug(battle.id, battle.battle_type, battle.created_at);
const resultsPath = join(proj.path, 'Arena', slug);
await sql`
UPDATE battles SET results_path = ${resultsPath}, updated_at = clock_timestamp()
WHERE id = ${battle.id}
`;
return resultsPath;
}
// ─── helpers ──────────────────────────────────────────────────────────────
async function readChatOutput(chatId: string): Promise<string> {
const [m] = await sql<{ content: string | null }[]>`
SELECT content FROM messages
WHERE chat_id = ${chatId} AND role = 'assistant'
ORDER BY created_at DESC LIMIT 1
`;
return m?.content ?? '';
}
async function loadBattle(battleId: string): Promise<BattleRow | null> {
const [b] = await sql<BattleRow[]>`
SELECT id, project_id, battle_type, prompt, status, results_path, created_at
FROM battles WHERE id = ${battleId}
`;
return b ?? null;
}
async function loadContestants(battleId: string): Promise<ContestantRow[]> {
return sql<ContestantRow[]>`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id, status
FROM contestants WHERE battle_id = ${battleId}
ORDER BY created_at ASC
`;
}
function publishContestantFrame(
battleId: string,
contestantId: string,
extra: Record<string, unknown>,
): void {
publishUser({
type: 'contestant_updated',
battle_id: battleId,
contestant_id: contestantId,
...extra,
});
}
// ─── initResume ───────────────────────────────────────────────────────────
async function initResume(): Promise<void> {
const battles = await sql<BattleRow[]>`
SELECT id, project_id, battle_type, prompt, status, results_path, created_at
FROM battles WHERE status = 'running'
`;
if (battles.length === 0) return;
log.info({ count: battles.length }, 'arena-runner: resuming in-flight battles on startup');
for (const battle of battles) {
await resumeBattle(battle).catch((err) => {
log.error({ err: errMsg(err), battleId: battle.id }, 'arena-runner: initResume failed for battle');
});
}
}
async function resumeBattle(battle: BattleRow): Promise<void> {
const contestants = await loadContestants(battle.id);
const taskIds = contestants.map((c) => c.task_id).filter((id): id is string => id !== null);
const taskStates = new Map<string, string>();
if (taskIds.length > 0) {
const tasks = await sql<{ id: string; state: string }[]>`
SELECT id, state FROM tasks WHERE id = ANY(${taskIds})
`;
for (const t of tasks) taskStates.set(t.id, t.state);
}
const decisions = reconcileContestants(
contestants.map((c) => ({ contestantId: c.id, taskId: c.task_id, status: c.status })),
taskStates,
);
for (const decision of decisions) {
if (decision.action === 'keep') continue;
const contestant = contestants.find((c) => c.id === decision.contestantId)!;
await applyResumeDecision(battle, contestant, decision.action);
}
// Re-check completion after applying decisions.
const updated = await loadContestants(battle.id);
if (isBattleComplete(updated)) {
await completeBattle(battle.id);
} else {
// Advance local lane in case a slot opened up.
void advanceLocalLane(battle.id);
}
log.info({ battleId: battle.id }, 'arena-runner: battle resumed');
}
async function applyResumeDecision(
battle: BattleRow,
contestant: ContestantRow,
action: ContestantResumeAction,
): Promise<void> {
switch (action) {
case 'keep': break;
case 'mark-done': {
const taskRow = contestant.task_id
? (await sql<{ started_at: Date | null; ended_at: Date | null; cost_tokens: number | null; chat_id: string | null }[]>`
SELECT started_at, ended_at, cost_tokens, chat_id FROM tasks WHERE id = ${contestant.task_id}`)[0]
: null;
const endedAt = taskRow?.ended_at ?? new Date();
const startedAt = taskRow?.started_at ?? endedAt;
const bench = computeBenchmark(startedAt, endedAt, taskRow?.cost_tokens ?? null, contestant.lane);
const output = taskRow?.chat_id ? await readChatOutput(taskRow.chat_id) : '';
const resultPath = battle
? await writeContestantResults(battle, contestant, output, bench).catch((err) => {
log.warn({ err: errMsg(err), contestantId: contestant.id }, 'arena-runner: resume result write failed');
return null;
})
: null;
await sql`
UPDATE contestants
SET status = 'done',
duration_ms = ${Math.round(bench.durationMs)},
tokens_per_sec = ${bench.tokensPerSec},
result_path = ${resultPath},
updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
break;
}
case 'mark-error':
await sql`
UPDATE contestants
SET status = 'error', error = 'task failed before callback',
updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
break;
case 'mark-cancelled':
await sql`
UPDATE contestants
SET status = 'error', error = 'cancelled before callback',
updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
break;
case 're-dispatch': {
const { taskId } = await dispatch({
projectId: battle.project_id,
contestantId: contestant.id,
prompt: battle.prompt,
identity: contestant.identity,
model: contestant.model,
battleType: battle.battle_type,
});
await sql`
UPDATE contestants
SET task_id = ${taskId}, updated_at = clock_timestamp()
WHERE id = ${contestant.id}
`;
log.info(
{ battleId: battle.id, contestantId: contestant.id, taskId },
'arena-runner: contestant re-dispatched on resume',
);
break;
}
}
}
// ─── cancelBattle ─────────────────────────────────────────────────────────
async function cancelBattle(battleId: string): Promise<{ cancelled: boolean; taskIds: string[] }> {
const updated = await sql`
UPDATE battles SET status = 'cancelled', updated_at = clock_timestamp()
WHERE id = ${battleId} AND status = 'running'
`;
if (updated.count === 0) return { cancelled: false, taskIds: [] };
// Mark all non-terminal contestants cancelled and collect in-flight task_ids.
const contestants = await sql<{ id: string; task_id: string | null; status: string }[]>`
SELECT id, task_id, status FROM contestants
WHERE battle_id = ${battleId} AND status NOT IN ('done', 'error')
`;
if (contestants.length > 0) {
await sql`
UPDATE contestants
SET status = 'error', error = 'battle cancelled', updated_at = clock_timestamp()
WHERE battle_id = ${battleId} AND status NOT IN ('done', 'error')
`;
for (const c of contestants) {
publishContestantFrame(battleId, c.id, {
status: 'error',
error: 'battle cancelled',
battle_status: 'cancelled',
});
}
}
const taskIds = contestants
.filter(
(c): c is typeof c & { task_id: string } =>
c.task_id !== null && c.status === 'running',
)
.map((c) => c.task_id);
log.info({ battleId }, 'arena-runner: battle cancelled by request');
return { cancelled: true, taskIds };
}
// ─── triggerAnalysis (Phase 5 seam) ──────────────────────────────────────
async function triggerAnalysis(battleId: string): Promise<{ triggered: boolean }> {
const battle = await loadBattle(battleId);
if (!battle) return { triggered: false };
log.info({ battleId }, 'arena-runner: triggerAnalysis requested');
// Calls the injected onBattleComplete seam — Phase 5 replaces this with the
// real two-stage digest→judge analyzer (see ADR 0002 + plan Phase 5).
onBattleComplete(battleId);
return { triggered: true };
}
// ─── startCrossExam (Phase 5 seam) ───────────────────────────────────────
async function startCrossExam(
battleId: string,
opts: { identity: string; model: string },
): Promise<{ crossExamId: string }> {
const [row] = await sql<{ id: string }[]>`
INSERT INTO cross_examinations (battle_id, identity, model)
VALUES (${battleId}, ${opts.identity}, ${opts.model})
RETURNING id
`;
const crossExamId = row!.id;
log.info({ battleId, crossExamId, ...opts }, 'arena-runner: cross-exam inserted, triggering analyzer');
if (onCrossExamStart) {
try {
onCrossExamStart({ battleId, crossExamId, identity: opts.identity, model: opts.model });
} catch (err) {
log.error({ err: err instanceof Error ? err.message : String(err), battleId, crossExamId }, 'arena-runner: onCrossExamStart threw');
}
}
return { crossExamId };
}
// ─── setWinner (user override) ────────────────────────────────────────────
async function setWinner(
battleId: string,
winnerId: string | null,
): Promise<{ ok: boolean; notFound?: boolean; invalidContestant?: boolean }> {
const [row] = await sql<{ id: string }[]>`SELECT id FROM battles WHERE id = ${battleId}`;
if (!row) return { ok: false, notFound: true };
if (winnerId !== null) {
const [c] = await sql<{ id: string }[]>`
SELECT id FROM contestants WHERE id = ${winnerId} AND battle_id = ${battleId}
`;
if (!c) return { ok: false, invalidContestant: true };
}
await sql`
UPDATE battles SET winner_contestant_id = ${winnerId}, updated_at = clock_timestamp()
WHERE id = ${battleId}
`;
publishUser({ type: 'battle_updated', battle_id: battleId, winner_contestant_id: winnerId });
return { ok: true };
}
return { startBattle, handleTaskTerminal, initResume, cancelBattle, triggerAnalysis, startCrossExam, setWinner };
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

View File

@@ -0,0 +1,747 @@
import { mkdir, readFile, writeFile, readdir, rm, appendFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
export const RUNS_REL = '.boo/runs';
export const DAILY_REL = '.boo/runs/daily';
export const GUIDELINES_REL = '.boo/guidelines';
export interface SessionJson {
session_id: string;
task: string;
start_time: string;
end_time?: string;
status: 'in_progress' | 'completed';
expected_record_types: string[];
}
export interface AuditTrailEntry {
timestamp: string;
record_type: string;
action_type: string;
tool?: string;
files?: string[];
detail?: string;
input?: string;
output?: string;
}
export interface IndexEntry {
id: string;
task: string;
status: string;
record_count: number;
start_time: string;
max_anomaly_level?: string;
}
export interface IndexJson {
entries: IndexEntry[];
}
export interface StartSessionResult {
sessionId: string;
contextSummary: {
recentActivity: IndexEntry[];
userCorrections: UserCorrectionRecord[];
unfinishedSessions: SessionJson[];
};
}
export interface EndSessionResult {
sessionId: string;
integrity: IntegrityCheck[];
correctionCount: number;
summaryPath: string;
}
export interface IntegrityCheck {
check: string;
passed: boolean;
detail?: string;
}
export interface RecoverResult {
level: number;
sessionId?: string;
task?: string;
recentActivity: IndexEntry[];
lastTrailEntries: AuditTrailEntry[];
userCorrections: UserCorrectionRecord[];
conclusions: string[];
dailyAnomalies: string[];
dailyBacklog: string[];
fullTrail?: AuditTrailEntry[];
anomalies?: string[];
}
export interface DailyReport {
date: string;
sections: {
taskOverview: string;
operationStats: { label: string; count: number }[];
changes: { time: string; target: string; detail: string }[];
userFeedback: { feedback: string; resolution: string; persistedTo: string }[];
anomalyAlerts: string[];
backlogTracking: string[];
integritySummary: string;
};
path: string;
}
export interface UserCorrectionRecord {
record_type: 'conversation';
action_type: 'user_correction';
priority: 'critical_for_recovery';
timestamp: string;
original_claim: string;
correction: string;
principle_extracted: string;
persisted_to: string[];
}
function runsDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), RUNS_REL);
}
function dailyDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), DAILY_REL);
}
function sessionDir(sessionId: string, basePath?: string): string {
return join(runsDir(basePath), sessionId);
}
function currentSessionPath(basePath?: string): string {
return join(runsDir(basePath), '.current_session');
}
function indexJsonPath(basePath?: string): string {
return join(runsDir(basePath), 'index.json');
}
function auditBufferPath(basePath?: string): string {
return join(runsDir(basePath), 'audit_buffer.jsonl');
}
function auditPendingPath(basePath?: string): string {
return join(runsDir(basePath), 'audit_pending.jsonl');
}
function trailPath(sessionId: string, basePath?: string): string {
return join(sessionDir(sessionId, basePath), 'audit_trail.jsonl');
}
function sessionJsonPath(sessionId: string, basePath?: string): string {
return join(sessionDir(sessionId, basePath), 'session.json');
}
function summaryPath(sessionId: string, basePath?: string): string {
return join(sessionDir(sessionId, basePath), 'session_summary.md');
}
export function generateSessionId(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
return `adhoc_${y}${m}${d}_${hh}${mm}`;
}
function isoNow(): string {
return new Date().toISOString();
}
function isoDate(d?: Date): string {
const dt = d ?? new Date();
return `${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}`;
}
function isTodayIso(iso: string): boolean {
return iso.startsWith(new Date().toISOString().slice(0, 10));
}
function tryParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function ensureDir(p: string): Promise<void> {
if (!existsSync(p)) {
await mkdir(p, { recursive: true });
}
}
async function readLines(p: string): Promise<string[]> {
try {
const content = await readFile(p, 'utf-8');
return content.split('\n').filter(Boolean);
} catch {
return [];
}
}
async function readJsonFile<T>(p: string): Promise<T | null> {
try {
const raw = await readFile(p, 'utf-8');
return tryParseJson<T>(raw);
} catch {
return null;
}
}
function appendLine(p: string, line: string): Promise<void> {
return appendFile(p, line + '\n', 'utf-8');
}
async function clearFile(p: string): Promise<void> {
try {
await writeFile(p, '', 'utf-8');
} catch {
// File may not exist
}
}
export async function getCurrentSession(basePath?: string): Promise<string | null> {
try {
const raw = await readFile(currentSessionPath(basePath), 'utf-8');
return raw.trim();
} catch {
return null;
}
}
export async function getSessionJson(sessionId: string, basePath?: string): Promise<SessionJson | null> {
return readJsonFile<SessionJson>(sessionJsonPath(sessionId, basePath));
}
export async function getIndex(basePath?: string): Promise<IndexJson | null> {
return readJsonFile<IndexJson>(indexJsonPath(basePath));
}
async function writeIndex(entries: IndexEntry[], basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await writeFile(indexJsonPath(basePath), JSON.stringify({ entries }, null, 2), 'utf-8');
}
async function appendIndex(sessionId: string, task: string, basePath?: string): Promise<void> {
const existing = await getIndex(basePath);
const entry: IndexEntry = {
id: sessionId,
task,
status: 'in_progress',
record_count: 0,
start_time: isoNow(),
};
const entries = [entry, ...(existing?.entries ?? [])].slice(0, 100);
await writeIndex(entries, basePath);
}
async function updateIndexStatus(sessionId: string, status: string, basePath?: string): Promise<void> {
const idx = await getIndex(basePath);
if (!idx) return;
for (const e of idx.entries) {
if (e.id === sessionId) {
e.status = status;
}
}
await writeIndex(idx.entries, basePath);
}
export async function startSession(task: string, basePath?: string): Promise<StartSessionResult> {
const sessionId = generateSessionId();
const sDir = sessionDir(sessionId, basePath);
await ensureDir(sDir);
const session: SessionJson = {
session_id: sessionId,
task,
start_time: isoNow(),
status: 'in_progress',
expected_record_types: ['data', 'change', 'conversation'],
};
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
await writeFile(currentSessionPath(basePath), sessionId, 'utf-8');
await appendIndex(sessionId, task, basePath);
// L0 context recovery
const idx = await getIndex(basePath);
const recentActivity = idx?.entries.slice(0, 5) ?? [];
// L2 user correction scan
const allCorrections = await scanAllTrailsForCorrections(basePath);
// Check for unfinished sessions
const unfinishedSessions = await findUnfinishedSessions(basePath);
return {
sessionId,
contextSummary: {
recentActivity,
userCorrections: allCorrections,
unfinishedSessions,
},
};
}
async function findUnfinishedSessions(basePath?: string): Promise<SessionJson[]> {
const rDir = runsDir(basePath);
if (!existsSync(rDir)) return [];
const entries = await readdir(rDir, { withFileTypes: true });
const unfinished: SessionJson[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const sess = await getSessionJson(entry.name, basePath);
if (sess && sess.status === 'in_progress') {
unfinished.push(sess);
}
}
return unfinished;
}
async function scanAllTrailsForCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
const rDir = runsDir(basePath);
if (!existsSync(rDir)) return [];
const entries = await readdir(rDir, { withFileTypes: true });
const corrections: UserCorrectionRecord[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const lines = await readLines(trailPath(entry.name, basePath));
for (const line of lines) {
const record = tryParseJson<UserCorrectionRecord>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record);
}
}
}
// Also scan audit_pending.jsonl
const pendingLines = await readLines(auditPendingPath(basePath));
for (const line of pendingLines) {
const record = tryParseJson<UserCorrectionRecord>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record);
}
}
return corrections;
}
export async function endSession(basePath?: string): Promise<EndSessionResult | null> {
const sessionId = await getCurrentSession(basePath);
if (!sessionId) return null;
const sDir = sessionDir(sessionId, basePath);
await ensureDir(sDir);
// Collect remaining buffer data
const bufferLines = await readLines(auditBufferPath(basePath));
const pendingLines = await readLines(auditPendingPath(basePath));
const allRemaining = [...bufferLines, ...pendingLines];
// Append to audit_trail.jsonl
const trail = trailPath(sessionId, basePath);
if (allRemaining.length > 0) {
await appendFile(trail, allRemaining.join('\n') + '\n', 'utf-8');
}
// Clear buffer files
await clearFile(auditBufferPath(basePath));
await clearFile(auditPendingPath(basePath));
// Read current trail for stats
const trailLines = await readLines(trail);
// Extract user_correction records
const corrections: UserCorrectionRecord[] = [];
for (const line of trailLines) {
const record = tryParseJson<UserCorrectionRecord>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record);
}
}
// Integrity checks
const integrity: IntegrityCheck[] = [
{
check: 'Audit records exist',
passed: trailLines.length > 0,
detail: trailLines.length > 0 ? `${trailLines.length} records` : 'No audit records found',
},
{
check: 'File modifications tracked',
passed: trailLines.some((l) => {
const r = tryParseJson<AuditTrailEntry>(l);
return r && (r.tool === 'Write' || r.tool === 'Edit');
}),
detail: 'Checking for Write/Edit tool entries',
},
{
check: 'User corrections persisted',
passed: corrections.every((c) => (c.persisted_to?.length ?? 0) > 0),
detail: corrections.length > 0
? `${corrections.length} corrections found, ${corrections.filter((c) => (c.persisted_to?.length ?? 0) > 0).length} persisted`
: 'No corrections to persist',
},
];
// Generate session summary
const summaryContent = generateSessionSummary(sessionId, trailLines, corrections);
const summaryFile = summaryPath(sessionId, basePath);
await writeFile(summaryFile, summaryContent, 'utf-8');
// Update session.json
const session = await getSessionJson(sessionId, basePath);
if (session) {
session.status = 'completed';
session.end_time = isoNow();
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
await updateIndexStatus(sessionId, 'completed', basePath);
}
// Update index.json record count
const idx = await getIndex(basePath);
if (idx) {
for (const e of idx.entries) {
if (e.id === sessionId) {
e.record_count = trailLines.length;
e.status = 'completed';
}
}
await writeIndex(idx.entries, basePath);
}
// Clear .current_session
try {
await rm(currentSessionPath(basePath));
} catch {
// Ok if already gone
}
return {
sessionId,
integrity,
correctionCount: corrections.length,
summaryPath: summaryFile,
};
}
function generateSessionSummary(
sessionId: string,
trailLines: string[],
corrections: UserCorrectionRecord[],
): string {
const actions: string[] = [];
const outputs: string[] = [];
for (const line of trailLines) {
const record = tryParseJson<AuditTrailEntry>(line);
if (record) {
if (record.action_type) actions.push(record.action_type);
if (record.output) outputs.push(record.output);
}
}
return [
`# Session Summary | ${sessionId}`,
'',
`## Time: ${isoNow()}`,
`## Status: completed`,
'',
'## Completed work',
...actions.map((a) => `- ${a}`),
'',
'## Key conclusions',
...outputs.map((o) => `- ${o}`),
'',
'## User corrections',
...(corrections.length > 0
? corrections.map((c) => `- ${c.original_claim}${c.correction} (${c.principle_extracted})`)
: ['- None']),
'',
].join('\n');
}
export async function recoverSession(
level: number,
specificSessionId?: string,
basePath?: string,
): Promise<RecoverResult> {
const result: RecoverResult = { level, recentActivity: [], lastTrailEntries: [], userCorrections: [], conclusions: [], dailyAnomalies: [], dailyBacklog: [] };
// L0: index summary
const idx = await getIndex(basePath);
result.recentActivity = idx?.entries.slice(0, 5) ?? [];
if (level === 0) return result;
// L1: current session + last 3 trail entries
let activeSessionId = specificSessionId ?? await getCurrentSession(basePath);
if (activeSessionId) {
result.sessionId = activeSessionId;
const session = await getSessionJson(activeSessionId, basePath);
if (session) {
result.task = session.task;
}
const trailLines = await readLines(trailPath(activeSessionId, basePath));
result.lastTrailEntries = trailLines.slice(-3).map((l) => {
const r = tryParseJson<AuditTrailEntry>(l);
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
});
}
if (level === 1) return result;
// L2: user corrections + conclusions + daily anomalies
result.userCorrections = await scanAllTrailsForCorrections(basePath);
// Extract conclusions from trail entries
const allTrailLines = await readLines(trailPath(activeSessionId ?? '', basePath));
for (const line of allTrailLines) {
const record = tryParseJson<AuditTrailEntry>(line);
if (record?.output) {
result.conclusions.push(record.output);
}
}
// Read daily reports for anomalies + backlog
const dDir = dailyDir(basePath);
if (existsSync(dDir)) {
const dailyFiles = (await readdir(dDir)).filter((f) => f.endsWith('_daily.md')).sort().reverse();
if (dailyFiles.length > 0) {
const latest = await readFile(join(dDir, dailyFiles[0]!), 'utf-8');
const anomalies = latest.match(/## (?:四|4).*?[\s\S]*?(?=##|$)/);
if (anomalies) result.dailyAnomalies.push(anomalies[0]);
const backlog = latest.match(/## (?:六|6).*?[\s\S]*?(?=##|$)/);
if (backlog) result.dailyBacklog.push(backlog[0]);
}
}
if (level === 2) return result;
// L3: full trail + pending
if (level >= 3) {
if (activeSessionId) {
const fullLines = await readLines(trailPath(activeSessionId, basePath));
result.fullTrail = fullLines.map((l) => {
const r = tryParseJson<AuditTrailEntry>(l);
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
});
}
}
return result;
}
export async function generateDailyReport(
targetDate?: string,
review?: boolean,
basePath?: string,
): Promise<DailyReport> {
const date = targetDate ?? isoDate();
const idx = await getIndex(basePath);
const rDir = runsDir(basePath);
const todayEntries = (idx?.entries ?? []).filter((e) => e.start_time.startsWith(date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8)));
let totalWriteEdit = 0;
let totalBash = 0;
let totalAuditBlocks = 0;
const changes: { time: string; target: string; detail: string }[] = [];
const feedback: { feedback: string; resolution: string; persistedTo: string }[] = [];
const anomalies: string[] = [];
for (const entry of todayEntries) {
const lines = await readLines(trailPath(entry.id, basePath));
for (const line of lines) {
const record = tryParseJson<AuditTrailEntry>(line);
if (!record) continue;
if (record.tool === 'Write' || record.tool === 'Edit') totalWriteEdit++;
if (record.tool === 'Bash') totalBash++;
if (record.action_type === 'audit_block') totalAuditBlocks++;
if (record.tool && (record.tool === 'Write' || record.tool === 'Edit') && record.files) {
changes.push({ time: record.timestamp, target: record.files.join(', '), detail: record.detail ?? '' });
}
if (record.action_type === 'user_correction') {
const uc = record as unknown as UserCorrectionRecord;
feedback.push({ feedback: uc.original_claim, resolution: uc.correction, persistedTo: (uc.persisted_to ?? []).join(', ') });
}
}
}
// Check for anomalies.json
if (existsSync(rDir)) {
const sessionDirs = await readdir(rDir, { withFileTypes: true });
for (const d of sessionDirs) {
if (!d.isDirectory()) continue;
const anomPath = join(rDir, d.name, 'anomalies.json');
if (existsSync(anomPath)) {
const anomContent = await readFile(anomPath, 'utf-8');
anomalies.push(`[${d.name}] ${anomContent.slice(0, 200)}`);
}
}
}
// Read previous day backlog
const prevDate = isoDate(new Date(Date.now() - 86400000));
let backlog: string[] = [];
const prevDailyPath = join(dailyDir(basePath), `${prevDate}_daily.md`);
if (existsSync(prevDailyPath)) {
const prevContent = await readFile(prevDailyPath, 'utf-8');
const m = prevContent.match(/## (?:六|6|明日待办)[\s\S]*?(?=##|$)/);
if (m) backlog = m[0].split('\n').filter((l) => l.trim().startsWith('-')).map((l) => l.replace(/^-\s*/, ''));
}
const reportPath = join(dailyDir(basePath), `${date}_daily.md`);
await ensureDir(dailyDir(basePath));
const sections = {
taskOverview: todayEntries.length > 0
? todayEntries.map((e) => `| ${e.id} | ${e.task} | ${e.status} | ${e.record_count} |`).join('\n')
: 'No activity',
operationStats: [
{ label: 'Write/Edit operations', count: totalWriteEdit },
{ label: 'Bash executions', count: totalBash },
{ label: 'Audit blocks', count: totalAuditBlocks },
],
changes,
userFeedback: feedback,
anomalyAlerts: anomalies,
backlogTracking: backlog,
integritySummary: [
`| All sessions have audit records | ${todayEntries.every((e) => e.record_count > 0) ? '✅' : '⚠️'} |`,
`| Audit blocks persisted | ${totalAuditBlocks > 0 ? '✅' : '⚠️'} |`,
`| User corrections persisted | ${feedback.every((f) => f.persistedTo.length > 0) ? '✅' : '⚠️'} |`,
].join('\n'),
};
const reportContent = generateDailyReportContent(date, sections);
await writeFile(reportPath, reportContent, 'utf-8');
// If review mode, also generate morning review
if (review) {
const reviewPath = join(dailyDir(basePath), `${date}_morning_review.md`);
const reviewContent = generateMorningReview(sections, date);
await writeFile(reviewPath, reviewContent, 'utf-8');
}
return { date, sections, path: reportPath };
}
function generateDailyReportContent(date: string, sections: DailyReport['sections']): string {
return [
`# Work Report | ${date}`,
'',
`> Auto-generated: ${isoNow()}`,
`> Data source: .boo/runs/index.json + session audit_trail`,
`> Coverage: ${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)} 00:00 — 23:59`,
'',
'---',
'',
'## I. Task Overview',
'',
'| Session ID | Task | Status | Records |',
'|-----------|------|--------|---------|',
sections.taskOverview,
'',
'---',
'',
'## II. Operation Stats',
'',
'| Metric | Count |',
'|--------|-------|',
...sections.operationStats.map((s) => `| ${s.label} | ${s.count} |`),
'',
'---',
'',
'## III. Change Records',
'',
...(sections.changes.length > 0
? ['| Time | Target | Detail |', '|------|--------|--------|', ...sections.changes.map((c) => `| ${c.time} | ${c.target} | ${c.detail} |`)]
: ['No changes recorded today.']),
'',
'---',
'',
'## IV. User Feedback & Corrections',
'',
...(sections.userFeedback.length > 0
? ['| Feedback | Resolution | Persisted To |', '|---------|------------|--------------|', ...sections.userFeedback.map((f) => `| ${f.feedback} | ${f.resolution} | ${f.persistedTo} |`)]
: ['None.']),
'',
'---',
'',
'## V. Anomaly Alerts',
'',
...(sections.anomalyAlerts.length > 0 ? sections.anomalyAlerts.map((a) => `- ${a}`) : ['None.']),
'',
'---',
'',
'## VI. Backlog Tracking',
'',
...(sections.backlogTracking.length > 0 ? sections.backlogTracking.map((b) => `- ${b}`) : ['None.']),
'',
'---',
'',
'## VII. Integrity Summary',
'',
'| Check | Result |',
'|-------|--------|',
sections.integritySummary,
'',
].join('\n');
}
function generateMorningReview(sections: DailyReport['sections'], date: string): string {
const anomalies = sections.anomalyAlerts;
const hasUnhandledAnomalies = anomalies.some((a) => !a.includes('resolved'));
const hasUnpersistedFeedback = sections.userFeedback.some((f) => !f.persistedTo);
const hasIncompleteBacklog = sections.backlogTracking.length > 0;
return [
`# Morning Self-Review | ${date}`,
'',
`> Generated: ${isoNow()}`,
'',
'## Self-Correction Check',
'',
`- Unresolved anomalies: ${hasUnhandledAnomalies ? '⚠️ Yes — needs attention' : '✅ None'}`,
`- Unpersisted user feedback: ${hasUnpersistedFeedback ? '⚠️ Needs documentation' : '✅ All persisted'}`,
`- Outstanding backlog: ${hasIncompleteBacklog ? '⚠️ Carry-over items' : '✅ Clean slate'}`,
'',
'## Today\'s Recommended Priorities',
'',
...(sections.backlogTracking.length > 0
? sections.backlogTracking.map((b) => `- [ ] ${b} (carry-over)`)
: []),
'- [ ] Review yesterday\'s user feedback and persist any remaining corrections',
'- [ ] Continue highest-priority task from session overview',
'',
].join('\n');
}
export async function ensureBooDirs(basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await ensureDir(dailyDir(basePath));
}
export async function writeAuditBuffer(entry: AuditTrailEntry, basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await appendLine(auditBufferPath(basePath), JSON.stringify(entry));
}
export async function writeAuditPending(entry: AuditTrailEntry, basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await appendLine(auditPendingPath(basePath), JSON.stringify(entry));
}

View File

@@ -0,0 +1,186 @@
import { readFile, writeFile, appendFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
export interface UserCorrectionRecord {
id: string;
record_type: 'conversation';
action_type: 'user_correction';
priority: 'critical_for_recovery';
timestamp: string;
original_claim: string;
correction: string;
principle_extracted: string;
persisted_to: string[];
}
const CORRECTIONS_REL = '.boo/corrections/index.json';
function correctionsDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), '.boo/corrections');
}
function correctionsPath(basePath?: string): string {
return resolve(basePath ?? process.cwd(), CORRECTIONS_REL);
}
function tryParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export interface CorrectionsIndex {
corrections: UserCorrectionRecord[];
}
async function readCorrections(basePath?: string): Promise<CorrectionsIndex> {
try {
const raw = await readFile(correctionsPath(basePath), 'utf-8');
return tryParseJson<CorrectionsIndex>(raw) ?? { corrections: [] };
} catch {
return { corrections: [] };
}
}
async function writeCorrections(idx: CorrectionsIndex, basePath?: string): Promise<void> {
const dir = correctionsDir(basePath);
if (!existsSync(dir)) {
const { mkdir } = await import('node:fs/promises');
await mkdir(dir, { recursive: true });
}
await writeFile(correctionsPath(basePath), JSON.stringify(idx, null, 2), 'utf-8');
}
let idCounter = 0;
function nextId(): string {
idCounter++;
return `uc_${Date.now()}_${idCounter}`;
}
function isoNow(): string {
return new Date().toISOString();
}
/**
* Record a user correction. Stores it in .boo/corrections/index.json
* and returns the record with the assigned id.
*/
export async function recordCorrection(
originalClaim: string,
correction: string,
principleExtracted: string,
persistedTo: string[] = [],
basePath?: string,
): Promise<UserCorrectionRecord> {
const idx = await readCorrections(basePath);
const record: UserCorrectionRecord = {
id: nextId(),
record_type: 'conversation',
action_type: 'user_correction',
priority: 'critical_for_recovery',
timestamp: isoNow(),
original_claim: originalClaim,
correction,
principle_extracted: principleExtracted,
persisted_to: persistedTo,
};
idx.corrections.push(record);
await writeCorrections(idx, basePath);
return record;
}
/**
* Scan an audit_trail.jsonl file for user_correction records.
* Returns all matching records found in the file.
*/
export async function scanForCorrections(
auditPath: string,
): Promise<UserCorrectionRecord[]> {
try {
const raw = await readFile(auditPath, 'utf-8');
const lines = raw.split('\n').filter(Boolean);
const corrections: UserCorrectionRecord[] = [];
for (const line of lines) {
const record = tryParseJson<Record<string, unknown>>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record as unknown as UserCorrectionRecord);
}
}
return corrections;
} catch {
return [];
}
}
/**
* Check if a proposed action contradicts any known user correction.
* Returns an array of contradiction warnings — empty means no contradictions.
*/
export function checkContradiction(
action: string,
corrections: UserCorrectionRecord[],
): { contradicts: boolean; warnings: { correction: UserCorrectionRecord; reason: string }[] } {
const warnings: { correction: UserCorrectionRecord; reason: string }[] = [];
for (const c of corrections) {
// Check if the action mentions the original claim's topic
const actionLower = action.toLowerCase();
const claimFragments = c.original_claim.toLowerCase().split(/\s+/).filter((w) => w.length > 4);
// If any significant word from the original claim appears in the proposed action,
// flag this as a potential contradiction
const matchingFragments = claimFragments.filter((f) => actionLower.includes(f));
if (matchingFragments.length >= 2) {
warnings.push({
correction: c,
reason: `Action "${action.slice(0, 60)}" may contradict prior correction: "${c.original_claim}" → "${c.correction}" (principle: ${c.principle_extracted})`,
});
}
}
return {
contradicts: warnings.length > 0,
warnings,
};
}
/**
* Add a file path to a correction's persisted_to array.
*/
export async function markPersisted(
correctionId: string,
filePath: string,
basePath?: string,
): Promise<UserCorrectionRecord | null> {
const idx = await readCorrections(basePath);
const record = idx.corrections.find((c) => c.id === correctionId);
if (!record) return null;
if (!record.persisted_to.includes(filePath)) {
record.persisted_to.push(filePath);
}
await writeCorrections(idx, basePath);
return record;
}
/**
* Get all stored user corrections.
*/
export async function listCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
const idx = await readCorrections(basePath);
return idx.corrections;
}
/**
* Append a correction record to an audit_trail.jsonl file (inline storage).
*/
export async function appendCorrectionToTrail(
trailPath: string,
correction: UserCorrectionRecord,
): Promise<void> {
await appendFile(trailPath, JSON.stringify(correction) + '\n', 'utf-8');
}

View File

@@ -4,7 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js'; import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { applyAll } from './pending_changes.js'; import { asPermissionMode } from './tools/types.js';
import { createCheckpoint } from './checkpoints.js'; import { createCheckpoint } from './checkpoints.js';
import { makeDcpStreamStripper } from './dcp-strip.js'; import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js'; import { dispatchViaAcp } from './acp-dispatch.js';
@@ -32,7 +32,13 @@ import {
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js'; import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
interface InferenceRunner { interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>; cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean; hasActive: (chatId: string) => boolean;
} }
@@ -310,6 +316,9 @@ export function createDispatcher(deps: Deps): {
const taskId = task.id; const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task (path A — native)'); log.info({ taskId }, 'dispatcher: starting task (path A — native)');
// Declared before try so the catch block can write it back on the task row.
let chatId: string | null = null;
try { try {
// Mark running // Mark running
await sql` await sql`
@@ -318,26 +327,29 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
// Create session + chat for this task // Session setup: reuse a pre-created session (e.g. Q&A arena contestants
// whose persona is stamped on the session via agent_id) or create a fresh one.
const model = task.model ?? config.DEFAULT_MODEL; const model = task.model ?? config.DEFAULT_MODEL;
let sessionId: string;
if (task.session_id) {
sessionId = task.session_id;
} else {
const sessionName = 'Task: ' + task.input.slice(0, 40); const sessionName = 'Task: ' + task.input.slice(0, 40);
const [session] = await sql<{ id: string }[]>` const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status) INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open') VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
RETURNING id RETURNING id
`; `;
const sessionId = session!.id; sessionId = session!.id;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
const [chat] = await sql<{ id: string }[]>` const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status) INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'Task execution', 'open') VALUES (${sessionId}, 'Task execution', 'open')
RETURNING id RETURNING id
`; `;
const chatId = chat!.id; chatId = chat!.id;
// Link task to session
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
// Create user message + streaming assistant // Create user message + streaming assistant
await sql<{ id: string }[]>` await sql<{ id: string }[]>`
@@ -352,8 +364,9 @@ export function createDispatcher(deps: Deps): {
`; `;
const assistantId = assistantMsg!.id; const assistantId = assistantMsg!.id;
// Enqueue inference // Enqueue inference — pass the native permission gate (plan/ask/bypass)
inference.enqueue(sessionId, chatId, assistantId, 'default'); // through to the write-tool context. Non-unified mode ids → undefined.
inference.enqueue(sessionId, chatId, assistantId, 'default', asPermissionMode(task.mode_id));
// Wait for inference to complete (poll message status) // Wait for inference to complete (poll message status)
const finalStatus = await waitForCompletion(assistantId); const finalStatus = await waitForCompletion(assistantId);
@@ -382,26 +395,10 @@ export function createDispatcher(deps: Deps): {
const summary = (msg?.content ?? '').slice(0, 500); const summary = (msg?.content ?? '').slice(0, 500);
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens} SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}, chat_id = ${chatId}
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)'); log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
// Bypass permission mode: auto-apply the staged edits to disk after the
// turn. Ask/Plan leave them in the pending-changes queue for review.
if (task.mode_id === 'bypass') {
try {
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${task.project_id}`;
if (proj?.path) {
const applied = await applyAll(sql, sessionId, proj.path);
log.info({ taskId, applied: applied.length }, 'dispatcher: native bypass auto-applied pending changes');
}
} catch (applyErr) {
log.warn(
{ taskId, err: applyErr instanceof Error ? applyErr.message : String(applyErr) },
'dispatcher: native bypass auto-apply failed',
);
}
}
} else { } else {
const [msg] = await sql<{ content: string | null }[]>` const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId} SELECT content FROM messages WHERE id = ${assistantId}
@@ -409,7 +406,7 @@ export function createDispatcher(deps: Deps): {
const summary = (msg?.content ?? 'Inference failed').slice(0, 500); const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}, chat_id = ${chatId}
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)'); log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
@@ -419,7 +416,7 @@ export function createDispatcher(deps: Deps): {
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)'); log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
await sql` await sql`
UPDATE tasks UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); `.catch(() => {});
} }

View File

@@ -0,0 +1,47 @@
// edit-guards-imports — detects dropped imports in edited files.
// Ported from opencode-morph-fast-apply (MIT).
export interface ImportCheckResult {
ok: boolean;
missingImports: string[];
reason?: string;
}
const IMPORT_PATTERNS = [
/^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]\s*;?$/m,
/^import\s+['"][^'"]+['"]\s*;?$/m,
/^export\s+.*\s+from\s+['"][^'"]+['"]\s*;?$/m,
/^require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?$/m,
/^import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?$/m,
];
function extractImportLines(content: string): string[] {
return content.split('\n').filter((line) =>
IMPORT_PATTERNS.some((p) => p.test(line.trim())),
);
}
export function checkDroppedImports(
original: string,
updated: string,
filePath: string,
): ImportCheckResult {
const originalImports = extractImportLines(original);
const updatedImports = extractImportLines(updated);
if (originalImports.length === 0) {
return { ok: true, missingImports: [] };
}
const missing = originalImports.filter((imp) => !updatedImports.includes(imp));
if (missing.length > 0 && originalImports.length > 0) {
return {
ok: false,
missingImports: missing,
reason: `Edit would drop ${missing.length} import(s) from ${filePath}`,
};
}
return { ok: true, missingImports: [] };
}

View File

@@ -0,0 +1,42 @@
// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage,
// and accidental import deletion during native edit_file application.
// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved.
export interface GuardResult {
ok: boolean;
reason?: string;
charLoss?: number;
lineLoss?: number;
}
const TRUNCATION_CHAR_THRESHOLD = 0.6;
const TRUNCATION_LINE_THRESHOLD = 0.5;
export function validateEditResult(
original: string,
updated: string,
filePath: string,
): GuardResult {
// Check for catastrophic content truncation
if (original.length > 0 && updated.length > 0) {
const charLoss = 1 - updated.length / original.length;
const originalLines = original.split('\n').length;
const updatedLines = updated.split('\n').length;
const lineLoss = 1 - updatedLines / originalLines;
if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) {
return {
ok: false,
reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`,
charLoss,
lineLoss,
};
}
}
return { ok: true };
}
export function formatGuardError(guard: GuardResult, filePath: string): string {
return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`;
}

View File

@@ -0,0 +1,23 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
const ARTIFACTS_ROOT = 'data/flow-artifacts';
export function getArtifactPath(flowRunId: string, stepId: string): string {
return join(ARTIFACTS_ROOT, flowRunId, `${stepId}.md`);
}
export async function writeFlowArtifact(
flowRunId: string,
stepId: string,
content: string,
): Promise<string> {
const dir = join(ARTIFACTS_ROOT, flowRunId);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const path = getArtifactPath(flowRunId, stepId);
await writeFile(path, content, 'utf8');
return path;
}

View File

@@ -22,7 +22,7 @@
* "Settled" = done skipped excluded. Only settled deps unblock a step; * "Settled" = done skipped excluded. Only settled deps unblock a step;
* an inFlight dep does NOT (the runner waits for its terminal callback). * an inFlight dep does NOT (the runner waits for its terminal callback).
*/ */
import type { Flow, Step, StepContext } from '../conductor/types.js'; import type { Flow, Step, StepContext, TriggerRule } from '../conductor/types.js';
export interface SchedulerState { export interface SchedulerState {
/** step ids that completed successfully (results available) */ /** step ids that completed successfully (results available) */
@@ -62,7 +62,7 @@ export function readySteps(flow: Flow, state: SchedulerState): Step[] {
!state.skipped.has(s.id) && !state.skipped.has(s.id) &&
!state.inFlight.has(s.id) && !state.inFlight.has(s.id) &&
!state.excluded.has(s.id) && !state.excluded.has(s.id) &&
(s.deps ?? []).every((d) => isSatisfied(state, d)), ((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
); );
} }
@@ -167,6 +167,32 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
return agent === 'qwen' && modeId === 'plan'; return agent === 'qwen' && modeId === 'plan';
} }
/**
* Evaluate a trigger rule against dependency results.
* - all_success: every dep must be done (not skipped/failed)
* - one_success: at least one dep must be done
* - all_done: every dep must be settled regardless of outcome
*/
export function evaluateTriggerRule(
deps: string[],
done: ReadonlySet<string>,
skipped: ReadonlySet<string>,
excluded: ReadonlySet<string>,
rule?: TriggerRule,
): boolean {
if (deps.length === 0) return true;
const satisfied = new Set([...done, ...skipped, ...excluded]);
switch (rule ?? 'all_success') {
case 'all_success':
return deps.every((d) => done.has(d) || skipped.has(d) || excluded.has(d));
case 'one_success':
return deps.some((d) => done.has(d));
case 'all_done':
return deps.every((d) => satisfied.has(d));
}
}
/** /**
* Reconcile every step of an in-flight run for startup resume. Returns one * Reconcile every step of an in-flight run for startup resume. Returns one
* decision per step. Pure — no IO. * decision per step. Pure — no IO.

View File

@@ -346,6 +346,20 @@ export function createFlowRunner(deps: Deps): FlowRunner {
continue; // re-evaluate — code output can unblock the next wave continue; // re-evaluate — code output can unblock the next wave
} }
// Approval gate steps: pause and wait for human decision.
const approvalReady = toRun.filter((s) => s.kind === 'approval');
if (approvalReady.length > 0) {
for (const s of approvalReady) {
await sql`
UPDATE flow_steps SET status = 'blocked', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${s.id}
`;
await appendStepEvent(sql, runId, s.id, 'paused', { reason: 'awaiting approval' });
publishStep(runId, s.id, 'blocked');
}
return;
}
// Only agent steps remain ready → dispatch the whole parallel wave, then wait. // Only agent steps remain ready → dispatch the whole parallel wave, then wait.
for (const s of toRun) { for (const s of toRun) {
await dispatchAgentStep(runId, run.project_id, model, s, ctx); await dispatchAgentStep(runId, run.project_id, model, s, ctx);
@@ -378,7 +392,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
// flow's step.run already bakes in the evidence/YAGNI contracts. // flow's step.run already bakes in the evidence/YAGNI contracts.
const persona = step.agent ? await loadPersona(step.agent) : ''; const persona = step.agent ? await loadPersona(step.agent) : '';
const taskPrompt = await step.run(ctx); const taskPrompt = await step.run(ctx);
const fullPrompt = persona ? `${persona}\n\n---\n\n${taskPrompt}` : taskPrompt; const resolvedPrompt = resolveVariables(taskPrompt, ctx.results);
const fullPrompt = persona ? `${persona}\n\n---\n\n${resolvedPrompt}` : resolvedPrompt;
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never // READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate. // user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
@@ -392,6 +407,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp() SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.id} WHERE run_id = ${runId} AND step_id = ${step.id}
`; `;
await appendStepEvent(sql, runId, step.id, 'started', { taskId: task!.id });
} }
/** /**
@@ -438,6 +454,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
WHERE run_id = ${runId} AND step_id = ${stepId} WHERE run_id = ${runId} AND step_id = ${stepId}
`; `;
} }
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
} }
// ─── run completion ───────────────────────────────────────────────────────── // ─── run completion ─────────────────────────────────────────────────────────
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
if (updated.count === 0) return; if (updated.count === 0) return;
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run'); const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
log.warn({ runId, error }, 'flow-runner: run failed'); log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error });
publishStep(runId, stepId, 'failed', { run_status: 'failed' }); publishStep(runId, stepId, 'failed', { run_status: 'failed' });
} }
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep( function publishStep(
runId: string, runId: string,
stepId: string, stepId: string,
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled', status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string }, extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void { ): void {
publishUser({ publishUser({
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function errMsg(e: unknown): string { function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e); return e instanceof Error ? e.message : String(e);
} }
// ─── Event log ───────────────────────────────────────────────────────────────
async function appendStepEvent(
sql: Sql,
runId: string,
stepId: string,
event: string,
payload?: Record<string, unknown>,
): Promise<void> {
await sql`
INSERT INTO flow_step_events (run_id, step_id, event, payload)
VALUES (${runId}, ${stepId}, ${event}, ${payload ? sql.json(payload as never) : null})
`;
}
// ─── Variable substitution ───────────────────────────────────────────────────
const VAR_PATTERN = /\$(\w+)\.output(?:\.(\w+(?:\.\w+)*))?/g;
export function resolveVariables(prompt: string, results: Record<string, string>): string {
return prompt.replace(VAR_PATTERN, (match, stepId, fieldPath) => {
const output = results[stepId];
if (!output) return match;
if (!fieldPath) return output;
try {
const lines = output.split('\n');
for (const line of lines) {
const parsed = line.match(new RegExp(`^${fieldPath}:\\s*(.+)$`, 'i'));
if (parsed) return parsed[1]!.trim();
}
} catch {
// fall through
}
return match;
});
}

View File

@@ -21,7 +21,16 @@
// punctuation to ASCII on both sides; the match is // punctuation to ASCII on both sides; the match is
// mapped back to original offsets. // mapped back to original offsets.
// 4. levenshtein — best line-window by normalized edit-distance // 4. levenshtein — best line-window by normalized edit-distance
// similarity; accepted only at >= SIMILARITY_THRESHOLD. // similarity; accepted only at >= SIMILARITY_THRESHOLD,
// anchored on an exact first+last line for multi-line
// needles, and REFUSED (ambiguous) when a second window
// scores within AMBIGUITY_EPSILON of the best. Like the
// exact/whitespace tiers, this tier fails CLOSED — it
// never splices over a merely-plausible guess, because a
// wrong-window splice corrupts the file (it leaves the
// real target intact and duplicates it). This mirrors
// opencode/cline/qwen, whose fuzzy tiers all keep the
// unique-match requirement rather than picking a winner.
// //
// Pure and dependency-free (Levenshtein is the standard iterative two-row DP), // Pure and dependency-free (Levenshtein is the standard iterative two-row DP),
// reimplemented from the general technique — no vendored source. // reimplemented from the general technique — no vendored source.
@@ -31,8 +40,31 @@ export type MatchResult =
| { kind: 'ambiguous'; count: number } | { kind: 'ambiguous'; count: number }
| { kind: 'not_found' }; | { kind: 'not_found' };
/** Levenshtein similarity floor for the final fuzzy fallback (strategy 4). */ /**
export const SIMILARITY_THRESHOLD = 0.66; * Levenshtein similarity floor for the final fuzzy fallback (strategy 4).
* 0.66 was far too low — at two-thirds similarity a structurally-wrong window
* (e.g. one of three near-identical form blocks) clears the bar and gets spliced
* over, leaving the real target intact and duplicated. Competent agents anchor
* far tighter (opencode's BlockAnchor needs an exact anchor; cline needs exact
* first+last lines). 0.85 keeps genuine quantized-model drift (a typo, an indent
* shift) while refusing a different block.
*/
export const SIMILARITY_THRESHOLD = 0.85;
/**
* If a second candidate window scores within this of the best, the match is
* ambiguous and tier 4 refuses rather than guessing — the same fail-closed
* stance the exact and whitespace tiers take on multiple hits. Repetitive files
* (the duplicate-block corruption case) produce near-tied windows; this is what
* turns that into a clean "add more context" error instead of a wrong splice.
*/
export const AMBIGUITY_EPSILON = 0.05;
/** Multi-line needles at or above this length must anchor on an exact (after
* trim + unicode-fold) first AND last line before similarity is even scored —
* the cline/opencode block-anchor rule. Below it, threshold + uniqueness alone
* guard the match. */
const ANCHOR_MIN_LINES = 3;
export function locateMatch(content: string, needle: string): MatchResult { export function locateMatch(content: string, needle: string): MatchResult {
// Empty needle has no meaningful match. // Empty needle has no meaningful match.
@@ -252,20 +284,39 @@ function locateByLevenshtein(content: string, needle: string): MatchResult | nul
const needleJoined = needleLines.map((l) => l.trim()).join('\n'); const needleJoined = needleLines.map((l) => l.trim()).join('\n');
let best = -1; // Block-anchor gate for multi-line needles: the first and last lines must match
let bestSpan: { start: number; end: number } | null = null; // exactly (after trim + unicode-fold) or the window is not even scored. This
// stops a high interior-similarity from dragging a structurally-wrong window
// over the threshold — the failure that duplicates blocks in repetitive files.
const anchored = n >= ANCHOR_MIN_LINES;
const needleFirst = canonicalize(needleLines[0]!.trim());
const needleLast = canonicalize(needleLines[n - 1]!.trim());
const scored: Array<{ score: number; start: number; end: number }> = [];
for (let i = 0; i + n <= contentLines.length; i++) { for (let i = 0; i + n <= contentLines.length; i++) {
const window = contentLines.slice(i, i + n); const window = contentLines.slice(i, i + n);
const windowJoined = window.map((l) => l.text.trim()).join('\n'); if (anchored) {
const score = similarity(windowJoined, needleJoined); const winFirst = canonicalize(window[0]!.text.trim());
if (score > best) { const winLast = canonicalize(window[n - 1]!.text.trim());
best = score; if (winFirst !== needleFirst || winLast !== needleLast) continue;
bestSpan = { start: window[0]!.start, end: window[n - 1]!.end };
} }
const windowJoined = window.map((l) => l.text.trim()).join('\n');
scored.push({
score: similarity(windowJoined, needleJoined),
start: window[0]!.start,
end: window[n - 1]!.end,
});
} }
if (bestSpan && best >= SIMILARITY_THRESHOLD) { if (scored.length === 0) return null;
return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end }; scored.sort((a, b) => b.score - a.score);
} const best = scored[0]!;
return null; if (best.score < SIMILARITY_THRESHOLD) return null;
// Uniqueness guard: refuse when a second window is within epsilon of the best.
// Fail closed (ambiguous) rather than silently splicing one of several lookalikes.
const tied = scored.filter((s) => s.score >= best.score - AMBIGUITY_EPSILON);
if (tied.length > 1) return { kind: 'ambiguous', count: tied.length };
return { kind: 'fuzzy', start: best.start, end: best.end };
} }

View File

@@ -0,0 +1,560 @@
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
export type Criticality = 'low' | 'medium' | 'high';
export interface GuidelineContent {
condition: string;
action: string | null;
description: string | null;
}
export interface Guideline {
id: string;
creationUtc: string;
content: GuidelineContent;
enabled: boolean;
tags: string[];
labels: string[];
metadata: Record<string, unknown>;
criticality: Criticality;
title: string | null;
priority: number;
}
export interface CreateGuidelineParams {
condition: string;
action?: string;
description?: string;
tags?: string[];
labels?: string[];
criticality?: Criticality;
title?: string;
priority?: number;
}
export interface UpdateGuidelineParams {
condition?: string;
action?: string | null;
description?: string | null;
enabled?: boolean;
tags?: string[];
labels?: string[];
metadata?: Record<string, unknown>;
criticality?: Criticality;
title?: string | null;
priority?: number;
}
export interface ListGuidelinesFilter {
tags?: string[];
labels?: string[];
}
interface GuidelineStoreData {
version: string;
guidelines: Guideline[];
migrationLog: string[];
}
const GUIDELINES_REL = '.boo/guidelines';
const STORE_FILE = 'guidelines.json';
const CURRENT_VERSION = 'v0.11.0';
function storeDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), GUIDELINES_REL);
}
function storePath(basePath?: string): string {
return join(storeDir(basePath), STORE_FILE);
}
function tryParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
let idCounter = 0;
function nextId(): string {
idCounter++;
return `gl_${Date.now()}_${idCounter}`;
}
function isoNow(): string {
return new Date().toISOString();
}
async function ensureStoreDir(basePath?: string): Promise<void> {
const dir = storeDir(basePath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
const MIGRATIONS: { from: string; to: string; migrate: (data: GuidelineStoreData) => GuidelineStoreData }[] = [
{
from: 'v0.1.0',
to: 'v0.2.0',
migrate: (data) => ({
...data,
version: 'v0.2.0',
guidelines: data.guidelines.map((g) => ({
...g,
enabled: g.enabled ?? true,
})),
migrationLog: [...data.migrationLog, 'v0.1.0→v0.2.0: add enabled field'],
}),
},
{
from: 'v0.2.0',
to: 'v0.3.0',
migrate: (data) => ({
...data,
version: 'v0.3.0',
migrationLog: [...data.migrationLog, 'v0.2.0→v0.3.0: remove guideline_set'],
}),
},
{
from: 'v0.3.0',
to: 'v0.4.0',
migrate: (data) => ({
...data,
version: 'v0.4.0',
guidelines: data.guidelines.map((g) => ({
...g,
content: {
...g.content,
action: g.content.action ?? null,
description: g.content.description ?? null,
},
metadata: g.metadata ?? {},
})),
migrationLog: [...data.migrationLog, 'v0.3.0→v0.4.0: add optional action, description, metadata'],
}),
},
{
from: 'v0.4.0',
to: 'v0.5.0',
migrate: (data) => ({
...data,
version: 'v0.5.0',
migrationLog: [...data.migrationLog, 'v0.4.0→v0.5.0: description as optional'],
}),
},
{
from: 'v0.5.0',
to: 'v0.6.0',
migrate: (data) => ({
...data,
version: 'v0.6.0',
guidelines: data.guidelines.map((g) => ({
...g,
criticality: g.criticality ?? 'medium',
})),
migrationLog: [...data.migrationLog, 'v0.5.0→v0.6.0: add criticality'],
}),
},
{
from: 'v0.6.0',
to: 'v0.7.0',
migrate: (data) => ({
...data,
version: 'v0.7.0',
migrationLog: [...data.migrationLog, 'v0.6.0→v0.7.0: add composition_mode (optional)'],
}),
},
{
from: 'v0.7.0',
to: 'v0.8.0',
migrate: (data) => ({
...data,
version: 'v0.8.0',
migrationLog: [...data.migrationLog, 'v0.7.0→v0.8.0: add track (default true)'],
}),
},
{
from: 'v0.8.0',
to: 'v0.9.0',
migrate: (data) => ({
...data,
version: 'v0.9.0',
guidelines: data.guidelines.map((g) => ({
...g,
labels: g.labels ?? [],
})),
migrationLog: [...data.migrationLog, 'v0.8.0→v0.9.0: add labels'],
}),
},
{
from: 'v0.9.0',
to: 'v0.10.0',
migrate: (data) => ({
...data,
version: 'v0.10.0',
guidelines: data.guidelines.map((g) => ({
...g,
priority: g.priority ?? 0,
})),
migrationLog: [...data.migrationLog, 'v0.9.0→v0.10.0: add priority'],
}),
},
{
from: 'v0.10.0',
to: 'v0.11.0',
migrate: (data) => ({
...data,
version: 'v0.11.0',
guidelines: data.guidelines.map((g) => ({
...g,
title: g.title ?? null,
})),
migrationLog: [...data.migrationLog, 'v0.10.0→v0.11.0: add title'],
}),
},
];
function applyMigrations(data: GuidelineStoreData): GuidelineStoreData {
let current = { ...data };
for (const migration of MIGRATIONS) {
if (current.version === migration.from) {
current = migration.migrate(current);
}
}
return current;
}
async function readStore(basePath?: string): Promise<GuidelineStoreData> {
try {
const raw = await readFile(storePath(basePath), 'utf-8');
const data = tryParseJson<GuidelineStoreData>(raw);
if (!data) return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
if (data.version !== CURRENT_VERSION) {
return applyMigrations(data);
}
return data;
} catch {
return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
}
}
async function writeStore(data: GuidelineStoreData, basePath?: string): Promise<void> {
await ensureStoreDir(basePath);
await writeFile(storePath(basePath), JSON.stringify(data, null, 2), 'utf-8');
}
export async function createGuideline(
params: CreateGuidelineParams,
basePath?: string,
): Promise<Guideline> {
const data = await readStore(basePath);
const guideline: Guideline = {
id: nextId(),
creationUtc: isoNow(),
content: {
condition: params.condition,
action: params.action ?? null,
description: params.description ?? null,
},
enabled: true,
tags: params.tags ?? [],
labels: params.labels ?? [],
metadata: {},
criticality: params.criticality ?? 'medium',
title: params.title ?? null,
priority: params.priority ?? 0,
};
data.guidelines.push(guideline);
await writeStore(data, basePath);
return guideline;
}
export async function listGuidelines(
filter?: ListGuidelinesFilter,
basePath?: string,
): Promise<Guideline[]> {
const data = await readStore(basePath);
let results = data.guidelines;
if (filter?.tags && filter.tags.length > 0) {
results = results.filter((g) => filter.tags!.some((tag) => g.tags.includes(tag)));
}
if (filter?.labels && filter.labels.length > 0) {
results = results.filter((g) => filter.labels!.every((label) => g.labels.includes(label)));
}
return results;
}
export async function readGuideline(
id: string,
basePath?: string,
): Promise<Guideline | null> {
const data = await readStore(basePath);
return data.guidelines.find((g) => g.id === id) ?? null;
}
export async function updateGuideline(
id: string,
params: UpdateGuidelineParams,
basePath?: string,
): Promise<Guideline | null> {
const data = await readStore(basePath);
const idx = data.guidelines.findIndex((g) => g.id === id);
if (idx === -1) return null;
const existing = data.guidelines[idx]!;
if (params.condition !== undefined) existing.content.condition = params.condition;
if (params.action !== undefined) existing.content.action = params.action;
if (params.description !== undefined) existing.content.description = params.description;
if (params.enabled !== undefined) existing.enabled = params.enabled;
if (params.tags !== undefined) existing.tags = params.tags;
if (params.labels !== undefined) existing.labels = params.labels;
if (params.metadata !== undefined) existing.metadata = params.metadata;
if (params.criticality !== undefined) existing.criticality = params.criticality;
if (params.title !== undefined) existing.title = params.title;
if (params.priority !== undefined) existing.priority = params.priority;
data.guidelines[idx] = existing;
await writeStore(data, basePath);
return existing;
}
export async function deleteGuideline(
id: string,
basePath?: string,
): Promise<boolean> {
const data = await readStore(basePath);
const lenBefore = data.guidelines.length;
data.guidelines = data.guidelines.filter((g) => g.id !== id);
if (data.guidelines.length === lenBefore) return false;
await writeStore(data, basePath);
return true;
}
export async function findGuideline(
content: { condition: string; action?: string },
basePath?: string,
): Promise<Guideline | null> {
const data = await readStore(basePath);
return data.guidelines.find((g) => {
const condMatch = g.content.condition === content.condition;
if (!condMatch) return false;
if (content.action !== undefined) {
return g.content.action === content.action;
}
return true;
}) ?? null;
}
// ─── Journey → Guideline projection (port of Parlant's JourneyGuidelineProjection) ───
export interface JourneyNode {
id: string;
action: string;
description?: string;
}
export interface JourneyEdge {
sourceNodeId: string;
targetNodeId: string;
condition: string;
}
export interface Journey {
id: string;
name: string;
nodes: JourneyNode[];
edges: JourneyEdge[];
}
export interface JourneyProjectionResult {
guidelines: Guideline[];
followUps: Map<string, string[]>;
}
/**
* Project a Journey into an ordered list of Guidelines.
* DFS traversal from root nodes: each (edge, node) pair → one Guideline.
* Edge condition becomes guideline condition, node action becomes guideline action.
* BFS queue avoids infinite loops via visited set.
*/
export function projectJourneyToGuidelines(
journey: Journey,
baseTags?: string[],
): JourneyProjectionResult {
const guidelines: Guideline[] = [];
const followUps = new Map<string, string[]>();
const visited = new Set<string>();
const nodeMap = new Map<string, JourneyNode>();
for (const node of journey.nodes) {
nodeMap.set(node.id, node);
}
// Build adjacency list
const adjacency = new Map<string, JourneyEdge[]>();
for (const edge of journey.edges) {
const list = adjacency.get(edge.sourceNodeId) ?? [];
list.push(edge);
adjacency.set(edge.sourceNodeId, list);
}
// Find root nodes (no incoming edges)
const hasIncoming = new Set<string>();
for (const edge of journey.edges) {
hasIncoming.add(edge.targetNodeId);
}
const roots = journey.nodes
.filter((n) => !hasIncoming.has(n.id))
.map((n) => n.id);
const queue: { nodeId: string; fromEdge?: JourneyEdge }[] = [];
// BFS from roots
for (const rootId of roots) {
if (!visited.has(rootId)) {
queue.push({ nodeId: rootId });
}
}
while (queue.length > 0) {
const { nodeId, fromEdge } = queue.shift()!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (!node) continue;
// If we arrived via an edge, create a guideline
if (fromEdge) {
const guideline = createGuidelineFromJourneyEdge(
journey,
node,
fromEdge,
baseTags,
);
guidelines.push(guideline);
// Track follow-ups
const sourceId = findGuidelineForNode(fromEdge.sourceNodeId, journey.nodes);
if (sourceId) {
const existing = followUps.get(sourceId) ?? [];
existing.push(guideline.id);
followUps.set(sourceId, existing);
}
}
// Enqueue downstream nodes
const outgoingEdges = adjacency.get(nodeId) ?? [];
for (const edge of outgoingEdges) {
if (!visited.has(edge.targetNodeId)) {
queue.push({ nodeId: edge.targetNodeId, fromEdge: edge });
}
}
}
return { guidelines, followUps };
}
function findGuidelineForNode(nodeId: string, nodes: JourneyNode[]): string | null {
// Placeholder: in a full implementation, map nodeId → guideline id
// For now return null — downstream consumers handle missing follow-ups gracefully
return null;
}
function createGuidelineFromJourneyEdge(
journey: Journey,
targetNode: JourneyNode,
edge: JourneyEdge,
baseTags?: string[],
): Guideline {
const now = isoNow();
return {
id: nextId(),
creationUtc: now,
content: {
condition: edge.condition,
action: targetNode.action,
description: targetNode.description ?? null,
},
enabled: true,
tags: baseTags ?? [journey.name],
labels: [],
metadata: {
journey_id: journey.id,
journey_node: targetNode.id,
source_edge_id: `${edge.sourceNodeId}${edge.targetNodeId}`,
},
criticality: 'medium',
title: targetNode.description
? `[${journey.name}] ${targetNode.description.slice(0, 60)}`
: null,
priority: 0,
};
}
// ─── Backtrack detection ───
export interface BacktrackCheckInput {
journeyId: string;
currentNodeId: string;
previousNodeId: string;
}
export interface BacktrackCheckResult {
journeyId: string;
currentNodeId: string;
previousNodeId: string;
isBacktrack: boolean;
recommendation: string | null;
}
/**
* Check if moving from previousNodeId to currentNodeId is a backtrack
* (regression to an already-visited node not on a forward path).
*/
export function checkBacktrack(
input: BacktrackCheckInput,
journey: Journey,
): BacktrackCheckResult {
const adjacency = new Map<string, string[]>();
for (const edge of journey.edges) {
const list = adjacency.get(edge.sourceNodeId) ?? [];
list.push(edge.targetNodeId);
adjacency.set(edge.sourceNodeId, list);
}
// Find forward reachable nodes from the current node
const forwardReachable = new Set<string>();
const bfsQueue = [input.currentNodeId];
while (bfsQueue.length > 0) {
const nid = bfsQueue.shift()!;
if (forwardReachable.has(nid)) continue;
forwardReachable.add(nid);
const next = adjacency.get(nid) ?? [];
for (const n of next) {
if (!forwardReachable.has(n)) bfsQueue.push(n);
}
}
const isBacktrack = input.previousNodeId !== input.currentNodeId
&& !forwardReachable.has(input.previousNodeId)
&& input.previousNodeId !== input.currentNodeId;
return {
journeyId: input.journeyId,
currentNodeId: input.currentNodeId,
previousNodeId: input.previousNodeId,
isBacktrack,
recommendation: isBacktrack
? `Revisiting node "${input.previousNodeId}" after "${input.currentNodeId}" — this may indicate a regression. Consider whether the forward path from "${input.currentNodeId}" is the correct one.`
: null,
};
}

View File

@@ -0,0 +1,75 @@
import { createInterface } from 'node:readline';
import type { Readable, Writable } from 'node:stream';
interface RpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params?: unknown;
}
interface RpcResponse {
jsonrpc: '2.0';
id: number;
result?: unknown;
error?: { code: number; message: string };
}
export class LspClient {
private nextId = 1;
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
private buffer = '';
constructor(
private stdin: Writable,
private stdout: Readable,
) {
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
rl.on('line', (line) => this.handleLine(line));
}
private handleLine(line: string): void {
this.buffer += line + '\n';
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
if (!match || !match[1]) return;
const len = parseInt(match[1], 10);
const headerEnd = match.index! + match[0].length;
const body = this.buffer.slice(headerEnd, headerEnd + len);
if (body.length < len) return;
this.buffer = this.buffer.slice(headerEnd + len);
try {
const msg: RpcResponse = JSON.parse(body);
const cb = this.pending.get(msg.id);
if (cb) {
this.pending.delete(msg.id);
cb.resolve(msg);
}
} catch {
// Malformed JSON, ignore
}
}
async request(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
const body = JSON.stringify(req);
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
return new Promise((resolve, reject) => {
this.pending.set(id, {
resolve: (resp) => {
if (resp.error) reject(new Error(resp.error.message));
else resolve(resp.result);
},
reject,
});
this.stdin.write(header + body);
});
}
async notify(method: string, params?: unknown): Promise<void> {
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
this.stdin.write(header + body);
}
}

View File

@@ -0,0 +1,19 @@
export interface LspServerConfig {
command: string;
args: string[];
rootPatterns: string[];
}
const TS_CONFIG: LspServerConfig = {
command: 'typescript-language-server',
args: ['--stdio'],
rootPatterns: ['package.json', 'tsconfig.json'],
};
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
export function getServerConfig(filePath: string): LspServerConfig | null {
const ext = filePath.split('.').pop()?.toLowerCase();
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
return null;
}

View File

@@ -0,0 +1,86 @@
import type { LspClient } from './client.js';
import type { Diagnostic, Location } from './types.js';
function fileUri(filePath: string): string {
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
}
export async function openDocument(
client: LspClient,
filePath: string,
content: string,
version: number = 1,
): Promise<void> {
const uri = fileUri(filePath);
await client.notify('textDocument/didOpen', {
textDocument: { uri, languageId: 'typescript', version, text: content },
});
}
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
await client.notify('textDocument/didClose', {
textDocument: { uri: fileUri(filePath) },
});
}
export async function getDiagnostics(
client: LspClient,
filePath: string,
content: string,
): Promise<Diagnostic[]> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/diagnostic', {
textDocument: { uri },
});
await closeDocument(client, filePath);
const diagnostics: Diagnostic[] = [];
if (result?.diagnostics) {
for (const d of result.diagnostics) {
diagnostics.push({
range: d.range,
severity: d.severity ?? 1,
message: d.message,
source: d.source,
});
}
}
return diagnostics;
}
export async function gotoDefinition(
client: LspClient,
filePath: string,
content: string,
line: number,
character: number,
): Promise<Location | null> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/definition', {
textDocument: { uri },
position: { line, character },
});
await closeDocument(client, filePath);
if (!result) return null;
const loc = Array.isArray(result) ? result[0] : result;
return loc ? { uri: loc.uri, range: loc.range } : null;
}
export async function findReferences(
client: LspClient,
filePath: string,
content: string,
line: number,
character: number,
): Promise<Location[]> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/references', {
textDocument: { uri },
position: { line, character },
context: { includeDeclaration: true },
});
await closeDocument(client, filePath);
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
}

View File

@@ -0,0 +1,119 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { LspClient } from './client.js';
import { getServerConfig } from './config.js';
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const SWEEP_INTERVAL_MS = 30_000;
interface LspInstance {
client: LspClient;
proc: ChildProcess;
lastUsed: number;
timer: ReturnType<typeof setTimeout>;
}
export class LspServerManager {
private instances = new Map<string, LspInstance>();
private sweepTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.startSweeper();
}
private startSweeper(): void {
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
this.sweepTimer.unref?.();
}
private findProjectRoot(filePath: string): string | null {
let dir = filePath;
const config = getServerConfig(filePath);
if (!config) return null;
while (true) {
for (const pattern of config.rootPatterns) {
if (existsSync(join(dir, pattern))) return dir;
}
const parent = join(dir, '..');
if (parent === dir) return dir;
dir = parent;
}
}
async getClient(filePath: string): Promise<LspClient | null> {
const config = getServerConfig(filePath);
if (!config) return null;
const projectRoot = this.findProjectRoot(filePath);
if (!projectRoot) return null;
const existing = this.instances.get(projectRoot);
if (existing) {
existing.lastUsed = Date.now();
clearTimeout(existing.timer);
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
existing.timer.unref?.();
return existing.client;
}
return this.spawn(projectRoot, config.command, config.args);
}
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
const client = new LspClient(proc.stdin!, proc.stdout!);
await client.request('initialize', {
processId: process.pid,
rootUri: `file://${projectRoot}`,
capabilities: {
textDocument: {
diagnostic: { dynamicRegistration: false },
definition: { dynamicRegistration: false },
references: { dynamicRegistration: false },
},
},
});
await client.notify('initialized', {});
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
timer.unref?.();
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
proc.on('exit', () => this.instances.delete(projectRoot));
return client;
}
private kill(projectRoot: string): void {
const inst = this.instances.get(projectRoot);
if (!inst) return;
this.instances.delete(projectRoot);
inst.proc.kill('SIGTERM');
setTimeout(() => {
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
}, 5000);
}
private sweep(): void {
const now = Date.now();
for (const [root, inst] of this.instances) {
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
this.kill(root);
}
}
}
shutdown(): void {
if (this.sweepTimer) clearInterval(this.sweepTimer);
for (const root of [...this.instances.keys()]) {
this.kill(root);
}
}
getActiveCount(): number {
return this.instances.size;
}
}
export const lspManager = new LspServerManager();

View File

@@ -0,0 +1,28 @@
export interface Position {
line: number;
character: number;
}
export interface Range {
start: Position;
end: Position;
}
export interface Location {
uri: string;
range: Range;
}
export interface Diagnostic {
range: Range;
severity: number;
message: string;
source?: string;
}
export interface TextDocumentItem {
uri: string;
languageId: string;
version: number;
text: string;
}

View File

@@ -1,9 +1,120 @@
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname, join, basename } from 'node:path';
import { randomBytes } from 'node:crypto';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.js'; import { resolveWritePath } from './write_guard.js';
import { locateMatch } from './fuzzy-match.js'; import { locateMatch } from './fuzzy-match.js';
/**
* Write a file atomically: stage to a sibling temp file, then rename over the
* target. rename(2) on the same filesystem is atomic, so a crash mid-write can
* never leave a half-written (truncated/corrupt) source file — readers see
* either the old content or the complete new content. The temp lives in the same
* directory to guarantee a same-filesystem rename.
*
* Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target;
* a bare rename would REPLACE the link with a regular file. We realpath an
* existing target first so the rename lands on the real file and the link
* survives — preserving the prior follow-through behavior. A missing target
* (create, or a broken link) just writes the literal path.
*/
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
let target = filePath;
try {
target = await realpath(filePath);
} catch {
// ENOENT (new file) or broken link — write the literal path.
}
const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`);
await writeFile(tmp, content, 'utf8');
try {
await rename(tmp, target);
} catch (err) {
await unlink(tmp).catch(() => {});
throw err;
}
}
/** Detect a file's dominant line ending so an edit can preserve it. */
function detectEol(text: string): '\r\n' | '\n' {
return text.includes('\r\n') ? '\r\n' : '\n';
}
/**
* Serialize the read-modify-write of a single file so two concurrent applies
* (e.g. two chat tabs sharing one worktree, or a Bypass write racing an
* apply_pending) can't lose an update. In-process keying is sufficient —
* BooCoder is a single Fastify process. One Map entry per distinct path.
*/
const fileLocks = new Map<string, Promise<void>>();
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const prev = fileLocks.get(filePath) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((r) => { release = r; });
fileLocks.set(filePath, prev.then(() => current));
await prev.catch(() => {});
try {
return await fn();
} finally {
release();
}
}
// --- Edit-apply planning (pure, unit-tested) ---------------------------------
/**
* Decision for applying one queued edit to a file's current content. Pulled out
* of `applyOne` so the splice — the part that actually corrupted files — is pure
* and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep
* their matchers fail-closed and idempotent.
*/
export type EditPlan =
| { kind: 'apply'; updated: string }
| { kind: 'noop'; reason: 'identical' | 'already-applied' }
| { kind: 'ambiguous'; count: number }
| { kind: 'not_found' };
/**
* Decide how (or whether) to apply an `old → new` edit to `content`.
*
* Idempotency is the whole point here: a queued edit can legitimately be
* re-applied (a local model re-emits the same tool call; a turn is retried; the
* same change sits in the queue twice). A naive splice stamps the new text again
* each time — the 23× block duplication. Two guards make re-application a no-op:
*
* - already-applied (anchored insert): when `new` is `old` + an appended block
* (`old="anchor"`, `new="anchor\n<block>"`), `old` still matches uniquely after
* the first apply, so a second apply would duplicate `<block>`. If the full
* `new` text is already present at the match site, the edit is already applied.
* - already-applied (old gone): if `old` can't be located but `new` is already
* in the file, the change landed on a prior pass — treat as a no-op, not an error.
* - identical: the splice would not change the file.
*
* Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a
* correctable error instead of writing a guess.
*/
export function planEdit(content: string, oldStr: string, newStr: string): EditPlan {
const match = locateMatch(content, oldStr);
if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count };
if (match.kind === 'not_found') {
if (newStr.length > 0 && content.includes(newStr)) {
return { kind: 'noop', reason: 'already-applied' };
}
return { kind: 'not_found' };
}
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
// No-change splice first (covers old === new), then the anchored re-stamp guard:
// the full replacement already sits at the match site (re-emitted anchored insert).
if (updated === content) return { kind: 'noop', reason: 'identical' };
if (content.slice(match.start, match.start + newStr.length) === newStr) {
return { kind: 'noop', reason: 'already-applied' };
}
return { kind: 'apply', updated };
}
// --- Types ------------------------------------------------------------------- // --- Types -------------------------------------------------------------------
export interface PendingChange { export interface PendingChange {
@@ -47,6 +158,13 @@ export async function queueEdit(
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const diff = JSON.stringify({ old: oldString, new: newString }); const diff = JSON.stringify({ old: oldString, new: newString });
// Idempotent queue: collapse an identical edit that is still pending. Local
// quantized models re-emit the same edit_file call within a turn, and a retried
// turn re-queues — each duplicate row would apply and stamp another copy. One
// pending row per (session, file, operation, diff) is enough.
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff);
if (existing) return existing;
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
@@ -55,6 +173,28 @@ export async function queueEdit(
return row!; return row!;
} }
/** Return an identical still-pending change for this (session, file, op, diff),
* or undefined. Used to keep the queue idempotent against re-emitted edits. */
async function findPendingDuplicate(
sql: Sql,
sessionId: string,
resolvedPath: string,
operation: 'create' | 'edit' | 'delete',
diff: string,
): Promise<PendingChange | undefined> {
const [row] = await sql<PendingChange[]>`
SELECT * FROM pending_changes
WHERE session_id = ${sessionId}
AND file_path = ${resolvedPath}
AND operation = ${operation}
AND diff = ${diff}
AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
`;
return row;
}
export async function queueCreate( export async function queueCreate(
sql: Sql, sql: Sql,
sessionId: string, sessionId: string,
@@ -68,6 +208,9 @@ export async function queueCreate(
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
if (existing) return existing;
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
@@ -87,6 +230,9 @@ export async function queueDelete(
): Promise<PendingChange> { ): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath); const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
if (existing) return existing;
const [row] = await sql<PendingChange[]>` const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent}) VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
@@ -110,31 +256,42 @@ export async function applyOne(
} }
try { try {
return await withFileLock(change.file_path, async () => {
// Re-validate path in case projectRoot has shifted // Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path); resolveWritePath(projectRoot, change.file_path);
switch (change.operation) { switch (change.operation) {
case 'create': { case 'create': {
await mkdir(dirname(change.file_path), { recursive: true }); await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8'); await writeFileAtomic(change.file_path, change.diff);
break; break;
} }
case 'edit': { case 'edit': {
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const content = await readFile(change.file_path, 'utf8'); const raw = await readFile(change.file_path, 'utf8');
const match = locateMatch(content, oldStr); // Normalize to LF for matching, then write back in the file's native EOL
if (match.kind === 'ambiguous') { // so an LF-emitting model doesn't leave a CRLF file with mixed endings.
const eol = detectEol(raw);
const toLf = (t: string) => t.replaceAll('\r\n', '\n');
const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr));
if (plan.kind === 'ambiguous') {
throw new Error( throw new Error(
`old_string matches ${match.count} locations — add surrounding context to disambiguate`, `old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
); );
} }
if (match.kind === 'not_found') { if (plan.kind === 'not_found') {
throw new Error( throw new Error(
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued', 'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
); );
} }
const updated = content.slice(0, match.start) + newStr + content.slice(match.end); if (plan.kind === 'apply') {
await writeFile(change.file_path, updated, 'utf8'); const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
await writeFileAtomic(change.file_path, out);
} else {
// noop: the edit is already applied (re-emitted / retried) or a no-change.
// Mark it applied without rewriting so it can't stamp a duplicate.
console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`);
}
break; break;
} }
case 'delete': { case 'delete': {
@@ -152,6 +309,7 @@ export async function applyOne(
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`; await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true }; return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
});
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message }; return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
@@ -220,13 +378,13 @@ export async function rewindOne(
); );
} }
const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end); const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end);
await writeFile(change.file_path, reverted, 'utf8'); await writeFileAtomic(change.file_path, reverted);
break; break;
} }
case 'delete': { case 'delete': {
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time) // Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
await mkdir(dirname(change.file_path), { recursive: true }); await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8'); await writeFileAtomic(change.file_path, change.diff);
break; break;
} }
} }

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { analyzeMessages } from '../analyzer.js';
describe('analyzeMessages', () => {
it('classifies user messages', () => {
const breakdown = analyzeMessages([{ role: 'user', content: 'hello world' }]);
expect(breakdown.user).toBeGreaterThan(0);
expect(breakdown.total).toBe(breakdown.user);
});
it('counts tool calls', () => {
const parts = [
{ role: 'assistant', content: 'using grep', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: '{"files":[]}', tool_call_id: '1' },
];
const breakdown = analyzeMessages(parts);
expect(breakdown.tools).toBeGreaterThan(0);
expect(breakdown.assistant).toBeGreaterThan(0);
});
it('separates reasoning tokens', () => {
const parts = [
{ role: 'assistant', content: 'short answer', reasoning_parts: [{ text: 'long chain of thought reasoning here' }] },
];
const breakdown = analyzeMessages(parts);
expect(breakdown.reasoning).toBeGreaterThan(0);
expect(breakdown.assistant).toBeLessThan(breakdown.reasoning);
});
});

View File

@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
describe('persistTaskBreakdown', () => {
it('exports functions', async () => {
const mod = await import('../persist.js');
expect(typeof mod.persistTaskBreakdown).toBe('function');
expect(typeof mod.getTaskBreakdown).toBe('function');
expect(typeof mod.analyzeAndPersistTaskBreakdown).toBe('function');
});
});

View File

@@ -0,0 +1,60 @@
// TokenScope analyzer — classifies message parts into category breakdown.
// Ported from opencode-tokenscope (MIT).
export interface TokenBreakdown {
system: number;
user: number;
assistant: number;
tools: number;
reasoning: number;
total: number;
}
const CHARS_PER_TOKEN = 4;
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
export function analyzeMessages(parts: any[]): TokenBreakdown {
const breakdown: TokenBreakdown = { system: 0, user: 0, assistant: 0, tools: 0, reasoning: 0, total: 0 };
for (const part of parts) {
const role = part.role ?? '';
const content = part.content ?? '';
const tokens = estimateTokens(content);
switch (role) {
case 'system':
breakdown.system += tokens;
break;
case 'user':
breakdown.user += tokens;
break;
case 'assistant':
breakdown.assistant += tokens;
if (part.tool_calls) {
for (const tc of part.tool_calls) {
breakdown.tools += estimateTokens(JSON.stringify(tc));
}
}
break;
case 'tool':
breakdown.tools += tokens;
break;
default:
breakdown.assistant += tokens;
}
if (part.reasoning_parts) {
for (const rp of part.reasoning_parts) {
const rTokens = estimateTokens(rp.text ?? '');
breakdown.reasoning += rTokens;
breakdown.assistant -= rTokens;
}
}
}
breakdown.total = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning;
return breakdown;
}

View File

@@ -0,0 +1,35 @@
// TokenScope persistence — writes breakdown to task records.
import type { Sql } from '../../db.js';
import type { TokenBreakdown } from './analyzer.js';
export async function persistTaskBreakdown(
sql: Sql,
taskId: string,
breakdown: TokenBreakdown,
): Promise<void> {
await sql`
UPDATE tasks SET token_breakdown = ${sql.json(breakdown as never)}
WHERE id = ${taskId}
`;
}
export async function getTaskBreakdown(
sql: Sql,
taskId: string,
): Promise<TokenBreakdown | null> {
const rows = await sql<{ token_breakdown: any }[]>`
SELECT token_breakdown FROM tasks WHERE id = ${taskId}
`;
return rows[0]?.token_breakdown ?? null;
}
export async function analyzeAndPersistTaskBreakdown(
sql: Sql,
taskId: string,
parts: any[],
): Promise<TokenBreakdown> {
const { analyzeMessages } = await import('./analyzer.js');
const breakdown = analyzeMessages(parts);
await persistTaskBreakdown(sql, taskId, breakdown);
return breakdown;
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { runWithInferenceContext, getInferenceContext } from '../inference_context.js';
import type { Sql } from '../../../db.js';
const fakeSql = {} as unknown as Sql;
describe('inference context (AsyncLocalStorage isolation)', () => {
it('throws when read outside a run', () => {
expect(() => getInferenceContext()).toThrow(/outside inference context/);
});
it('keeps each run its own context across overlapping awaits', async () => {
// The race the global `let current` had: run B starts (and would overwrite a
// shared global) while run A is awaiting. After A resumes it must still read
// its OWN sessionId, not B's.
const run = (id: string, delay: number) =>
runWithInferenceContext({ sql: fakeSql, sessionId: id, taskId: null }, async () => {
await new Promise((r) => setTimeout(r, delay));
return getInferenceContext().sessionId;
});
const [a, b] = await Promise.all([run('A', 20), run('B', 5)]);
expect(a).toBe('A');
expect(b).toBe('B');
});
it('carries permissionMode and taskId per run', async () => {
const result = await runWithInferenceContext(
{ sql: fakeSql, sessionId: 's1', taskId: 't1', permissionMode: 'bypass' },
async () => {
await Promise.resolve();
const ctx = getInferenceContext();
return { taskId: ctx.taskId, mode: ctx.permissionMode };
},
);
expect(result).toEqual({ taskId: 't1', mode: 'bypass' });
});
});

View File

@@ -26,6 +26,15 @@ export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
}, },
}, },
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
// Under Ask (and Plan) the human approves via the Pending Changes panel — the
// agent must not auto-apply. Bypass and legacy (undefined) may apply.
if (context.permissionMode === 'ask' || context.permissionMode === 'plan') {
return {
status: 'denied',
message:
'Permission mode is Ask — staged changes must be approved by the user in the Pending Changes panel, not applied by the agent.',
};
}
const results = await applyAll(context.sql, context.sessionId, projectRoot); const results = await applyAll(context.sql, context.sessionId, projectRoot);
const succeeded = results.filter((r) => r.success).length; const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length; const failed = results.filter((r) => !r.success).length;

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js'; import type { ToolDef, ToolContext } from './types.js';
import { queueCreate } from '../pending_changes.js'; import { queueCreate } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const CreateFileInput = z.object({ const CreateFileInput = z.object({
file_path: z.string().min(1), file_path: z.string().min(1),
@@ -32,6 +33,7 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
}, },
}, },
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('create_file');
const change = await queueCreate( const change = await queueCreate(
context.sql, context.sql,
context.sessionId, context.sessionId,
@@ -40,12 +42,11 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
input.content, input.content,
projectRoot, projectRoot,
); );
return { return finalizeWrite(
status: 'queued', context,
change_id: change.id, projectRoot,
file_path: change.file_path, change,
operation: 'create', `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`, );
};
}, },
}; };

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js'; import type { ToolDef, ToolContext } from './types.js';
import { queueDelete } from '../pending_changes.js'; import { queueDelete } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const DeleteFileInput = z.object({ const DeleteFileInput = z.object({
file_path: z.string().min(1), file_path: z.string().min(1),
@@ -30,6 +31,7 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
}, },
}, },
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('delete_file');
const change = await queueDelete( const change = await queueDelete(
context.sql, context.sql,
context.sessionId, context.sessionId,
@@ -37,12 +39,11 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
input.file_path, input.file_path,
projectRoot, projectRoot,
); );
return { return finalizeWrite(
status: 'queued', context,
change_id: change.id, projectRoot,
file_path: change.file_path, change,
operation: 'delete', `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`, );
};
}, },
}; };

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js'; import type { ToolDef, ToolContext } from './types.js';
import { queueEdit } from '../pending_changes.js'; import { queueEdit } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const EditFileInput = z.object({ const EditFileInput = z.object({
file_path: z.string().min(1), file_path: z.string().min(1),
@@ -34,6 +35,7 @@ export const editFileTool: ToolDef<EditFileInputT> = {
}, },
}, },
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> { async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('edit_file');
const change = await queueEdit( const change = await queueEdit(
context.sql, context.sql,
context.sessionId, context.sessionId,
@@ -43,12 +45,11 @@ export const editFileTool: ToolDef<EditFileInputT> = {
input.new_string, input.new_string,
projectRoot, projectRoot,
); );
return { return finalizeWrite(
status: 'queued', context,
change_id: change.id, projectRoot,
file_path: change.file_path, change,
operation: 'edit', `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`, );
};
}, },
}; };

View File

@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
import { newTaskTool } from './new_task.js'; import { newTaskTool } from './new_task.js';
import { listTasksTool } from './list_tasks.js'; import { listTasksTool } from './list_tasks.js';
import { checkTaskStatusTool } from './check_task_status.js'; import { checkTaskStatusTool } from './check_task_status.js';
import { lspDiagnosticsTool } from './lsp_diagnostics.js';
import { lspGotoDefinitionTool } from './lsp_goto_definition.js';
import { lspFindReferencesTool } from './lsp_find_references.js';
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js'; export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
checkTaskStatusTool, checkTaskStatusTool,
]; ];
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool }; // Read-only agent tools for code intelligence.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const READ_TOOLS: readonly ToolDef<any>[] = [
lspDiagnosticsTool,
lspGotoDefinitionTool,
lspFindReferencesTool,
];
export {
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
newTaskTool, listTasksTool, checkTaskStatusTool,
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
};

View File

@@ -1,36 +1,49 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
import type { PermissionMode } from './types.js';
/** /**
* Module-level inference context for write tools. * Per-run inference context for write tools.
* *
* Set via `setInferenceContext()` before each inference run starts. * Write tools need ambient state (sql, sessionId, the permission gate) that the
* Write tools read it via `getInferenceContext()` during execute. * BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
* Same pattern as BooChat's `loadConfig()` singleton — tools need * carry. This used to be a single module-level `let current` — but the inference
* ambient state that can't be threaded through the tool-phase execute * runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
* signature (which is `execute(input, projectRoot, extraRoots?)`). * message racing a dispatcher-polled native task; two chat tabs streaming) would
* clobber each other's context, and `cancel()` cleared it for ALL in-flight runs.
*
* AsyncLocalStorage gives each run its own context: `enqueue()` starts its async
* loop synchronously inside `runWithInferenceContext`, so the store propagates
* through every awaited tool execution in that run — and only that run.
*/ */
export interface InferenceContext { export interface InferenceContext {
sql: Sql; sql: Sql;
sessionId: string; sessionId: string;
taskId: string | null; taskId: string | null;
/** Native-BooCode permission gate, set per run from the request/task mode. */
permissionMode?: PermissionMode;
} }
let current: InferenceContext | null = null; const storage = new AsyncLocalStorage<InferenceContext>();
export function setInferenceContext(ctx: InferenceContext): void { /**
current = ctx; * Bind `ctx` for the duration of the (possibly detached) async chain `fn` starts.
} * The inference runner kicks off its loop synchronously within this call, so all
* downstream `await`s — including write-tool `execute` via the adapter — read the
export function clearInferenceContext(): void { * same store. Concurrent runs each get their own; nothing is shared or cleared
current = null; * out from under an in-flight run.
*/
export function runWithInferenceContext<T>(ctx: InferenceContext, fn: () => T): T {
return storage.run(ctx, fn);
} }
export function getInferenceContext(): InferenceContext { export function getInferenceContext(): InferenceContext {
if (!current) { const ctx = storage.getStore();
if (!ctx) {
throw new Error( throw new Error(
'Write tool called outside inference context — setInferenceContext() was not called before this run', 'Write tool called outside inference context — runWithInferenceContext() did not wrap this run',
); );
} }
return current; return ctx;
} }

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { getDiagnostics } from '../lsp/operations.js';
const LspDiagnosticsInput = z.object({
file_path: z.string().describe('Path to the file to check for diagnostics'),
});
type InputT = z.infer<typeof LspDiagnosticsInput>;
export const lspDiagnosticsTool: ToolDef<InputT> = {
name: 'lsp_diagnostics',
description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.',
inputSchema: LspDiagnosticsInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_diagnostics',
description: 'Get TypeScript/JavaScript diagnostics for a file',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file' },
},
required: ['file_path'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type for LSP diagnostics' };
const diagnostics = await getDiagnostics(client, resolved, content);
if (diagnostics.length === 0) return { result: 'No diagnostics found.' };
const lines = diagnostics.map((d) => {
const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown';
return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`;
});
return { result: lines.join('\n') };
},
};

View File

@@ -0,0 +1,49 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { findReferences } from '../lsp/operations.js';
const LspFindReferencesInput = z.object({
file_path: z.string().describe('Path to the source file'),
line: z.number().int().nonnegative().describe('0-based line number'),
character: z.number().int().nonnegative().describe('0-based character offset'),
});
type InputT = z.infer<typeof LspFindReferencesInput>;
export const lspFindReferencesTool: ToolDef<InputT> = {
name: 'lsp_find_references',
description: 'Find all references to a symbol at a given position in a file.',
inputSchema: LspFindReferencesInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_find_references',
description: 'Find all references to symbol at position',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
line: { type: 'number' },
character: { type: 'number' },
},
required: ['file_path', 'line', 'character'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type' };
const refs = await findReferences(client, resolved, content, input.line, input.character);
if (refs.length === 0) return { result: 'No references found.' };
const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`);
return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` };
},
};

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { gotoDefinition } from '../lsp/operations.js';
const LspGotoDefinitionInput = z.object({
file_path: z.string().describe('Path to the source file'),
line: z.number().int().nonnegative().describe('0-based line number'),
character: z.number().int().nonnegative().describe('0-based character offset'),
});
type InputT = z.infer<typeof LspGotoDefinitionInput>;
export const lspGotoDefinitionTool: ToolDef<InputT> = {
name: 'lsp_goto_definition',
description: 'Find the definition of a symbol at a given position in a file.',
inputSchema: LspGotoDefinitionInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_goto_definition',
description: 'Find definition of symbol at position',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
line: { type: 'number' },
character: { type: 'number' },
},
required: ['file_path', 'line', 'character'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type' };
const loc = await gotoDefinition(client, resolved, content, input.line, input.character);
if (!loc) return { result: 'No definition found.' };
return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` };
},
};

View File

@@ -6,6 +6,7 @@ const NewTaskInput = z.object({
input: z.string().min(1).describe('Task description for the child subtask'), input: z.string().min(1).describe('Task description for the child subtask'),
agent: z.string().optional().describe('Optional: dispatch to a specific agent'), agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
model: z.string().optional().describe('Optional: model override for the subtask'), model: z.string().optional().describe('Optional: model override for the subtask'),
background: z.boolean().optional().describe('If true, return immediately without blocking on completion'),
}); });
type NewTaskInputT = z.infer<typeof NewTaskInput>; type NewTaskInputT = z.infer<typeof NewTaskInput>;
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
input: { type: 'string', description: 'Task description for the child subtask' }, input: { type: 'string', description: 'Task description for the child subtask' },
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' }, agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
model: { type: 'string', description: 'Optional: model override for the subtask' }, model: { type: 'string', description: 'Optional: model override for the subtask' },
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
}, },
required: ['input'], required: ['input'],
}, },
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
return { error: 'Cannot determine project_id from current session' }; return { error: 'Cannot determine project_id from current session' };
} }
const isBg = input.background === true;
const [task] = await sql<{ id: string; state: string }[]>` const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, parent_task_id, input, agent, model) INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null}) VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
`; `;
return { return {
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`, message: isBg
? `Background subtask created (id: ${task!.id}). It will continue independently.`
: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
task_id: task!.id, task_id: task!.id,
state: task!.state, state: task!.state,
background: isBg,
}; };
}, },
}; };

View File

@@ -1,6 +1,22 @@
import type { z } from 'zod'; import type { z } from 'zod';
import type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
/**
* Unified permission ladder for native BooCode inference. Gates the write tools:
* plan — read-only: create/edit/delete are denied (no staging).
* ask — stage to the pending-changes queue; `apply_pending` is denied so the
* agent cannot self-apply (the human approves via the Diff panel).
* bypass — apply each write immediately (no queue, no approval).
* Undefined preserves the historical behavior (stage + `apply_pending` allowed).
*/
export type PermissionMode = 'plan' | 'ask' | 'bypass';
/** Narrow a raw task/request mode id to a unified PermissionMode, else undefined
* (e.g. an external agent's native mode id, or null). */
export function asPermissionMode(id: string | null | undefined): PermissionMode | undefined {
return id === 'plan' || id === 'ask' || id === 'bypass' ? id : undefined;
}
export interface ToolJsonSchema { export interface ToolJsonSchema {
type: 'function'; type: 'function';
function: { function: {
@@ -21,6 +37,8 @@ export interface ToolContext {
sql: Sql; sql: Sql;
sessionId: string; sessionId: string;
taskId: string | null; taskId: string | null;
/** Native-BooCode permission gate for write tools (undefined = legacy behavior). */
permissionMode?: PermissionMode;
} }
export interface ToolDef<TInput> { export interface ToolDef<TInput> {

View File

@@ -0,0 +1,53 @@
/**
* Permission-gate helpers for native BooCode write tools. The gate comes from
* the per-run inference context (`ToolContext.permissionMode`):
* plan — deny the write (read-only); nothing is staged.
* bypass — apply the staged change immediately (no queue, no approval).
* ask / undefined — leave it in the pending-changes queue for review.
*/
import type { ToolContext } from './types.js';
import { applyOne } from '../pending_changes.js';
/** Result returned when a write is denied under Plan (read-only) mode. */
export function denyReadOnly(operation: string): unknown {
return {
status: 'denied',
operation,
message: `Read-only (Plan) permission mode — ${operation} is not permitted. Switch to Ask or Bypass to make changes.`,
};
}
/** Finalize a just-staged change per the permission gate: apply now under Bypass,
* otherwise return it as queued for the human to approve. */
export async function finalizeWrite(
context: ToolContext,
projectRoot: string,
change: { id: string; file_path: string; operation: string },
queuedHint: string,
): Promise<unknown> {
if (context.permissionMode === 'bypass') {
const res = await applyOne(context.sql, change.id, projectRoot);
console.log(
`[write-gate] bypass apply ${change.operation} ${change.file_path} -> ${res.success ? 'applied' : 'FAILED: ' + (res.error ?? '?')}`,
);
return {
status: res.success ? 'applied' : 'failed',
change_id: change.id,
file_path: change.file_path,
operation: change.operation,
message: res.success
? `${change.operation} applied to ${change.file_path}.`
: `Apply failed for ${change.file_path}: ${res.error ?? 'unknown error'}. Left in the pending queue.`,
};
}
console.log(
`[write-gate] ${context.permissionMode ?? 'legacy'} queued ${change.operation} ${change.file_path}`,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: change.operation,
message: queuedHint,
};
}

View File

@@ -17,6 +17,8 @@
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`. - **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor. - **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue). - **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
- **`services/inference/tool-shim.ts`** — Recovers structured tool calls from plain-text model output. Some models (notably Qwen) emit `<tool_call><name>...</name><arguments>...</arguments></tool_call>` inline text instead of structured JSON. `extractToolCalls(text)` parses both XML and JSON inline formats. `hasToolCallMarkup(text)` is a fast pre-check. Used as a fallback in the stream phase when structured `tool_calls` parse fails. Does NOT require `FAST_MODEL` — operates on the existing turn's output text.
- **`services/inference/loop-detectors.ts`** — Six detectors that catch repetitive model behavior: `detectContentRepeat` (same content N times), `detectToolLoop` (same tool called consecutively). `detectDoomLoop` combines both. These are additive to the existing `sentinels.ts` doom-loop detection.
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array. - **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn. - **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up. - **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.

View File

@@ -19,6 +19,8 @@ import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js'; import { registerSkillsRoutes } from './routes/skills.js';
import { registerToolsRoutes } from './routes/tools.js'; import { registerToolsRoutes } from './routes/tools.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
import { createInferenceRunner } from './services/inference/index.js'; import { createInferenceRunner } from './services/inference/index.js';
import { createBroker } from './services/broker.js'; import { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js'; import { listSkills } from './services/skills.js';
@@ -122,6 +124,8 @@ async function main() {
registerSidebarRoutes(app, sql); registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker); registerChatRoutes(app, sql, broker);
registerToolsRoutes(app, sql); registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerInferenceSettingsRoutes(app);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or // Batch 9.6: warm the skills cache at boot and surface the count. Empty or
// missing /data/skills is non-fatal — the skill tools just return empty. // missing /data/skills is non-fatal — the skill tools just return empty.

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
// token-analyzer-ui: context window utilization and token breakdown data.
// v1 — global aggregates only.
export interface ContextWindowStats {
avg_ctx_used: number | null;
avg_ctx_max: number | null;
avg_utilization_pct: number | null;
message_count: number;
}
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/analytics/context — average context window utilization across
// completed assistant messages that carry ctx_used/ctx_max.
app.get('/api/analytics/context', async () => {
const [row] = await sql<ContextWindowStats[]>`
SELECT
AVG(ctx_used)::DOUBLE PRECISION AS avg_ctx_used,
AVG(ctx_max)::DOUBLE PRECISION AS avg_ctx_max,
AVG(ctx_used::float / NULLIF(ctx_max, 0))::DOUBLE PRECISION AS avg_utilization_pct,
COUNT(*)::INT AS message_count
FROM messages
WHERE role = 'assistant'
AND status = 'complete'
AND ctx_used IS NOT NULL
AND ctx_max IS NOT NULL
AND ctx_max > 0
`;
return row ?? { avg_ctx_used: null, avg_ctx_max: null, avg_utilization_pct: null, message_count: 0 };
});
}

View File

@@ -0,0 +1,55 @@
import { FastifyInstance } from 'fastify';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
const CONFIG_PATH = resolve(process.env.BOOCODE_DATA_DIR || '/opt/boocode/data', 'inference-settings.json');
const DEFAULTS = {
cache_type_k: 'q4_0',
cache_reuse: 256,
spec_type: 'ngram-mod',
spec_ngram_mod_thsh: 2,
ctx_checkpoints: 32,
sleep_idle_seconds: 600,
metrics_enabled: true,
slot_save_path: '/tmp/llama-slots',
};
function load(): Record<string, unknown> {
try {
if (existsSync(CONFIG_PATH)) {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
}
} catch { /* corrupt file */ }
return { ...DEFAULTS };
}
function save(data: Record<string, unknown>): void {
const dir = dirname(CONFIG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + '\n');
}
const VALID_CACHE_TYPES = ['f32', 'f16', 'q8_0', 'q4_0'] as const;
const VALID_SPEC_TYPES = ['off', 'ngram-mod', 'draft-simple'] as const;
export function registerInferenceSettingsRoutes(app: FastifyInstance): void {
app.get('/api/settings/inference', async (_req, _res) => {
return { ...DEFAULTS, ...load() };
});
app.patch<{ Body: Record<string, unknown> }>('/api/settings/inference', async (req, reply) => {
const current = { ...DEFAULTS, ...load() };
const merged = { ...current, ...req.body };
if (merged.cache_type_k && !(VALID_CACHE_TYPES as readonly string[]).includes(merged.cache_type_k as string)) {
return reply.status(400).send({ error: 'Invalid cache_type_k' });
}
if (merged.spec_type && !(VALID_SPEC_TYPES as readonly string[]).includes(merged.spec_type as string)) {
return reply.status(400).send({ error: 'Invalid spec_type' });
}
save(merged);
return { ...DEFAULTS, ...load() };
});
}

View File

@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { realpath, stat, readdir, access } from 'node:fs/promises'; import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises';
import { basename, resolve, sep } from 'node:path'; import { basename, resolve, sep } from 'node:path';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
@@ -473,7 +473,7 @@ export function registerProjectRoutes(
// Always includes auto_mode (the dirty-state-derived mode) so the client can // Always includes auto_mode (the dirty-state-derived mode) so the client can
// show a suggestion when a pinned mode diverges from what would be auto-selected. // show a suggestion when a pinned mode diverges from what would be auto-selected.
// Returns { git_repo: false } when the path is not a git repository. // Returns { git_repo: false } when the path is not a git repository.
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>( app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
'/api/projects/:id/git/diff', '/api/projects/:id/git/diff',
async (req, reply) => { async (req, reply) => {
const { id } = req.params; const { id } = req.params;
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
rawMode === 'uncommitted' ? 'uncommitted' : rawMode === 'uncommitted' ? 'uncommitted' :
auto_mode; // no mode param → auto-select (FIX 1) auto_mode; // no mode param → auto-select (FIX 1)
const result = await getGitDiff(projectRoot, mode); const ignoreWhitespace = req.query.whitespace === '1';
const result = await getGitDiff(projectRoot, mode, ignoreWhitespace);
if (result === null) { if (result === null) {
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] }; return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
} }
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
).min(1), ).min(1),
}); });
const WriteFileBody = z.object({
path: z.string().min(1),
content: z.string(),
});
// POST /api/projects/:id/git/stage — stage whole files // POST /api/projects/:id/git/stage — stage whole files
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/projects/:id/git/stage', '/api/projects/:id/git/stage',
@@ -637,6 +643,38 @@ export function registerProjectRoutes(
}, },
); );
// POST /api/projects/:id/write_file — write a file atomically
app.post<{ Params: { id: string } }>(
'/api/projects/:id/write_file',
async (req, reply) => {
const body = WriteFileBody.safeParse(req.body);
if (!body.success) { reply.code(400); return { error: body.error.message }; }
const { id } = req.params;
const projectPath = await selectProjectPath(sql, id);
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
let root: string;
try { root = await resolveProjectRoot(projectPath); }
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path);
// Validate path stays within project root
const realTarget = await realpath(target).catch(() => target);
if (!realTarget.startsWith(root + sep) && realTarget !== root) {
reply.code(403);
return { error: 'path escapes project root' };
}
const tmp = target + '.tmp';
try {
await writeFile(tmp, body.data.content, 'utf-8');
await rename(tmp, target);
return { ok: true };
} catch (err) {
// Clean up tmp on failure
await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {});
throw err;
}
},
);
// GET /api/projects/:id/files // GET /api/projects/:id/files
app.get<{ Params: { id: string } }>( app.get<{ Params: { id: string } }>(
'/api/projects/:id/files', '/api/projects/:id/files',

View File

@@ -372,13 +372,12 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES mess
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at); CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
-- tasks table (provider dispatch, arena) -- tasks table (provider dispatch)
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE, session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
parent_task_id UUID REFERENCES tasks(id), parent_task_id UUID REFERENCES tasks(id),
arena_id UUID,
state TEXT NOT NULL DEFAULT 'pending' state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')), CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
input TEXT NOT NULL, input TEXT NOT NULL,
@@ -405,3 +404,6 @@ DO $$ BEGIN
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE; FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF; END IF;
END $$; END $$;
-- Remove the v2.0.5 arena_id column (replaced by the new Arena feature).
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;

View File

@@ -112,14 +112,14 @@ describe('stripShadowingFlags', () => {
expect(result).toEqual(['-c', '4096']); expect(result).toEqual(['-c', '4096']);
}); });
it('strips cache flags by default', () => { it('passes through cache flags (no longer shadowed)', () => {
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']); const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
expect(result).toEqual([]); expect(result).toEqual(['--cache-type-k', 'q8_0']);
}); });
it('strips spec flags by default', () => { it('passes through spec flags (no longer shadowed)', () => {
const result = stripShadowingFlags(['--spec-draft-n-max', '16']); const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
expect(result).toEqual([]); expect(result).toEqual(['--spec-draft-n-max', '16']);
}); });
}); });

View File

@@ -0,0 +1,52 @@
export interface UserCorrectionRecord {
record_type: 'conversation';
action_type: 'user_correction';
priority: 'critical_for_recovery';
timestamp: string;
original_claim: string;
correction: string;
principle_extracted: string;
persisted_to: string[];
}
export function createCorrection(params: {
originalClaim: string;
correction: string;
principleExtracted?: string;
persistedTo?: string[];
}): UserCorrectionRecord {
return {
record_type: 'conversation',
action_type: 'user_correction',
priority: 'critical_for_recovery',
timestamp: new Date().toISOString(),
original_claim: params.originalClaim,
correction: params.correction,
principle_extracted: params.principleExtracted || '',
persisted_to: params.persistedTo || [],
};
}
export function findCorrections(
records: Record<string, unknown>[],
): UserCorrectionRecord[] {
return records.filter(
r => r['action_type'] === 'user_correction',
) as unknown as UserCorrectionRecord[];
}
export function checkCorrectionConflict(
proposedAction: string,
corrections: UserCorrectionRecord[],
): UserCorrectionRecord | null {
for (const c of corrections) {
if (!c.original_claim) continue;
const claimKeywords = c.original_claim.toLowerCase().split(/\s+/).filter(w => w.length > 3);
const actionLower = proposedAction.toLowerCase();
const matchCount = claimKeywords.filter(k => actionLower.includes(k)).length;
if (matchCount >= 2 && matchCount / claimKeywords.length >= 0.5) {
if (c.persisted_to.length > 0) return c;
}
}
return null;
}

View File

@@ -0,0 +1,251 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureRunsDir } from './runs-dir.js';
export type GuidelineId = string;
export type TagId = string;
export type Criticality = 'low' | 'medium' | 'high';
export type GuidelineDocumentVersion = string;
export interface GuidelineContent {
condition: string;
action: string | null;
description: string | null;
}
export interface Guideline {
id: GuidelineId;
creationUtc: string;
content: GuidelineContent;
enabled: boolean;
tags: TagId[];
labels: string[];
metadata: Record<string, unknown>;
criticality: Criticality;
title: string | null;
priority: number;
}
export interface GuidelineDocument {
id: string;
version: GuidelineDocumentVersion;
creation_utc: string;
condition: string;
action: string | null;
description: string | null;
title: string | null;
criticality: string;
enabled: boolean;
metadata: Record<string, unknown>;
labels: string[];
priority: number;
}
export interface GuidelineUpdateParams {
condition?: string;
action?: string | null;
description?: string | null;
title?: string | null;
criticality?: Criticality;
enabled?: boolean;
priority?: number;
}
function generateId(): string {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < 10; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function dbPath(projectRoot?: string): string {
const dir = join(ensureRunsDir(projectRoot), '..', 'guidelines');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return join(dir, 'guidelines.json');
}
function readDb(projectRoot?: string): GuidelineDocument[] {
const path = dbPath(projectRoot);
try {
return JSON.parse(readFileSync(path, 'utf-8')) as GuidelineDocument[];
} catch {
return [];
}
}
function writeDb(docs: GuidelineDocument[], projectRoot?: string): void {
writeFileSync(dbPath(projectRoot), JSON.stringify(docs, null, 2), 'utf-8');
}
function toDocument(g: Guideline): GuidelineDocument {
return {
id: g.id,
version: '0.11.0',
creation_utc: g.creationUtc,
condition: g.content.condition,
action: g.content.action,
description: g.content.description,
title: g.title,
criticality: g.criticality,
enabled: g.enabled,
metadata: g.metadata,
labels: g.labels,
priority: g.priority,
};
}
function fromDocument(d: GuidelineDocument): Guideline {
return {
id: d.id,
creationUtc: d.creation_utc,
content: {
condition: d.condition,
action: d.action ?? null,
description: d.description ?? null,
},
title: d.title ?? null,
criticality: (d.criticality || 'medium') as Criticality,
enabled: d.enabled ?? true,
tags: [],
labels: d.labels ?? [],
metadata: d.metadata ?? {},
priority: d.priority ?? 0,
};
}
export class GuidelineDocumentStore {
createGuideline(params: {
condition: string;
action?: string | null;
description?: string | null;
title?: string | null;
criticality?: Criticality;
enabled?: boolean;
labels?: string[];
priority?: number;
id?: GuidelineId;
}, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const id = params.id || `gl_${generateId()}`;
if (docs.find(d => d.id === id)) {
throw new Error(`Guideline with id '${id}' already exists`);
}
const guideline: Guideline = {
id,
creationUtc: new Date().toISOString(),
content: {
condition: params.condition,
action: params.action ?? null,
description: params.description ?? null,
},
title: params.title ?? null,
criticality: params.criticality ?? 'medium',
enabled: params.enabled ?? true,
tags: [],
labels: params.labels ?? [],
metadata: {},
priority: params.priority ?? 0,
};
docs.push(toDocument(guideline));
writeDb(docs, projectRoot);
return guideline;
}
listGuidelines(params?: {
tags?: TagId[];
labels?: string[];
}, projectRoot?: string): Guideline[] {
let docs = readDb(projectRoot);
if (params?.tags && params.tags.length > 0) {
const tagSet = new Set(params.tags);
docs = docs.filter(d => d.metadata['tags'] &&
Array.isArray(d.metadata['tags']) &&
(d.metadata['tags'] as string[]).some(t => tagSet.has(t)));
}
if (params?.labels && params.labels.length > 0) {
const labelSet = new Set(params.labels);
docs = docs.filter(d => {
const gl = fromDocument(d);
return params.labels!.every(l => gl.labels.includes(l));
});
}
return docs.map(fromDocument);
}
readGuideline(id: GuidelineId, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const doc = docs.find(d => d.id === id);
if (!doc) throw new Error(`Guideline '${id}' not found`);
return fromDocument(doc);
}
updateGuideline(id: GuidelineId, params: GuidelineUpdateParams, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
const doc = docs[idx]!;
if (params.condition !== undefined) doc.condition = params.condition;
if (params.action !== undefined) doc.action = params.action;
if (params.description !== undefined) doc.description = params.description;
if (params.title !== undefined) doc.title = params.title;
if (params.criticality !== undefined) doc.criticality = params.criticality;
if (params.enabled !== undefined) doc.enabled = params.enabled;
if (params.priority !== undefined) doc.priority = params.priority;
docs[idx] = doc;
writeDb(docs, projectRoot);
return fromDocument(doc);
}
deleteGuideline(id: GuidelineId, projectRoot?: string): void {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
docs.splice(idx, 1);
writeDb(docs, projectRoot);
}
findGuideline(content: GuidelineContent, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const doc = docs.find(d =>
d.condition === content.condition &&
(content.action === undefined || d.action === content.action),
);
if (!doc) throw new Error(`Guideline not found for condition='${content.condition}'`);
return fromDocument(doc);
}
upsertLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
const doc = docs[idx]!;
const current = new Set(doc.labels || []);
for (const l of labels) current.add(l);
doc.labels = [...current];
writeDb(docs, projectRoot);
return fromDocument(doc);
}
removeLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
const doc = docs[idx]!;
const removeSet = new Set(labels);
doc.labels = (doc.labels || []).filter(l => !removeSet.has(l));
writeDb(docs, projectRoot);
return fromDocument(doc);
}
}

View File

@@ -0,0 +1,68 @@
export {
findRunsDir,
ensureRunsDir,
readCurrentSession,
writeCurrentSession,
clearCurrentSession,
readIndex,
writeIndex,
updateIndexEntry,
findInProgressSessions,
INDEX_SCHEMA_VERSION,
GITIGNORE_CONTENT,
} from './runs-dir.js';
export type { IndexEntry, IndexFile } from './runs-dir.js';
export {
generateSessionId,
isoNow,
createSession,
getSessionDir,
getActiveSession,
readSession,
updateSession,
endSession,
appendToTrail,
readTrail,
recoverContext,
checkUnfinishedSessions,
generateSessionSummary,
} from './session-manager.js';
export type { SessionJson, RecoverySummary } from './session-manager.js';
export {
createCorrection,
findCorrections,
checkCorrectionConflict,
} from './corrections.js';
export type { UserCorrectionRecord } from './corrections.js';
export {
GuidelineDocumentStore,
} from './guideline-store.js';
export type {
GuidelineId,
GuidelineContent,
Guideline,
Criticality,
GuidelineUpdateParams,
GuidelineDocument,
} from './guideline-store.js';
export {
JourneyStore,
} from './journey-store.js';
export type {
JourneyId,
JourneyNodeId,
JourneyEdgeId,
Journey,
JourneyNode,
JourneyEdge,
} from './journey-store.js';
export {
projectJourneyToGuidelines,
detectJourneyBacktrack,
} from './journey-projection.js';
export type { ProjectedGuideline, BacktrackCheck } from './journey-projection.js';

View File

@@ -0,0 +1,189 @@
import type {
Journey,
JourneyNode,
JourneyEdge,
JourneyNodeId,
JourneyEdgeId,
} from './journey-store.js';
import type { Guideline, GuidelineId, Criticality } from './guideline-store.js';
export interface ProjectedGuideline {
id: GuidelineId;
content: {
condition: string;
action: string | null;
description: string | null;
};
criticality: Criticality;
creationUtc: string;
enabled: boolean;
tags: string[];
labels: string[];
metadata: Record<string, unknown>;
}
function formatNodeGuidelineId(nodeId: JourneyNodeId, edgeId?: JourneyEdgeId | null): GuidelineId {
return `journey_node:${nodeId}${edgeId ? `:${edgeId}` : ''}` as GuidelineId;
}
export function projectJourneyToGuidelines(
journey: Journey,
nodes: JourneyNode[],
edges: JourneyEdge[],
): ProjectedGuideline[] {
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
for (const n of nodes) nodeMap.set(n.id, n);
const edgeMap = new Map<JourneyEdgeId, JourneyEdge>();
for (const e of edges) edgeMap.set(e.id, e);
const nodeEdges = new Map<JourneyNodeId, JourneyEdge[]>();
for (const e of edges) {
const list = nodeEdges.get(e.source) || [];
list.push(e);
nodeEdges.set(e.source, list);
}
const guidelines: Map<GuidelineId, ProjectedGuideline> = new Map();
const nodeIndexes = new Map<JourneyNodeId, number>();
let index = 0;
const queue: Array<{ edgeId: JourneyEdgeId | null; nodeId: JourneyNodeId }> = [];
const visited = new Set<string>();
queue.push({ edgeId: null, nodeId: journey.rootId });
while (queue.length > 0) {
const { edgeId, nodeId } = queue.shift()!;
const visitKey = `${edgeId || ''}:${nodeId}`;
if (visited.has(visitKey)) continue;
visited.add(visitKey);
const node = nodeMap.get(nodeId);
if (!node) continue;
if (!nodeIndexes.has(nodeId)) {
index++;
nodeIndexes.set(nodeId, index);
}
const edge = edgeId ? edgeMap.get(edgeId) : undefined;
const baseJourneyNode: Record<string, unknown> = {
follow_ups: [],
index: String(nodeIndexes.get(nodeId)),
journey_id: journey.id,
labels: node.labels,
tool_ids: node.tools,
};
const edgeJourneyNode = (edge?.metadata?.['journey_node'] as Record<string, unknown>) || {};
const nodeJourneyNode = (node.metadata?.['journey_node'] as Record<string, unknown>) || {};
const mergedJourneyNode = { ...baseJourneyNode, ...nodeJourneyNode, ...edgeJourneyNode };
const metadata: Record<string, unknown> = {
journey_node: mergedJourneyNode,
};
for (const [k, v] of Object.entries(node.metadata)) {
if (k !== 'journey_node') metadata[k] = v;
}
if (edge) {
for (const [k, v] of Object.entries(edge.metadata)) {
if (k !== 'journey_node') metadata[k] = v;
}
}
const gid = formatNodeGuidelineId(nodeId, edgeId);
const guideline: ProjectedGuideline = {
id: gid,
content: {
condition: (edge?.condition) || '',
action: node.action,
description: node.description,
},
criticality: 'high' as Criticality,
creationUtc: new Date().toISOString(),
enabled: true,
tags: journey.tags,
labels: [...(node.labels || [])],
metadata,
};
guidelines.set(gid, guideline);
const childEdges = nodeEdges.get(nodeId) || [];
for (const childEdge of childEdges) {
if (visited.has(`${childEdge.id}:${childEdge.target}`)) continue;
queue.push({ edgeId: childEdge.id, nodeId: childEdge.target });
const childGid = formatNodeGuidelineId(childEdge.target, childEdge.id);
const followUps = (guideline.metadata['journey_node'] as Record<string, unknown>)['follow_ups'] as string[];
if (!followUps.includes(childGid)) {
followUps.push(childGid);
}
}
}
return [...guidelines.values()];
}
export interface BacktrackCheck {
journeyId: string;
currentNodeId: JourneyNodeId;
previousNodeId: JourneyNodeId;
isBacktrack: boolean;
recommendation: string | null;
}
export function detectJourneyBacktrack(
journey: Journey,
nodes: JourneyNode[],
edges: JourneyEdge[],
currentNodeId: JourneyNodeId,
previousNodeId: JourneyNodeId,
): BacktrackCheck {
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
for (const n of nodes) nodeMap.set(n.id, n);
const adjacency = new Map<JourneyNodeId, JourneyNodeId[]>();
for (const e of edges) {
const list = adjacency.get(e.source) || [];
list.push(e.target);
adjacency.set(e.source, list);
}
const isInForwardPath = (from: JourneyNodeId, target: JourneyNodeId): boolean => {
const visitedInner = new Set<JourneyNodeId>();
const queueInner: JourneyNodeId[] = [from];
while (queueInner.length > 0) {
const current = queueInner.shift()!;
if (current === target) return true;
if (visitedInner.has(current)) continue;
visitedInner.add(current);
for (const next of adjacency.get(current) || []) {
if (!visitedInner.has(next)) queueInner.push(next);
}
}
return false;
};
const fromCurToPrev = isInForwardPath(currentNodeId, previousNodeId);
const fromPrevToCur = isInForwardPath(previousNodeId, currentNodeId);
const isBacktrack = !fromPrevToCur && !fromCurToPrev;
let recommendation: string | null = null;
if (isBacktrack && nodeMap.has(previousNodeId)) {
const prevNode = nodeMap.get(previousNodeId)!;
recommendation = `Detected potential backtrack from '${currentNodeId}' to '${previousNodeId}' (${prevNode.action || 'no action'}). Consider whether this regression is intentional.`;
}
return {
journeyId: journey.id,
currentNodeId,
previousNodeId,
isBacktrack,
recommendation,
};
}

View File

@@ -0,0 +1,360 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureRunsDir } from './runs-dir.js';
import type { GuidelineId } from './guideline-store.js';
export type JourneyId = string;
export type JourneyNodeId = string;
export type JourneyEdgeId = string;
export interface JourneyNode {
id: JourneyNodeId;
creationUtc: string;
action: string | null;
tools: string[];
metadata: Record<string, unknown>;
description: string | null;
labels: string[];
}
export interface JourneyEdge {
id: JourneyEdgeId;
creationUtc: string;
source: JourneyNodeId;
target: JourneyNodeId;
condition: string | null;
metadata: Record<string, unknown>;
}
export interface Journey {
id: JourneyId;
creationUtc: string;
description: string;
triggers: GuidelineId[];
title: string;
rootId: JourneyNodeId;
tags: string[];
labels: string[];
priority: number;
}
interface JourneyDocument {
id: string;
version: string;
creation_utc: string;
title: string;
description: string;
root_id: JourneyNodeId;
labels: string[];
priority: number;
}
interface NodeDocument {
id: string;
node_id: JourneyNodeId;
journey_id: JourneyId;
creation_utc: string;
action: string | null;
tools: string[];
metadata: Record<string, unknown>;
description: string | null;
labels: string[];
}
interface EdgeDocument {
id: string;
journey_id: JourneyId;
creation_utc: string;
source: JourneyNodeId;
target: JourneyNodeId;
condition: string | null;
metadata: Record<string, unknown>;
}
interface TriggerDocument {
id: string;
journey_id: JourneyId;
trigger: GuidelineId;
creation_utc: string;
}
function generateId(): string {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < 10; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function dbPath(name: string, projectRoot?: string): string {
const dir = join(ensureRunsDir(projectRoot), '..', 'journeys');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return join(dir, `${name}.json`);
}
function readCollection<T>(name: string, projectRoot?: string): T[] {
try {
return JSON.parse(readFileSync(dbPath(name, projectRoot), 'utf-8')) as T[];
} catch {
return [];
}
}
function writeCollection<T>(name: string, data: T[], projectRoot?: string): void {
writeFileSync(dbPath(name, projectRoot), JSON.stringify(data, null, 2), 'utf-8');
}
export class JourneyStore {
createJourney(params: {
title: string;
description: string;
triggers?: GuidelineId[];
labels?: string[];
priority?: number;
}, projectRoot?: string): Journey {
const id = `jny_${generateId()}`;
const rootId = `node_${generateId()}`;
const creationUtc = new Date().toISOString();
const journey: Journey = {
id,
creationUtc,
description: params.description,
triggers: params.triggers || [],
title: params.title,
rootId,
tags: [],
labels: params.labels || [],
priority: params.priority || 0,
};
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
journeys.push({
id,
version: '0.7.0',
creation_utc: creationUtc,
title: params.title,
description: params.description,
root_id: rootId,
labels: params.labels || [],
priority: params.priority || 0,
});
writeCollection('journeys', journeys, projectRoot);
const root: JourneyNode = {
id: rootId,
creationUtc,
action: null,
tools: [],
metadata: {},
description: null,
labels: [],
};
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
nodes.push({
id: `nd_${generateId()}`,
node_id: rootId,
journey_id: id,
creation_utc: creationUtc,
action: null,
tools: [],
metadata: {},
description: null,
labels: [],
});
writeCollection('nodes', nodes, projectRoot);
return journey;
}
readJourney(id: JourneyId, projectRoot?: string): Journey {
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
const doc = journeys.find(j => j.id === id);
if (!doc) throw new Error(`Journey '${id}' not found`);
const triggers = readCollection<TriggerDocument>('triggers', projectRoot)
.filter(t => t.journey_id === id)
.map(t => t.trigger);
return {
id: doc.id,
creationUtc: doc.creation_utc,
description: doc.description,
triggers,
title: doc.title,
rootId: doc.root_id,
tags: [],
labels: doc.labels || [],
priority: doc.priority || 0,
};
}
deleteJourney(id: JourneyId, projectRoot?: string): void {
let journeys = readCollection<JourneyDocument>('journeys', projectRoot);
const idx = journeys.findIndex(j => j.id === id);
if (idx === -1) throw new Error(`Journey '${id}' not found`);
journeys.splice(idx, 1);
writeCollection('journeys', journeys, projectRoot);
let nodes = readCollection<NodeDocument>('nodes', projectRoot);
nodes = nodes.filter(n => n.journey_id !== id);
writeCollection('nodes', nodes, projectRoot);
let edges = readCollection<EdgeDocument>('edges', projectRoot);
edges = edges.filter(e => e.journey_id !== id);
writeCollection('edges', edges, projectRoot);
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
triggers = triggers.filter(t => t.journey_id !== id);
writeCollection('triggers', triggers, projectRoot);
}
listJourneys(projectRoot?: string): Journey[] {
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
return journeys.map(j => this.readJourney(j.id, projectRoot));
}
createNode(journeyId: JourneyId, params: {
action?: string | null;
tools?: string[];
description?: string | null;
labels?: string[];
id?: JourneyNodeId;
}, projectRoot?: string): JourneyNode {
const nodeId = params.id || `node_${generateId()}`;
const creationUtc = new Date().toISOString();
const node: JourneyNode = {
id: nodeId,
creationUtc,
action: params.action ?? null,
tools: params.tools || [],
metadata: {},
description: params.description ?? null,
labels: params.labels || [],
};
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
nodes.push({
id: `nd_${generateId()}`,
node_id: nodeId,
journey_id: journeyId,
creation_utc: creationUtc,
action: node.action,
tools: node.tools,
metadata: node.metadata,
description: node.description,
labels: node.labels,
});
writeCollection('nodes', nodes, projectRoot);
return node;
}
listNodes(journeyId: JourneyId, projectRoot?: string): JourneyNode[] {
const docs = readCollection<NodeDocument>('nodes', projectRoot)
.filter(n => n.journey_id === journeyId);
const nodes = docs.map(d => ({
id: d.node_id,
creationUtc: d.creation_utc,
action: d.action,
tools: d.tools,
metadata: d.metadata,
description: d.description,
labels: d.labels || [],
}));
nodes.push({
id: 'end' as JourneyNodeId,
creationUtc: new Date().toISOString(),
action: null,
tools: [],
metadata: {},
description: null,
labels: [],
});
return nodes;
}
createEdge(journeyId: JourneyId, params: {
source: JourneyNodeId;
target: JourneyNodeId;
condition?: string | null;
}, projectRoot?: string): JourneyEdge {
const creationUtc = new Date().toISOString();
const edge: JourneyEdge = {
id: `edge_${generateId()}`,
creationUtc,
source: params.source,
target: params.target,
condition: params.condition ?? null,
metadata: {},
};
const edges = readCollection<EdgeDocument>('edges', projectRoot);
edges.push({
id: edge.id,
journey_id: journeyId,
creation_utc: creationUtc,
source: params.source,
target: params.target,
condition: params.condition ?? null,
metadata: {},
});
writeCollection('edges', edges, projectRoot);
return edge;
}
listEdges(journeyId: JourneyId, nodeId?: JourneyNodeId, projectRoot?: string): JourneyEdge[] {
let docs = readCollection<EdgeDocument>('edges', projectRoot)
.filter(e => e.journey_id === journeyId);
if (nodeId) {
docs = docs.filter(e => e.source === nodeId || e.target === nodeId);
}
return docs.map(d => ({
id: d.id,
creationUtc: d.creation_utc,
source: d.source,
target: d.target,
condition: d.condition,
metadata: d.metadata,
}));
}
deleteEdge(edgeId: JourneyEdgeId, projectRoot?: string): void {
let edges = readCollection<EdgeDocument>('edges', projectRoot);
const idx = edges.findIndex(e => e.id === edgeId);
if (idx === -1) throw new Error(`Edge '${edgeId}' not found`);
edges.splice(idx, 1);
writeCollection('edges', edges, projectRoot);
}
addTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
const triggers = readCollection<TriggerDocument>('triggers', projectRoot);
if (triggers.find(t => t.journey_id === journeyId && t.trigger === trigger)) {
return false;
}
triggers.push({
id: `trg_${generateId()}`,
journey_id: journeyId,
trigger,
creation_utc: new Date().toISOString(),
});
writeCollection('triggers', triggers, projectRoot);
return true;
}
removeTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
const len = triggers.length;
triggers = triggers.filter(t => !(t.journey_id === journeyId && t.trigger === trigger));
writeCollection('triggers', triggers, projectRoot);
return triggers.length < len;
}
}

View File

@@ -0,0 +1,111 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
export const INDEX_SCHEMA_VERSION = '1.1';
export const GITIGNORE_CONTENT = `# boocode audit runs
/*
!index.json
`;
export interface IndexEntry {
id: string;
type: string;
status: string;
task?: string;
skill?: string;
created?: string;
last_updated?: string;
record_count?: number;
anomaly_count?: number;
max_anomaly_level?: string;
}
export interface IndexFile {
schema_version: string;
entries: IndexEntry[];
}
function findRunsDirFrom(start: string): string {
const explicit = process.env['AUDIT_DOT_DIR']?.trim();
const candidates = explicit ? [explicit] : ['.boo'];
let cur = resolve(start);
while (true) {
for (const basename of candidates) {
const candidate = join(cur, basename, 'runs');
if (existsSync(candidate)) return candidate;
}
const parent = resolve(cur, '..');
if (parent === cur) break;
cur = parent;
}
const defaultBasename = explicit || '.boo';
return join(resolve(start), defaultBasename, 'runs');
}
export function findRunsDir(projectRoot?: string): string {
return findRunsDirFrom(projectRoot || process.cwd());
}
export function ensureRunsDir(projectRoot?: string): string {
const dir = findRunsDir(projectRoot);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
const gitignorePath = join(dir, '.gitignore');
if (!existsSync(gitignorePath)) {
writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
}
}
return dir;
}
export function readCurrentSession(projectRoot?: string): string | null {
const path = join(ensureRunsDir(projectRoot), '.current_session');
try {
return readFileSync(path, 'utf-8').trim();
} catch {
return null;
}
}
export function writeCurrentSession(sessionId: string, projectRoot?: string): void {
writeFileSync(join(ensureRunsDir(projectRoot), '.current_session'), sessionId, 'utf-8');
}
export function clearCurrentSession(projectRoot?: string): void {
const path = join(ensureRunsDir(projectRoot), '.current_session');
try {
writeFileSync(path, '', 'utf-8');
} catch {
// silent
}
}
export function readIndex(projectRoot?: string): IndexFile {
const path = join(ensureRunsDir(projectRoot), 'index.json');
try {
return JSON.parse(readFileSync(path, 'utf-8')) as IndexFile;
} catch {
return { schema_version: INDEX_SCHEMA_VERSION, entries: [] };
}
}
export function writeIndex(index: IndexFile, projectRoot?: string): void {
const runsDir = ensureRunsDir(projectRoot);
writeFileSync(join(runsDir, 'index.json'), JSON.stringify(index, null, 2), 'utf-8');
}
export function updateIndexEntry(entry: IndexEntry, projectRoot?: string): void {
const idx = readIndex(projectRoot);
const existing = idx.entries.find(e => e.id === entry.id);
if (existing) {
Object.assign(existing, entry);
} else {
idx.entries.push({ ...entry });
}
writeIndex(idx, projectRoot);
}
export function findInProgressSessions(projectRoot?: string): IndexEntry[] {
const idx = readIndex(projectRoot);
return idx.entries.filter(e => e.status === 'in_progress');
}

View File

@@ -0,0 +1,236 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
ensureRunsDir,
readCurrentSession,
writeCurrentSession,
clearCurrentSession,
updateIndexEntry,
findInProgressSessions,
readIndex,
type IndexEntry,
} from './runs-dir.js';
export interface SessionJson {
session_id: string;
task: string;
start_time: string;
end_time?: string;
status: 'in_progress' | 'completed';
expected_record_types?: string[];
total_records?: number;
}
export function generateSessionId(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
return `adhoc_${y}${m}${d}_${h}${min}`;
}
export function isoNow(): string {
return new Date().toISOString();
}
export function createSession(
task: string,
sessionId?: string,
projectRoot?: string,
): string {
const sid = sessionId || generateSessionId();
const runsDir = ensureRunsDir(projectRoot);
const sessionDir = join(runsDir, sid);
mkdirSync(sessionDir, { recursive: true });
const session: SessionJson = {
session_id: sid,
task,
start_time: isoNow(),
status: 'in_progress',
expected_record_types: ['data', 'change', 'conversation'],
};
writeFileSync(join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), 'utf-8');
writeCurrentSession(sid, projectRoot);
updateIndexEntry({
id: sid,
type: 'adhoc',
status: 'in_progress',
task,
created: session.start_time,
last_updated: session.start_time,
}, projectRoot);
return sid;
}
export function getSessionDir(sessionId: string, projectRoot?: string): string {
return join(ensureRunsDir(projectRoot), sessionId);
}
export function getActiveSession(projectRoot?: string): SessionJson | null {
const sid = readCurrentSession(projectRoot);
if (!sid) return null;
return readSession(sid, projectRoot);
}
export function readSession(sessionId: string, projectRoot?: string): SessionJson | null {
const path = join(getSessionDir(sessionId, projectRoot), 'session.json');
try {
return JSON.parse(readFileSync(path, 'utf-8')) as SessionJson;
} catch {
return null;
}
}
export function updateSession(
sessionId: string,
updates: Partial<SessionJson>,
projectRoot?: string,
): void {
const session = readSession(sessionId, projectRoot) || { session_id: sessionId, task: '', start_time: isoNow(), status: 'in_progress' as const };
Object.assign(session, updates);
writeFileSync(
join(getSessionDir(sessionId, projectRoot), 'session.json'),
JSON.stringify(session, null, 2),
'utf-8',
);
}
export function endSession(sessionId: string, projectRoot?: string): void {
updateSession(sessionId, { status: 'completed', end_time: isoNow() }, projectRoot);
updateIndexEntry({ id: sessionId, type: 'adhoc', status: 'completed', last_updated: isoNow() }, projectRoot);
clearCurrentSession(projectRoot);
}
export function appendToTrail(sessionId: string, records: Record<string, unknown>[], projectRoot?: string): void {
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
appendFileSync(trailPath, lines, 'utf-8');
}
export function readTrail(sessionId: string, projectRoot?: string): Record<string, unknown>[] {
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
try {
const content = readFileSync(trailPath, 'utf-8').trim();
if (!content) return [];
return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>);
} catch {
return [];
}
}
export interface RecoverySummary {
sessionId: string;
task: string;
recentActivity: IndexEntry[];
userCorrections: Record<string, unknown>[];
unresolvedIssues: string[];
recommendedPriorities: string[];
level: number;
}
export function recoverContext(
sessionId: string,
level: number,
projectRoot?: string,
): RecoverySummary {
const session = readSession(sessionId, projectRoot);
const idx = readIndex(projectRoot);
const recentActivity = idx.entries.slice(-5);
const trail = readTrail(sessionId, projectRoot);
const userCorrections = trail.filter(r => r['action_type'] === 'user_correction');
const summary: RecoverySummary = {
sessionId,
task: session?.task || '(unknown)',
recentActivity,
userCorrections,
unresolvedIssues: [],
recommendedPriorities: [],
level,
};
if (level >= 1) {
const last = trail.slice(-3);
if (last.length > 0) {
summary.recommendedPriorities.push(`Last action: ${JSON.stringify(last[last.length - 1]?.['action'] || 'none')}`);
}
}
if (level >= 3) {
summary.recommendedPriorities.push(`Full trail: ${trail.length} records`);
}
let checkCount = 0;
for (const entry of recentActivity) {
if (entry.status === 'in_progress' && entry.id !== sessionId) {
summary.unresolvedIssues.push(`Unfinished session: ${entry.id} (${entry.task || 'no task'})`);
checkCount++;
if (checkCount >= 3) break;
}
}
return summary;
}
export function checkUnfinishedSessions(projectRoot?: string): IndexEntry[] {
return findInProgressSessions(projectRoot);
}
export function generateSessionSummary(sessionId: string, projectRoot?: string): string {
const session = readSession(sessionId, projectRoot);
const trail = readTrail(sessionId, projectRoot);
const corrections = trail.filter(r => r['action_type'] === 'user_correction');
const changes = trail.filter(r => r['action'] === 'edit_file' || r['action'] === 'create_file' || r['action'] === 'delete_file');
const lines: string[] = [
`# Session Summary | ${sessionId}`,
'',
`## Task: ${session?.task || '(unknown)'}`,
`## Time: ${session?.start_time || '?'}${session?.end_time || 'in_progress'}`,
`## Status: ${session?.status || 'unknown'}`,
'',
'## Completed Work',
];
for (const r of trail) {
if (r['action']) {
lines.push(`- ${r['action']}: ${r['detail'] || r['reason'] || '(no detail)'}`);
}
}
if (corrections.length > 0) {
lines.push('', '## User Corrections');
for (const c of corrections) {
lines.push(`- Original: ${c['original_claim']}`);
lines.push(` Correction: ${c['correction']}`);
if (c['principle_extracted']) {
lines.push(` Principle: ${c['principle_extracted']}`);
}
}
}
if (changes.length > 0) {
lines.push('', '## Files Changed');
const fileSet = new Set<string>();
for (const c of changes) {
const files = c['files'];
if (Array.isArray(files)) {
for (const f of files) fileSet.add(String(f));
}
}
for (const f of fileSet) lines.push(`- ${f}`);
}
lines.push('', '## Stats');
lines.push(`- Total records: ${trail.length}`);
lines.push(`- Corrections: ${corrections.length}`);
lines.push(`- File changes: ${changes.length}`);
return lines.join('\n');
}

View File

@@ -271,7 +271,9 @@ function buildNumstatMap(
async function getUncommittedDiff( async function getUncommittedDiff(
gitRoot: string, gitRoot: string,
inProgress: string | null, inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> { ): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null; const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] = const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
: runGit(['diff', '--cached', '--name-status'], gitRoot), : runGit(['diff', '--cached', '--name-status'], gitRoot),
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot), runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''), hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''), hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits hasCommits
? runGit(['diff', '--cached', 'HEAD'], gitRoot) ? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
: runGit(['diff', '--cached'], gitRoot), : runGit(['diff', ...ws, '--cached'], gitRoot),
]); ]);
const allChanged = parseNameStatus(nameStatusOut ?? ''); const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -347,11 +349,13 @@ async function getCommittedDiff(
base: string, base: string,
label: string, label: string,
inProgress: string | null, inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> { ): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([ const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot), runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot), runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
runGit(['diff', base, 'HEAD'], gitRoot), runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
]); ]);
const allChanged = parseNameStatus(nameStatusOut ?? ''); const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -383,23 +387,23 @@ async function getCommittedDiff(
* the directory is not a git repository. On a null committed-mode base, falls * the directory is not a git repository. On a null committed-mode base, falls
* back to uncommitted and labels the result accordingly. * back to uncommitted and labels the result accordingly.
*/ */
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> { export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise<GitDiffResult | null> {
const gitRoot = await resolveGitRoot(cwd); const gitRoot = await resolveGitRoot(cwd);
if (!gitRoot) return null; if (!gitRoot) return null;
const inProgress = await detectInProgress(gitRoot); const inProgress = await detectInProgress(gitRoot);
if (mode === 'uncommitted') { if (mode === 'uncommitted') {
return getUncommittedDiff(gitRoot, inProgress); return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
} }
const { base, label } = await resolveCommittedBase(gitRoot); const { base, label } = await resolveCommittedBase(gitRoot);
if (!base) { if (!base) {
// Fall back to uncommitted with a descriptive label // Fall back to uncommitted with a descriptive label
const result = await getUncommittedDiff(gitRoot, inProgress); const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
return { ...result, base_label: label }; return { ...result, base_label: label };
} }
return getCommittedDiff(gitRoot, base, label, inProgress); return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
} }
// ── Phase 2: Write helpers ───────────────────────────────────────────────── // ── Phase 2: Write helpers ─────────────────────────────────────────────────

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { deduplicate } from '../strategies/deduplication.js';
import type { DcpMessage } from '../messages.js';
describe('deduplicate', () => {
it('removes consecutive identical tool_call+tool_result pairs', () => {
const messages: DcpMessage[] = [
{ role: 'user', content: 'search for x' },
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '1' },
// Duplicate pair
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '2' },
];
const { messages: result, stats } = deduplicate(messages);
expect(result).toHaveLength(3); // user + first pair
expect(stats.removedCount).toBe(2);
});
it('preserves non-duplicate content', () => {
const messages: DcpMessage[] = [
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '1' },
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result
];
const { messages: result, stats } = deduplicate(messages);
expect(result).toHaveLength(4);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { toDcpMessages, fromDcpMessages } from '../messages.js';
describe('toDcpMessages', () => {
it('converts user messages', () => {
const result = toDcpMessages([{ role: 'user', content: 'hello' }]);
expect(result[0].role).toBe('user');
expect(result[0].content).toBe('hello');
});
it('marks Error: content as isError', () => {
const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]);
expect(result[0].isError).toBe(true);
});
});
describe('fromDcpMessages', () => {
it('round-trips messages', () => {
const original = [{ role: 'user', content: 'hello' }];
expect(fromDcpMessages(toDcpMessages(original))).toEqual(original);
});
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { purgeErrors } from '../strategies/purge-errors.js';
import type { DcpMessage } from '../messages.js';
describe('purgeErrors', () => {
it('removes tool results where content starts with Error:', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' },
{ role: 'tool', content: '{"files":[]}', tool_call_id: '2' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(1);
expect(stats.removedCount).toBe(1);
});
it('removes empty tool results', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: '', tool_call_id: '1' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(0);
expect(stats.removedCount).toBe(1);
});
it('preserves valid tool results', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(1);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { transformMessages } from '../transform.js';
import type { DcpMessage } from '../messages.js';
describe('transformMessages', () => {
it('applies dedup then purge in order', () => {
const input: DcpMessage[] = [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result', tool_call_id: '1' },
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result', tool_call_id: '2' }, // Dup
];
const { messages, stats } = transformMessages('test-chat', input);
expect(stats.removedCount).toBeGreaterThan(0);
expect(messages.length).toBeLessThan(input.length);
});
it('handles empty input', () => {
const { messages, stats } = transformMessages('empty', []);
expect(messages).toHaveLength(0);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,4 @@
export { transformMessages } from './transform.js';
export type { DcpMessage } from './messages.js';
export { toDcpMessages, fromDcpMessages } from './messages.js';
export { getDcpState, clearDcpState } from './state.js';

View File

@@ -0,0 +1,34 @@
// DCP message shape adapter.
// Converts between BooCode MessagePart[] and the DCP internal shape.
// Clean-room implementation — no AGPL source copied.
export interface DcpMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
tool_call_id?: string;
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
isError?: boolean;
}
export function toDcpMessages(parts: any[]): DcpMessage[] {
return parts.map((p: any) => {
const msg: DcpMessage = { role: p.role, content: p.content ?? '' };
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
if (p.tool_calls) msg.tool_calls = p.tool_calls;
if (p.isError) msg.isError = true;
if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) {
msg.isError = true;
}
return msg;
});
}
export function fromDcpMessages(msgs: DcpMessage[]): any[] {
return msgs.map((m) => ({
role: m.role,
content: m.content,
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.isError ? { isError: true } : {}),
}));
}

View File

@@ -0,0 +1,27 @@
// Per-chat session state for DCP.
// Tracks last transform timestamp and message count to avoid re-processing.
interface ChatDcpState {
lastTransformAt: number;
lastMessageCount: number;
}
const chatStates = new Map<string, ChatDcpState>();
export function getDcpState(chatId: string): ChatDcpState | undefined {
return chatStates.get(chatId);
}
export function setDcpState(chatId: string, messageCount: number): void {
chatStates.set(chatId, { lastTransformAt: Date.now(), lastMessageCount: messageCount });
}
export function clearDcpState(chatId: string): void {
chatStates.delete(chatId);
}
export function shouldTransform(chatId: string, messageCount: number): boolean {
const state = chatStates.get(chatId);
if (!state) return true;
return state.lastMessageCount !== messageCount;
}

View File

@@ -0,0 +1,50 @@
import type { DcpMessage } from '../messages.js';
export function deduplicate(messages: DcpMessage[]): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
const result: DcpMessage[] = [];
let removedCount = 0;
let freedTokens = 0;
let i = 0;
while (i < messages.length) {
const current: DcpMessage = messages[i]!;
const next = messages[i + 1];
if (
current.role === 'assistant' &&
current.tool_calls &&
next &&
next.role === 'tool' &&
next.tool_call_id === current.tool_calls[0]?.id
) {
const nextNext = messages[i + 2];
const nextNextNext = messages[i + 3];
if (
nextNext &&
nextNext.role === 'assistant' &&
nextNext.tool_calls &&
nextNextNext &&
nextNextNext.role === 'tool' &&
nextNextNext.tool_call_id === nextNext.tool_calls[0]?.id &&
nextNext.tool_calls[0]?.name === current.tool_calls[0]?.name &&
nextNext.tool_calls[0]?.arguments === current.tool_calls[0]?.arguments &&
nextNextNext.content === next.content
) {
result.push(current, next);
i += 4;
removedCount += 2;
freedTokens += Math.ceil(nextNext.content.length / 4);
freedTokens += Math.ceil(current.content.length / 4);
} else {
result.push(current);
i++;
}
} else {
result.push(current);
i++;
}
}
return { messages: result, stats: { removedCount, freedTokens } };
}

View File

@@ -0,0 +1,34 @@
// Purge-errors strategy — removes failed/empty tool_result entries.
// Clean-room implementation.
import type { DcpMessage } from '../messages.js';
const ERROR_PREFIXES = ['Error:', 'error:', 'Error: '];
const DEFAULT_WINDOW = 5;
export function purgeErrors(
messages: DcpMessage[],
windowSize: number = DEFAULT_WINDOW,
): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
const result: DcpMessage[] = [];
let removedCount = 0;
let freedTokens = 0;
for (const msg of messages) {
if (msg.role === 'tool') {
const shouldRemove =
msg.isError ||
ERROR_PREFIXES.some((p) => msg.content.startsWith(p)) ||
msg.content.trim() === '';
if (shouldRemove) {
removedCount++;
freedTokens += Math.ceil(msg.content.length / 4);
continue; // Skip this message
}
}
result.push(msg);
}
return { messages: result, stats: { removedCount, freedTokens } };
}

View File

@@ -0,0 +1,52 @@
// Transform orchestrator — runs DCP strategies in sequence.
// Clean-room implementation.
import type { DcpMessage } from './messages.js';
import { deduplicate } from './strategies/deduplication.js';
import { purgeErrors } from './strategies/purge-errors.js';
import { getDcpState, setDcpState, shouldTransform } from './state.js';
export interface TransformStats {
removedCount: number;
freedTokens: number;
dedupRemoved: number;
purgeRemoved: number;
}
export interface TransformResult {
messages: DcpMessage[];
stats: TransformStats;
}
export function transformMessages(chatId: string, messages: DcpMessage[]): TransformResult {
if (!shouldTransform(chatId, messages.length)) {
return { messages, stats: { removedCount: 0, freedTokens: 0, dedupRemoved: 0, purgeRemoved: 0 } };
}
let m = messages;
// Step 1: Deduplicate
const dedupResult = deduplicate(m);
m = dedupResult.messages;
const dedupRemoved = dedupResult.stats.removedCount;
// Step 2: Purge errors
const purgeResult = purgeErrors(m);
m = purgeResult.messages;
const purgeRemoved = purgeResult.stats.removedCount;
const totalRemoved = dedupRemoved + purgeRemoved;
const totalFreed = dedupResult.stats.freedTokens + purgeResult.stats.freedTokens;
setDcpState(chatId, messages.length);
return {
messages: m,
stats: {
removedCount: totalRemoved,
freedTokens: totalFreed,
dedupRemoved,
purgeRemoved,
},
};
}

View File

@@ -131,23 +131,13 @@ export function isManagedFlag(flag: string): boolean {
const SHADOW_CONTEXT = ['-c', '--ctx-size']; const SHADOW_CONTEXT = ['-c', '--ctx-size'];
const SHADOW_CACHE = ['-ctk', '--cache-type-k', '-ctv', '--cache-type-v']; // Empty: agents should be able to opt into cache-type flags (lift analysis
// found these are high-value features, not safety concerns).
const SHADOW_CACHE: string[] = [];
const SHADOW_SPEC = [ // Empty: ngram speculative decoding is a performance feature agents should
'--spec-default', // be able to enable.
'--spec-type', const SHADOW_SPEC: string[] = [];
'--spec-ngram-size-n',
'--spec-ngram-size',
'--draft-min',
'--draft-max',
'--spec-draft-n-max',
'--spec-draft-n-min',
'--spec-draft-p-min',
'--spec-draft-p-split',
'--spec-ngram-mod-n-match',
'--spec-ngram-mod-n-min',
'--spec-ngram-mod-n-max',
];
const SHADOW_TEMPLATE = [ const SHADOW_TEMPLATE = [
'--chat-template', '--chat-template',
@@ -160,7 +150,6 @@ const SHADOW_TEMPLATE = [
// Shadowing flags that take no value — a boolean switch — so the stripper must // Shadowing flags that take no value — a boolean switch — so the stripper must
// not also drop the following token. // not also drop the following token.
const VALUELESS_SHADOW_FLAGS: ReadonlySet<string> = new Set([ const VALUELESS_SHADOW_FLAGS: ReadonlySet<string> = new Set([
'--spec-default',
'--jinja', '--jinja',
'--no-jinja', '--no-jinja',
]); ]);

View File

@@ -0,0 +1,68 @@
// Loop detectors — detects repetitive patterns in assistant output
// that indicate a model is stuck in a loop.
export interface LoopDetectionResult {
isLoop: boolean;
reason?: string;
confidence: number; // 0-1
}
const REPEATED_PHRASE_MIN_COUNT = 4;
const REPEATED_TOOL_MIN_COUNT = 3;
export function detectContentRepeat(messages: string[]): LoopDetectionResult {
if (messages.length < REPEATED_PHRASE_MIN_COUNT) {
return { isLoop: false, confidence: 0 };
}
const recent = messages.slice(-REPEATED_PHRASE_MIN_COUNT);
const unique = new Set(recent);
if (unique.size === 1) {
return {
isLoop: true,
reason: `Same content repeated ${REPEATED_PHRASE_MIN_COUNT} times`,
confidence: 0.9,
};
}
if (unique.size <= 2 && recent.length >= 4) {
return {
isLoop: true,
reason: 'Content oscillating between two variants',
confidence: 0.7,
};
}
return { isLoop: false, confidence: 0 };
}
export function detectToolLoop(toolNames: string[]): LoopDetectionResult {
if (toolNames.length < REPEATED_TOOL_MIN_COUNT) return { isLoop: false, confidence: 0 };
const recent = toolNames.slice(-REPEATED_TOOL_MIN_COUNT);
const unique = new Set(recent);
if (unique.size === 1) {
return {
isLoop: true,
reason: `Same tool "${recent[0]}" called ${REPEATED_TOOL_MIN_COUNT} times consecutively`,
confidence: 0.85,
};
}
return { isLoop: false, confidence: 0 };
}
export function detectDoomLoop(
messages: string[],
toolNames: string[],
): LoopDetectionResult {
const contentResult = detectContentRepeat(messages);
if (contentResult.isLoop) return contentResult;
const toolResult = detectToolLoop(toolNames);
if (toolResult.isLoop) return toolResult;
return { isLoop: false, confidence: 0 };
}

View File

@@ -57,11 +57,21 @@ interface ConfigLike {
LLAMA_SIDECAR_URL?: string; LLAMA_SIDECAR_URL?: string;
} }
export function resolveRoute(agent: AgentLike | null): RoutingInfo { export function resolveRoute(
agent: AgentLike | null,
config?: ConfigLike,
): RoutingInfo {
// When llama_extra_args are explicitly set, route through sidecar with them.
const flags = agent?.llama_extra_args; const flags = agent?.llama_extra_args;
if (flags && flags.length > 0) { if (flags && flags.length > 0) {
return { route: 'sidecar', flags }; return { route: 'sidecar', flags };
} }
// When LLAMA_SIDECAR_URL is configured (even without per-agent flags),
// route through sidecar to pick up the default base args (cache quant,
// spec decoding, slot save, etc.). Fall back to llama-swap otherwise.
if (config?.LLAMA_SIDECAR_URL) {
return { route: 'sidecar', flags: [] };
}
return { route: 'swap', flags: null }; return { route: 'swap', flags: null };
} }
@@ -70,15 +80,13 @@ export function upstreamModel(
modelId: string, modelId: string,
agent?: AgentLike | null, agent?: AgentLike | null,
): LanguageModel { ): LanguageModel {
const { route, flags } = resolveRoute(agent ?? null); const { route, flags } = resolveRoute(agent ?? null, config);
if (route === 'sidecar') { if (route === 'sidecar') {
const url = config.LLAMA_SIDECAR_URL; const url = config.LLAMA_SIDECAR_URL;
if (!url) { if (!url) {
throw new Error( throw new Error(`Sidecar route selected but LLAMA_SIDECAR_URL is not set`);
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
);
} }
return sidecarProvider(url, flags!).chatModel(modelId); return sidecarProvider(url, (flags ?? [])).chatModel(modelId);
} }
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId); return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
} }

View File

@@ -0,0 +1,45 @@
// ToolShim — recovers structured tool calls from plain-text model output.
// When the model emits tool calls as plain text instead of structured JSON,
// this shim attempts to parse and recover them.
export interface ParsedToolCall {
id: string;
name: string;
arguments: string;
}
const TOOL_CALL_PATTERN = /<tool_call>\s*<name>(.+?)<\/name>\s*<arguments>(.+?)<\/arguments>\s*<\/tool_call>/gs;
const JSON_TOOL_PATTERN = /\{\s*"name":\s*"([^"]+)",\s*"arguments":\s*({.+?})\s*\}/gs;
export function extractToolCalls(text: string): ParsedToolCall[] {
const calls: ParsedToolCall[] = [];
let match: RegExpExecArray | null;
// Try XML-style tool calls (common in Qwen output)
const xmlRegex = new RegExp(TOOL_CALL_PATTERN);
while ((match = xmlRegex.exec(text)) !== null) {
calls.push({
id: `call_${calls.length}`,
name: match[1]!.trim(),
arguments: match[2]!.trim(),
});
}
if (calls.length > 0) return calls;
// Try JSON-style tool calls
const jsonRegex = new RegExp(JSON_TOOL_PATTERN);
while ((match = jsonRegex.exec(text)) !== null) {
calls.push({
id: `call_${calls.length}`,
name: match[1]!.trim(),
arguments: match[2]!.trim(),
});
}
return calls;
}
export function hasToolCallMarkup(text: string): boolean {
return TOOL_CALL_PATTERN.test(text) || JSON_TOOL_PATTERN.test(text);
}

Some files were not shown because too many files have changed in this diff Show More