Compare commits

...

38 Commits

Author SHA1 Message Date
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
e04d0fdaa8 feat(coder): unified Plan/Ask/Bypass permission picker
Replace the raw per-agent mode dropdown in the BooCoder composer with a
curated three-option permission ladder mapped generically onto each
provider's native modes: `plan` id -> Plan, default -> Ask, isUnattended
-> Bypass (claude bypassPermissions, qwen yolo, opencode full-access).
modeId stays the single wire field; the active unified mode is derived
from it (no contracts change).

Native BooCode gains its own mode set: Ask stages to the pending-changes
queue (today's behavior), Bypass auto-applies the queue to disk after the
turn (interactive messages path + task dispatcher path), Plan falls back
to Ask. The shared apps/server inference engine is left untouched.

Also preserve isUnattended on live-probed ACP modes so opencode's bypass
mode stays detectable from the wire.

Coder 373 tests green; coder + web typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:14:21 +00:00
da36344d0b style(web): outline the slash-picker chevron buttons
Give the expand chevrons the BooCoder outline-button look (border-border
bg-background, hover:bg-muted, filled when expanded) instead of the borderless
ghost style. Applies to both BooChat's flat menu and BooCoder's grouped menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:00:49 +00:00
875cae0843 fix(coder): parse YAML block-scalar descriptions in slash command discovery
Most plugin/han SKILL.md and command files write `description:` as a folded
block scalar (`>` / `|`) with the text on the following indented lines. The
old single-line frontmatter reader captured the literal `>`, so the slash
menu showed garbage/blank descriptions for nearly all of them. frontmatterField
now collapses folded blocks (join with spaces) and preserves literal blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:00:49 +00:00
4caa5f91ff docs: CLAUDE.md notes for Orchestrator + gitignored .env.host
Document the in-app Orchestrator engine and its load-bearing read-only
invariant in apps/coder/CLAUDE.md, and note that apps/coder/.env.host is
now gitignored (recreated from .env.example with CLAUDE_SDK_BACKEND=1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:48:50 +00:00
1d416d0cf9 docs: refresh README + CURRENT.md for v2.7.17 (Orchestrator)
Bring README current (was v2.2.1): add the Orchestrator, the Files/Git diff
panel, persistent agent sessions + claude Agent-SDK, fix the provider list
(5 — cursor/copilot retired), drop the broken AGENTS.md link, update latest
release + planned. Refresh CURRENT.md to v2.7.17 on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:43:53 +00:00
bfda61e27e chore: stop tracking apps/coder/.env.host
Untrack the host env file (git rm --cached, kept on disk for the boocoder
service) and widen .gitignore to .env.* (re-including .env.example) so env
files no longer get committed. The file's prior contents (dev DB password +
internal Tailscale URLs; no API keys) remain in history — left as-is given the
single-user Tailscale-only threat model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:32:03 +00:00
a734615480 docs: archive shipped openspec changes, refresh roadmap + DEFERRED-WORK
Move openspec/changes/{contracts-ssot,orchestrator} → archived/ (both shipped,
v2.7.13 and v2.7.17). Mark the roadmap's "Write/edit robustness" and "Claude
provider SDK" milestones as shipped (fuzzy-match.ts + checkpoints.ts; the
claude-sdk backend is live via CLAUDE_SDK_BACKEND in .env.host) and add a
v2.7.12–v2.7.17 shipped summary. Flag DEFERRED-WORK.md as superseded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:30:01 +00:00
301 changed files with 19295 additions and 462 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules
dist
.env
.env.*
!.env.example
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
.claude/
@@ -18,3 +20,4 @@ data/*
!data/mcp.example.json
!data/coder-providers.example.json
codecontext/fork.tar.gz
/Arena

View File

@@ -2,6 +2,36 @@
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
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`.
## v2.7.17-orchestrator — 2026-06-03
Brings the deterministic multi-agent "conductor" into the app as the **Orchestrator**: launch any read-only Han flow (research, code-review, investigate, architectural-analysis, security-review, …) from BooChat or BooCoder and watch each specialist agent stream live in a Paseo-style run pane, ending with an evidence-disciplined, adversarially-validated report — all on free local Qwen, persisted and resumable. Built and audited end-to-end via `paseo-epic` in an isolated worktree, on top of the prior `/opt/boocode/conductor` standalone CLI: the conductor's 22 flow definitions, Spine factory, and Han evidence/YAGNI contracts were re-homed into `apps/coder/src/conductor`, and a new DB-backed flow-runner (`flow_runs`/`flow_steps`) dispatches each step as a real BooCoder task through the existing dispatcher — reusing its streaming→WS-frame pipeline and worktree-as-read-snapshot, with an `onTaskTerminal` hook that advances the wave and a startup resume that re-dispatches in-flight steps after a coder restart. Read-only is enforced hard: every step is dispatched `qwen --approval-mode plan`, an adversarial-security review caught and closed a bypass where a qwen-unavailable task silently fell through to write-capable native inference (now fails closed), and the ACP path's mode-set was made fail-closed too. The UI adds a fourth `orchestrator` pane kind (collapsed agent roster, expand-one live stream, report on top), a Workflow button + slash flows on the shared `ChatInput` for full BooChat/BooCoder parity, a "New Orchestrator" entry in the + and split menus, a category-grouped launcher dialog, runs history, and export (copy / save-to-file / send-to-chat) — fed by two new `flow_run_*` WS frames on a coder user channel. Qwen-only by design (Claude Code remains the Claude path); the existing model-competition Arena stays a separate feature. The flow launcher and the `/` slash menu both carry chevron-expandable per-item explanations (an always-on one-liner expands to a 12 sentence what-it-does / when-to-use blurb, condensed from each Han skill's own description), with a "read-only" pill pinned in the launcher and the fast/concise toggle wired through to the workers. Spec/plan in `openspec/changes/orchestrator`; coder 373 tests green (42 new scheduler/resume/read-only decision tests), contracts/coder/server builds + web tsc clean. Built on `v2.7.16-container-git-safedir`; pairs conceptually with the earlier `v2.7.12-audit-cleanup` multi-agent orchestration.

View File

@@ -74,11 +74,11 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Its env file `apps/coder/.env.host` is gitignored (`.env.*`, with `!.env.example`) — a fresh host recreates it from `.env.example` (incl. `CLAUDE_SDK_BACKEND=1` for the Claude Agent-SDK backend). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
- `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.
- 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

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
Last updated: 2026-06-02
Last updated: 2026-06-07
- **Last shipped:** `v2.7.8-ember-coder-tabs-model-chips` (2026-06-01)
- **Branch:** `codebase-audit-cleanup` (audit + cleanup epic, off main HEAD)
- **In progress:** Phase 3 — stale comments + docs refresh
- **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`
- **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.

View File

@@ -1,10 +1,10 @@
# boocode
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals) — plus the in-app **Orchestrator**, a deterministic multi-agent conductor that runs read-only Han analysis/review flows on local Qwen.
**Latest release:** `v2.2.1-pane-scoped-chats` (2026-05-26) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
**Latest release:** `v2.7.17-orchestrator` (2026-06-03) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md)
**Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md) · **Roadmap:** [`boocode_roadmap.md`](boocode_roadmap.md)
## Stack
@@ -75,15 +75,16 @@ curl http://100.114.205.53:9502/api/health
## What's shipped
See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlights as of **v2.2.1**:
See [`boocode_roadmap.md`](boocode_roadmap.md) and [`CHANGELOG.md`](CHANGELOG.md) for full version history. Highlights as of **v2.7.17**:
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder)
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder / orchestrator)
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
- **BooCoder (v2.2)**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, Paseo-style provider snapshot (7 providers: boocode, cursor, claude, opencode, goose, qwen, copilot), `AgentComposerBar` (provider / mode / model / thinking), ACP dispatch with inline permission prompts + tool/reasoning streaming, PTY fallback, Arena, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite, **pane-scoped chats** (v2.2.1 — each coder/terminal pane owns its chat)
- **BooCoder**: write tools (`edit_file` with fuzzy matching, `create_file`, `delete_file`, `apply_pending`, `rewind`, git-ref checkpoints), pending-changes queue + a **Files/Git diff panel** (stage / commit / discard), provider snapshot (5 providers: boocode, claude, opencode, goose, qwen — cursor/copilot retired), `AgentComposerBar`, warm ACP + **persistent agent sessions** (opencode HTTP server; claude via the Agent SDK with native session resume) + PTY fallback, config-backed provider lifecycle, Arena (same task → N models), MCP server, CLI client, human inbox, Boomerang orchestration, pane-scoped chats
- **Orchestrator** (v2.7.17): launch any of 22 read-only Han flows (research, code-review, investigate, architectural-analysis, …) from BooChat or BooCoder via the Workflow button, a slash command, or **+ menu → New Orchestrator**; each step runs as a bounded agent on local Qwen (hard read-only via `qwen --approval-mode plan`), streaming live in a Paseo-style run pane with an evidence-disciplined, adversarially-validated report. Persisted + resumable. `@boocode/contracts` single-sources the cross-app wire contracts (v2.7.13).
## Planned
- **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md).
Most prior roadmap milestones have shipped (see [`boocode_roadmap.md`](boocode_roadmap.md)). What remains is optional/exploratory — e.g. a verify-gate ensembler over pending changes (majority-vote diff ranking). No committed milestones currently in flight.
## License

View File

@@ -1,17 +0,0 @@
NODE_ENV=production
PORT=9502
HOST=100.114.205.53
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
LOG_LEVEL=info
SEARXNG_URL=http://100.114.205.53:8888
GITEA_BASE_URL=https://git.indifferentketchup.com
GITEA_USER=indifferentketchup
GITEA_SSH_HOST=100.114.205.53:2222
MCP_CONFIG_PATH=/data/mcp.json
SKILLS_ROOT=/opt/boocode/data/skills
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
CLAUDE_SDK_BACKEND=1

View File

@@ -32,3 +32,15 @@
- **Claude SDK backend tool RESULTS arrive as `type:'user'` SDK messages** (tool_result content blocks): `mapSdkMessage` (`claude-sdk-map.ts`) MUST map the `user` case → a terminal `tool_update` (completed/failed + output), else the tool_call persists `status:'running'` and the UI spinner never stops. The dispatcher's `tool_update` path then publishes + persists it.
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in.
- **A new per-message coder field silently drops unless you update every mapper**: the HTTP read SELECT + `mapCoderMessageRow` (`apps/coder/src/routes/messages.ts`), **the WS `snapshot` SELECT (`apps/coder/src/routes/ws.ts`)** — it has its OWN column list and the client's `snapshot` handler `setMessages`-overwrites the HTTP load, so a field present in the HTTP route but absent here shows live yet vanishes on refresh — `CoderPane.tsx` (`RawCoderMessage`/`CoderMessage`/`mapCoderTimelineRow` + the live `message_complete` WS reducer), `CoderMessageWire` (`CoderMessageList.tsx`), and `api/types.ts`. The client `mapCoderTimelineRow` whitelists fields — easiest to forget. This bit `model` twice: the client chain (`v2.7.9`) and then the WS snapshot SELECT (`v2.7.11`) — the chip showed live but vanished on coder refresh until both were fixed.
## Orchestrator (v2.7.17)
- **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`).
## 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';
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
import { codeReview } from './code-review.js';
import { parallelResearch } from './parallel-research.js';
const spines: Spine[] = [
// analysis / research
@@ -53,7 +54,7 @@ const spines: Spine[] = [
stakeholderSummary,
];
const bespoke: Flow[] = [codeReview];
const bespoke: Flow[] = [codeReview, parallelResearch];
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;
}
export type StepKind = 'agent' | 'code';
export type StepKind = 'agent' | 'code' | 'approval';
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
export interface Step {
/** unique id within the flow; other steps depend on it by this id */
@@ -46,6 +48,8 @@ export interface Step {
kind: StepKind;
/** ids that must complete (or skip) before this step runs */
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) */
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.
import { WRITE_TOOLS } from './services/tools/index.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
import { registerMessageRoutes } from './routes/messages.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 { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerRunsRoutes } from './routes/runs.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerLifecycleRoutes } from './routes/lifecycle.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
// Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's
// onTaskTerminal hook.
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 { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.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 { publishAgentStatus } from './services/agent-status-publish.js';
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.
// The inference runner calls enqueue() which fires asynchronously — we hook
// into the enqueue to set context before the run starts.
// Wrap the inference runner to bind the write-tool context around each run.
// enqueue() starts its async loop synchronously, so wrapping the call in
// 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 = {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
// Set the inference context so write tools can access sql + sessionId.
// The context persists for the duration of the inference run. Since
// BooCoder is single-user and runs one inference at a time per session,
// this module-level state is safe.
setInferenceContext({ sql, sessionId, taskId: null });
inference.enqueue(sessionId, chatId, assistantId, user);
enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => {
runWithInferenceContext({ sql, sessionId, taskId: null, permissionMode }, () => {
inference.enqueue(sessionId, chatId, assistantId, user);
});
},
cancel: async (sessionId: string, chatId: string) => {
const result = await inference.cancel(sessionId, chatId);
clearInferenceContext();
return result;
// No context to clear — AsyncLocalStorage scopes it to each run's own chain.
return inference.cancel(sessionId, 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
// 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
// (a later phase); resume on startup is a later phase too.
// terminal callback can be wired in.
const flowRunner = createFlowRunner({ sql, broker, log: app.log, config });
// Phase 4: dispatcher — polls tasks table and runs inference. onTaskTerminal
// notifies the flow-runner when a step's task settles (D-2).
// Arena SEAM (a): build the local-model set from the live llama-swap model list.
// 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({
sql,
inference: inferenceApi,
broker,
log: app.log,
config,
onTaskTerminal: flowRunner.handleTaskTerminal,
onTaskTerminal,
});
dispatcher.start();
// Phase 5: re-advance any flow_runs that were 'running' when the service last
// stopped (D-9). Runs AFTER dispatcher.start() so re-dispatched 'pending' tasks
// are picked up by the dispatcher's startup poll.
// Re-advance in-flight flow_runs and battles after a coder restart. Both run
// AFTER dispatcher.start() so re-dispatched 'pending' tasks are picked up.
void flowRunner.initResume().catch((err) => {
app.log.error(
{ err: err instanceof Error ? err.message : String(err) },
'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 +
// 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);
registerInboxRoutes(app, sql);
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerRunsRoutes(app, sql, flowRunner, dispatcher.cancelExternalTask);
registerArenaRoutes(app, sql, battleRunner, dispatcher.cancelExternalTask, config);
registerProviderRoutes(app, sql, config);
registerWorktreeSafetyRoutes(app, sql);
registerLifecycleRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerWebSocket(app, sql, broker);
// 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
* GET /api/arena/:id — get all tasks in an arena
* POST /api/arena/:id/select/:task_id — mark a task as the arena winner
* POST /api/battles — launch a battle
* GET /api/battles?project_id= — list battles for a project
* 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 { z } from 'zod';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
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({
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
// ─── Validation schemas ───────────────────────────────────────────────────────
const UuidParam = z.string().uuid();
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(),
input: z.string().min(1).max(64_000),
contestants: z.array(ContestantSchema).min(2).max(5),
battle_type: z.enum(['coding', 'qa']),
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 {
id: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
state: string;
}
const ListBattlesQuery = z.object({
project_id: z.string().uuid(),
});
export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
// POST /api/arena — create a new arena
app.post('/api/arena', async (req, reply) => {
const parsed = CreateArenaBody.safeParse(req.body);
const CrossExamineBody = z.object({
identity: z.string().min(1).max(200),
model: z.string().min(1).max(200),
});
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) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, input, contestants } = parsed.data;
const arenaId = crypto.randomUUID();
const { description } = parsed.data;
const tasks: TaskRow[] = [];
for (const contestant of contestants) {
const [task] = await sql<TaskRow[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id)
VALUES (
${project_id},
${input},
${contestant.agent ?? null},
${contestant.model ?? null},
${contestant.mode_id ?? null},
${contestant.thinking_option_id ?? null},
${arenaId}
)
RETURNING id, agent, model, mode_id, thinking_option_id, state
`;
tasks.push(task!);
try {
const prompt = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system: [
'You are a battle-prompt writer for an AI Arena.',
'The user gives you a short description of a coding or Q&A challenge.',
'Expand it into a clear, self-contained prompt (26 sentences) that any AI model can act on.',
'Include specific acceptance criteria where helpful.',
'Output ONLY the prompt — no preamble, no labels, no meta-commentary.',
].join(' '),
user: description,
maxTokens: 400,
temperature: 0.6,
});
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);
return {
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,
})),
};
return { battle_id: battleId };
});
// GET /api/arena/:arena_id — list all tasks in an arena
app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => {
const { arena_id } = req.params;
// 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)) {
// GET /api/battles?project_id= — list battles, most-recent-first
app.get('/api/battles', async (req, reply) => {
const parsed = ListBattlesQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid arena_id format' };
return { error: 'invalid query', details: parsed.error.flatten() };
}
const tasks = 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
FROM tasks
WHERE arena_id = ${arena_id}
ORDER BY created_at
const battles = await sql`
SELECT id, project_id, battle_type, prompt, status,
winner_contestant_id, results_path, error,
created_at, updated_at
FROM battles
WHERE project_id = ${parsed.data.project_id}
ORDER BY created_at DESC
LIMIT 100
`;
if (tasks.length === 0) {
reply.code(404);
return { error: 'arena not found' };
}
return { arena_id, tasks };
return { battles };
});
// POST /api/arena/:arena_id/select/:task_id — mark the winner
app.post<{ Params: { arena_id: string; task_id: string } }>(
'/api/arena/:arena_id/select/:task_id',
async (req, reply) => {
const { arena_id, task_id } = req.params;
// Verify the task belongs to this arena
const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>`
SELECT id, state, arena_id FROM tasks WHERE id = ${task_id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
const task = rows[0]!;
if (task.arena_id !== arena_id) {
reply.code(409);
return { error: 'task does not belong to this arena' };
}
// Mark as selected via output_summary prefix (lightweight — no schema change)
await sql`
UPDATE tasks
SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]')
WHERE id = ${task_id}
`;
return { selected: true, task_id, arena_id };
// GET /api/battles/:id — one battle + its contestants + cross-examinations
app.get<{ Params: { id: string } }>('/api/battles/:id', 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 [battle] = await sql<{
id: string;
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 (!battle) {
reply.code(404);
return { error: 'battle not found' };
}
const contestants = await sql`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
created_at, updated_at
FROM contestants
WHERE battle_id = ${id}
ORDER BY created_at ASC
`;
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,6 +4,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import { resolveChatId } from './chat-resolve.js';
import { asPermissionMode } from '../services/tools/types.js';
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
@@ -43,7 +44,13 @@ const SendBody = z.object({
});
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>;
hasActive: (chatId: string) => boolean;
}
@@ -245,7 +252,16 @@ export function registerMessageRoutes(
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.
// Plan = read-only, Ask = stage to the queue (agent can't self-apply),
// Bypass = apply each write immediately. Other mode ids (e.g. an external
// fallback's native mode) leave the gate undefined = legacy behavior.
req.log.info(
{ provider, mode_id, permissionMode: asPermissionMode(mode_id), chatId },
'native enqueue — permission gate',
);
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default', asPermissionMode(mode_id));
reply.code(202);
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };

View File

@@ -54,9 +54,6 @@ DO $$ BEGIN
END IF;
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
CREATE OR REPLACE VIEW human_inbox AS
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;
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 arena_id;
CREATE OR REPLACE VIEW human_inbox AS
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;
-- 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.
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'));
-- 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
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
-- 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'));
END IF;
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', () => {
it('returns not_found for an empty needle', () => {
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);
});
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 () => {
// Setup: create and apply a file
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

@@ -68,11 +68,18 @@ export function deriveModesFromACP(
): { modes: ProviderMode[]; currentModeId: string | null } {
if (modeState?.availableModes?.length) {
return {
modes: modeState.availableModes.map((mode) => ({
id: mode.id,
label: mode.name,
description: mode.description ?? undefined,
})),
// ACP omits the unattended flag; inherit it from the manifest fallback by
// id so the unified permission picker can still detect each agent's bypass
// mode (e.g. opencode `full-access`) from live-probed modes.
modes: modeState.availableModes.map((mode) => {
const fb = fallbackModes.find((f) => f.id === mode.id);
return {
id: mode.id,
label: mode.name,
description: mode.description ?? undefined,
...(fb?.isUnattended ? { isUnattended: true } : {}),
};
}),
currentModeId: modeState.currentModeId ?? null,
};
}

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

@@ -12,12 +12,48 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import type { AgentCommand } from './provider-types.js';
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
/**
* Frontmatter reader between `---` fences. Handles single-line `key: value`
* AND YAML block scalars (`key: >` folded / `key: |` literal) whose value
* spans the following more-indented lines — the shape most plugin SKILL.md
* descriptions use (`description: >`).
*/
function frontmatterField(content: string, field: string): string | undefined {
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!block?.[1]) return undefined;
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
const lines = block[1].split(/\r?\n/);
const keyRe = new RegExp(`^(\\s*)${field}:\\s*(.*)$`);
for (let i = 0; i < lines.length; i++) {
const m = lines[i]?.match(keyRe);
if (!m) continue;
const keyIndent = (m[1] ?? '').length;
const inline = (m[2] ?? '').trim();
// Block scalar: `>` (folded) or `|` (literal), optional chomping `+`/`-`.
if (/^[>|][+-]?$/.test(inline)) {
const folded = inline[0] === '>';
const body: string[] = [];
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j] ?? '';
if (line.trim() === '') {
body.push('');
continue;
}
const indent = line.length - line.trimStart().length;
if (indent <= keyIndent) break; // dedent ends the block
body.push(line.slice(keyIndent + 1));
}
const joined = folded
? body
.map((l) => l.trim())
.join(' ')
.replace(/\s+/g, ' ')
.trim()
: body.join('\n').replace(/\n+$/, '');
return joined || undefined;
}
return inline.replace(/^["']|["']$/g, '').trim() || undefined;
}
return undefined;
}
function readCommandDir(dir: string): AgentCommand[] {

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,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { asPermissionMode } from './tools/types.js';
import { createCheckpoint } from './checkpoints.js';
import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js';
@@ -31,7 +32,13 @@ import {
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
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>;
hasActive: (chatId: string) => boolean;
}
@@ -305,10 +312,13 @@ export function createDispatcher(deps: Deps): {
// ─── Path A: Native Inference ───────────────────────────────────────────────
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; mode_id: string | null; session_id: string | null }): Promise<void> {
const taskId = task.id;
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 {
// Mark running
await sql`
@@ -317,26 +327,29 @@ export function createDispatcher(deps: Deps): {
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 sessionName = 'Task: ' + task.input.slice(0, 40);
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
RETURNING id
`;
const sessionId = session!.id;
let sessionId: string;
if (task.session_id) {
sessionId = task.session_id;
} else {
const sessionName = 'Task: ' + task.input.slice(0, 40);
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
RETURNING id
`;
sessionId = session!.id;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'Task execution', 'open')
RETURNING id
`;
const chatId = chat!.id;
// Link task to session
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
chatId = chat!.id;
// Create user message + streaming assistant
await sql<{ id: string }[]>`
@@ -351,8 +364,9 @@ export function createDispatcher(deps: Deps): {
`;
const assistantId = assistantMsg!.id;
// Enqueue inference
inference.enqueue(sessionId, chatId, assistantId, 'default');
// Enqueue inference — pass the native permission gate (plan/ask/bypass)
// 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)
const finalStatus = await waitForCompletion(assistantId);
@@ -381,7 +395,7 @@ export function createDispatcher(deps: Deps): {
const summary = (msg?.content ?? '').slice(0, 500);
await sql`
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}
`;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
@@ -392,7 +406,7 @@ export function createDispatcher(deps: Deps): {
const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
await sql`
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}
`;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
@@ -402,7 +416,7 @@ export function createDispatcher(deps: Deps): {
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
await sql`
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}
`.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;
* 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 {
/** 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.inFlight.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';
}
/**
* 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
* 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
}
// 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.
for (const s of toRun) {
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.
const persona = step.agent ? await loadPersona(step.agent) : '';
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
// 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()
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}
`;
}
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
}
// ─── run completion ─────────────────────────────────────────────────────────
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
if (updated.count === 0) return;
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error });
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
}
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep(
runId: 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 },
): void {
publishUser({
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function errMsg(e: unknown): string {
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
// mapped back to original offsets.
// 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),
// reimplemented from the general technique — no vendored source.
@@ -31,8 +40,31 @@ export type MatchResult =
| { kind: 'ambiguous'; count: number }
| { 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 {
// 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');
let best = -1;
let bestSpan: { start: number; end: number } | null = null;
// Block-anchor gate for multi-line needles: the first and last lines must match
// 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++) {
const window = contentLines.slice(i, i + n);
const windowJoined = window.map((l) => l.text.trim()).join('\n');
const score = similarity(windowJoined, needleJoined);
if (score > best) {
best = score;
bestSpan = { start: window[0]!.start, end: window[n - 1]!.end };
if (anchored) {
const winFirst = canonicalize(window[0]!.text.trim());
const winLast = canonicalize(window[n - 1]!.text.trim());
if (winFirst !== needleFirst || winLast !== needleLast) continue;
}
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) {
return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end };
}
return null;
if (scored.length === 0) return null;
scored.sort((a, b) => b.score - a.score);
const best = scored[0]!;
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 { dirname } from 'node:path';
import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
import { dirname, join, basename } from 'node:path';
import { randomBytes } from 'node:crypto';
import type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.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 -------------------------------------------------------------------
export interface PendingChange {
@@ -47,6 +158,13 @@ export async function queueEdit(
const resolved = resolveWritePath(projectRoot, filePath);
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[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
@@ -55,6 +173,28 @@ export async function queueEdit(
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(
sql: Sql,
sessionId: string,
@@ -68,6 +208,9 @@ export async function queueCreate(
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
if (existing) return existing;
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
@@ -87,6 +230,9 @@ export async function queueDelete(
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
if (existing) return existing;
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
@@ -110,48 +256,60 @@ export async function applyOne(
}
try {
// Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path);
return await withFileLock(change.file_path, async () => {
// Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path);
switch (change.operation) {
case 'create': {
await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8');
break;
}
case 'edit': {
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const content = await readFile(change.file_path, 'utf8');
const match = locateMatch(content, oldStr);
if (match.kind === 'ambiguous') {
throw new Error(
`old_string matches ${match.count} locations — add surrounding context to disambiguate`,
);
switch (change.operation) {
case 'create': {
await mkdir(dirname(change.file_path), { recursive: true });
await writeFileAtomic(change.file_path, change.diff);
break;
}
if (match.kind === 'not_found') {
throw new Error(
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
);
case 'edit': {
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const raw = await readFile(change.file_path, 'utf8');
// Normalize to LF for matching, then write back in the file's native EOL
// 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(
`old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
);
}
if (plan.kind === 'not_found') {
throw new Error(
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
);
}
if (plan.kind === 'apply') {
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;
}
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
await writeFile(change.file_path, updated, 'utf8');
break;
}
case 'delete': {
// Stash current content in diff for potential rewind
try {
const existing = await readFile(change.file_path, 'utf8');
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
} catch {
// File may already be gone — proceed with status update
case 'delete': {
// Stash current content in diff for potential rewind
try {
const existing = await readFile(change.file_path, 'utf8');
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
} catch {
// File may already be gone — proceed with status update
}
await unlink(change.file_path);
break;
}
await unlink(change.file_path);
break;
}
}
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 };
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 };
});
} catch (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 };
@@ -220,13 +378,13 @@ export async function rewindOne(
);
}
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;
}
case 'delete': {
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8');
await writeFileAtomic(change.file_path, change.diff);
break;
}
}

View File

@@ -32,6 +32,18 @@ const QWEN_PTY_MODES: ProviderMode[] = [
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
];
// Native BooCode (llama-swap) has no agent-native mode vocabulary, so we define
// one that matches the unified permission ladder. `bypass` is the only mode that
// changes behavior (auto-apply staged edits after the turn — dispatcher.ts);
// `plan` falls back to `ask` semantics for native (writes still stage to the
// pending-changes queue). External agents map the same three unified modes onto
// THEIR native ids via the `plan`-id / default / `isUnattended` shape.
const BOOCODE_MODES: ProviderMode[] = [
{ id: 'plan', label: 'Plan', description: 'Read-only analysis (native BooCode falls back to Ask)' },
{ id: 'ask', label: 'Ask Permission', description: 'Stage edits to the pending-changes queue for review' },
{ id: 'bypass', label: 'Bypass', description: 'Auto-apply edits to disk after the turn', isUnattended: true },
];
const CLAUDE_THINKING = [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
@@ -41,6 +53,10 @@ const CLAUDE_THINKING = [
];
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
boocode: {
defaultModeId: 'ask',
modes: BOOCODE_MODES,
},
claude: {
defaultModeId: 'default',
modes: CLAUDE_MODES,

View File

@@ -122,12 +122,14 @@ async function buildProviderEntry(
};
}
// 2. Native boocode → always ready (llama-swap models).
// 2. Native boocode → always ready (llama-swap models). Exposes the unified
// permission modes (plan/ask/bypass) so the composer's permission picker works
// for native BooCode too; `bypass` auto-applies staged edits (dispatcher.ts).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
defaultModeId: null, commands: manifestCommands,
enabled: true, installed: true, models: withConfigModels(llamaModels),
modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
}

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> {
// 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 succeeded = 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 type { ToolDef, ToolContext } from './types.js';
import { queueCreate } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const CreateFileInput = z.object({
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> {
if (context.permissionMode === 'plan') return denyReadOnly('create_file');
const change = await queueCreate(
context.sql,
context.sessionId,
@@ -40,12 +42,11 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
input.content,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'create',
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
};
return finalizeWrite(
context,
projectRoot,
change,
`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 type { ToolDef, ToolContext } from './types.js';
import { queueDelete } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const DeleteFileInput = z.object({
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> {
if (context.permissionMode === 'plan') return denyReadOnly('delete_file');
const change = await queueDelete(
context.sql,
context.sessionId,
@@ -37,12 +39,11 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
input.file_path,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'delete',
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
};
return finalizeWrite(
context,
projectRoot,
change,
`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 type { ToolDef, ToolContext } from './types.js';
import { queueEdit } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const EditFileInput = z.object({
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> {
if (context.permissionMode === 'plan') return denyReadOnly('edit_file');
const change = await queueEdit(
context.sql,
context.sessionId,
@@ -43,12 +45,11 @@ export const editFileTool: ToolDef<EditFileInputT> = {
input.new_string,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'edit',
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
};
return finalizeWrite(
context,
projectRoot,
change,
`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 { listTasksTool } from './list_tasks.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';
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
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 { 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 read it via `getInferenceContext()` during execute.
* Same pattern as BooChat's `loadConfig()` singleton — tools need
* ambient state that can't be threaded through the tool-phase execute
* signature (which is `execute(input, projectRoot, extraRoots?)`).
* Write tools need ambient state (sql, sessionId, the permission gate) that the
* BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
* carry. This used to be a single module-level `let current` — but the inference
* runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
* 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 {
sql: Sql;
sessionId: string;
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;
}
export function clearInferenceContext(): void {
current = null;
/**
* 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
* same store. Concurrent runs each get their own; nothing is shared or cleared
* 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 {
if (!current) {
const ctx = storage.getStore();
if (!ctx) {
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'),
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
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>;
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
input: { type: 'string', description: 'Task description for the child subtask' },
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
model: { type: 'string', description: 'Optional: model override for the subtask' },
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
},
required: ['input'],
},
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
return { error: 'Cannot determine project_id from current session' };
}
const isBg = input.background === true;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
`;
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,
state: task!.state,
background: isBg,
};
},
};

View File

@@ -1,6 +1,22 @@
import type { z } from 'zod';
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 {
type: 'function';
function: {
@@ -21,6 +37,8 @@ export interface ToolContext {
sql: Sql;
sessionId: string;
taskId: string | null;
/** Native-BooCode permission gate for write tools (undefined = legacy behavior). */
permissionMode?: PermissionMode;
}
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)`.
- **`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).
- **`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.
- **`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.

View File

@@ -19,6 +19,8 @@ import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.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 { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js';
@@ -122,6 +124,8 @@ async function main() {
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerInferenceSettingsRoutes(app);
// 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.

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 { 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 type { Sql } from '../db.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
// 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.
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
'/api/projects/:id/git/diff',
async (req, reply) => {
const { id } = req.params;
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
rawMode === 'uncommitted' ? 'uncommitted' :
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) {
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
}
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
).min(1),
});
const WriteFileBody = z.object({
path: z.string().min(1),
content: z.string(),
});
// POST /api/projects/:id/git/stage — stage whole files
app.post<{ Params: { id: string } }>(
'/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
app.get<{ Params: { id: string } }>(
'/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;
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
parent_task_id UUID REFERENCES tasks(id),
arena_id UUID,
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
input TEXT NOT NULL,
@@ -405,3 +404,6 @@ DO $$ BEGIN
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF;
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

@@ -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(
gitRoot: string,
inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
: runGit(['diff', '--cached', '--name-status'], gitRoot),
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
: runGit(['diff', '--cached'], gitRoot),
? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
: runGit(['diff', ...ws, '--cached'], gitRoot),
]);
const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -347,11 +349,13 @@ async function getCommittedDiff(
base: string,
label: string,
inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
runGit(['diff', base, 'HEAD'], gitRoot),
runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
]);
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
* 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);
if (!gitRoot) return null;
const inProgress = await detectInProgress(gitRoot);
if (mode === 'uncommitted') {
return getUncommittedDiff(gitRoot, inProgress);
return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
}
const { base, label } = await resolveCommittedBase(gitRoot);
if (!base) {
// 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 getCommittedDiff(gitRoot, base, label, inProgress);
return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
}
// ── 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

@@ -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;
}
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;
if (flags && flags.length > 0) {
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 };
}
@@ -70,15 +80,13 @@ export function upstreamModel(
modelId: string,
agent?: AgentLike | null,
): LanguageModel {
const { route, flags } = resolveRoute(agent ?? null);
const { route, flags } = resolveRoute(agent ?? null, config);
if (route === 'sidecar') {
const url = config.LLAMA_SIDECAR_URL;
if (!url) {
throw new Error(
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
);
throw new Error(`Sidecar route selected 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);
}

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);
}

View File

@@ -21,6 +21,7 @@ import {
buildMessagesPayload,
loadContext,
} from './payload.js';
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
import {
finalizeCompletion,
finalizeEmpty,
@@ -156,9 +157,20 @@ export async function runAssistantTurn(
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
break;
}
const { session: iterSession, project: iterProject, history } = loaded;
let { session: iterSession, project: iterProject, history } = loaded;
const projectRoot = await resolveProjectRoot(iterProject.path);
try {
const dcpMsgs = toDcpMessages(history);
const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs);
if (stats.removedCount > 0) {
ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages');
history = fromDcpMessages(pruned) as typeof history;
}
} catch (err) {
ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped');
}
// v1.14.0: log step boundary for instrumentation. step_start parts are in
// the schema CHECK but not emitted here — writing to the assistant message
// before the stream phase creates a sequence-0 collision with

View File

@@ -44,7 +44,11 @@ export interface InferenceFrame {
| 'chat_renamed'
| 'error'
| 'flow_run_started'
| 'flow_run_step_updated';
| 'flow_run_step_updated'
// arena frames
| 'battle_started'
| 'contestant_updated'
| 'battle_updated';
message_id?: string;
message_ids?: string[];
chat_id?: string;
@@ -84,6 +88,19 @@ export interface InferenceFrame {
status?: string;
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
// arena frames
battle_id?: string;
battle_type?: 'coding' | 'qa';
prompt?: string;
contestants?: Array<{ id: string; identity: string; model: string; lane: 'local' | 'cloud' }>;
contestant_id?: string;
battle_status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
duration_ms?: number;
tokens_per_sec?: number;
winner_contestant_id?: string | null;
analysis_ready?: boolean;
cross_exam_id?: string;
delta?: string;
}
export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void;

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { Bm25Ranker } from '../bm25.js';
describe('Bm25Ranker', () => {
it('scores documents by term frequency', () => {
const ranker = new Bm25Ranker();
ranker.fit(['the cat sat on the mat', 'the dog chased the cat', 'the bird flew over the mat']);
const results = ranker.rank('cat mat');
expect(results.length).toBeGreaterThan(0);
expect(results[0]!.score).toBeGreaterThan(0);
});
it('returns empty for no matches', () => {
const ranker = new Bm25Ranker();
ranker.fit(['aaa bbb', 'ccc ddd']);
const results = ranker.rank('zzz');
expect(results).toHaveLength(0);
});
it('handles single document corpus', () => {
const ranker = new Bm25Ranker();
ranker.fit(['only document here']);
const results = ranker.rank('document');
expect(results).toHaveLength(1);
});
it('ranks relevant docs higher', () => {
const ranker = new Bm25Ranker();
ranker.fit([
'javascript is a programming language',
'python is also a programming language',
'the weather is nice today',
]);
const results = ranker.rank('javascript programming');
expect(results[0]!.index).toBe(0);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { parseMemoryEntries } from '../entries.js';
describe('parseMemoryEntries', () => {
it('parses a single entry with tags', () => {
const md = '## project: Indentation\n> tags: style\n\nUse two-space indentation\n';
const entries = parseMemoryEntries('style.md', md);
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe('Indentation');
expect(entries[0].topic).toBe('project');
expect(entries[0].tags).toEqual(['style']);
expect(entries[0].content).toContain('two-space');
});
it('parses multiple entries', () => {
const md = [
'## project: Style',
'',
'Use tab indentation',
'',
'## user: Preference',
'',
'Prefer pnpm',
'',
].join('\n');
const entries = parseMemoryEntries('mem.md', md);
expect(entries).toHaveLength(2);
expect(entries[0].topic).toBe('project');
expect(entries[1].topic).toBe('user');
});
});

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { getMemoryRoot, getTopicDir } from '../paths.js';
describe('getMemoryRoot', () => {
it('returns .boocode/memory under project root', () => {
expect(getMemoryRoot('/proj')).toBe('/proj/.boocode/memory');
});
});
describe('getTopicDir', () => {
it('returns project/ under memory root', () => {
expect(getTopicDir('/r/.boocode/memory', 'project')).toBe('/r/.boocode/memory/project');
});
});

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { formatMemoryBlock } from '../prompt.js';
describe('formatMemoryBlock', () => {
it('wraps entries in boocode-memory tags', () => {
const block = formatMemoryBlock(['Use pnpm', 'Tests in vitest']);
expect(block).toContain('<boocode-memory>');
expect(block).toContain('Use pnpm');
expect(block).toContain('</boocode-memory>');
});
it('returns empty string for no entries', () => {
expect(formatMemoryBlock([])).toBe('');
});
});

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { rankByRelevance } from '../recall.js';
import type { MemoryEntry } from '../entries.js';
describe('rankByRelevance', () => {
it('returns entries matching query keywords', () => {
const entries: MemoryEntry[] = [
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
];
const result = rankByRelevance('what indentation?', entries);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Style');
});
});
describe('rankByHybrid', () => {
it('falls back to BM25 when embeddings unavailable', async () => {
const entries: MemoryEntry[] = [
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
];
const { rankByHybrid } = await import('../recall.js');
const result = await rankByHybrid('indentation style', entries);
expect(result.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,67 @@
// BM25 ranker — pure Okapi BM25 scoring. No external deps.
interface Bm25Config {
k1?: number;
b?: number;
}
export class Bm25Ranker {
private k1: number;
private b: number;
private corpus: string[];
private avgDocLen: number;
private idfCache: Map<string, number>;
private docCount: number;
constructor(config?: Bm25Config) {
this.k1 = config?.k1 ?? 1.5;
this.b = config?.b ?? 0.75;
this.corpus = [];
this.avgDocLen = 0;
this.idfCache = new Map();
this.docCount = 0;
}
fit(docs: string[]): void {
this.corpus = docs;
this.docCount = docs.length;
const lengths = docs.map((d) => d.split(/\s+/).length);
this.avgDocLen = lengths.reduce((a, b) => a + b, 0) / lengths.length;
this.idfCache.clear();
}
private tokenize(text: string): string[] {
return text.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
}
private idf(term: string): number {
const cached = this.idfCache.get(term);
if (cached !== undefined) return cached;
const docsWithTerm = this.corpus.filter((d) => this.tokenize(d).includes(term)).length;
const idf = Math.log(1 + (this.docCount - docsWithTerm + 0.5) / (docsWithTerm + 0.5));
this.idfCache.set(term, idf);
return idf;
}
score(query: string, docIndex: number): number {
if (docIndex < 0 || docIndex >= this.corpus.length) return 0;
const doc = this.corpus[docIndex]!;
const queryTerms = this.tokenize(query);
const docTokens = this.tokenize(doc);
const docLen = docTokens.length;
let total = 0;
for (const term of queryTerms) {
const tf = docTokens.filter((t) => t === term).length;
if (tf === 0) continue;
const idfVal = this.idf(term);
total += idfVal * ((tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + this.b * docLen / this.avgDocLen)));
}
return total;
}
rank(query: string, topN: number = 10): Array<{ index: number; score: number }> {
const scores = this.corpus.map((_, i) => ({ index: i, score: this.score(query, i) }));
return scores.sort((a, b) => b.score - a.score).slice(0, topN).filter((s) => s.score > 0);
}
}

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