Compare commits

...

55 Commits

Author SHA1 Message Date
752ea74f43 v2.0.0-final: dispatcher + task queue + agent probing
Phase 4 of v2.0. BooCoder can now queue tasks and dispatch them
through the inference loop autonomously.

Dispatcher (services/dispatcher.ts): in-process setInterval(5s) polls
tasks WHERE state='pending', picks one at a time, creates an isolated
session+chat, enqueues inference with the task's input as the user
message, polls for completion, marks state completed/failed with
output_summary. Single-task-at-a-time for v2.0.0; parallel dispatch
is a Phase 5+ concern. Respects onClose hook for graceful shutdown.

Task routes (routes/tasks.ts): POST /api/tasks (create), GET /api/tasks
(list with state/project filters), GET /api/tasks/:id (detail),
POST /api/tasks/:id/cancel (marks cancelled, aborts if running).

Agent probe (services/agent-probe.ts): on startup, probes PATH for
opencode/goose/claude/pi via which + --version. UPSERTs into
available_agents table. Finds nothing inside the container (expected —
Phase 5 addresses host-agent access via ACP/PTY).

Schema: ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id (links
task to its auto-created inference session for isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:55:18 +00:00
73b53089b0 CLAUDE.md: v2.0.0 architecture docs — BooCoder, DB rename, MCP config, workspace deps
Session learnings applied:
- Database renamed boochat (from boocode), new tables documented
- BooCoder architecture section: workspace dep pattern, write tools,
  coder pane integration, proxy routing
- Environment: MCP_CONFIG_PATH, BooCoder health at :9502
- Workflow: Go binary at /snap/go/current/bin, codecontext fork location
- Conventions: workspace exports with types conditions, Docker build order

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:51:24 +00:00
457c59fb06 v2.0.0: BooCoder frontend — chat pane + diff pane + session picker
Integrates BooCoder as a 'coder' workspace pane within the existing
BooChat SPA at code.indifferentketchup.com. Renamed the placeholder
'agent' pane kind to 'coder' across all types, menus, hooks, and
mobile switcher (Icon: Code instead of Bot).

CoderPane.tsx: split layout with chat area (messages via WS to
boocoder:9502, input bar posting to /api/coder/sessions/:id/messages)
and diff panel (pending changes with Approve/Reject per change plus
Approve All/Reject All). Reuses MarkdownRenderer for message content.

Proxy: Vite dev config adds /api/coder → boocoder:9502 (ordered above
/api per CLAUDE.md proxy-ordering rule). Production: Fastify route in
apps/server/src/index.ts proxies /api/coder/* to http://boocoder:3000
via fetch() pass-through. WS connects directly to :9502 (same
Tailscale network, no proxy needed for WebSocket upgrade).

WorkspacePaneKind mirror updated in both apps/web and apps/server
types. useWorkspacePanes gains coderPane() factory (replaces the old
agent toast stub). Workspace.tsx switch renders CoderPane for
pane.kind === 'coder'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:24:49 +00:00
78455b7efc v2.0.0: BooCoder frontend — chat pane + diff pane + session picker
Phase 3 of v2.0. React + Vite SPA at apps/coder/web/ served by
the coder Fastify server via @fastify/static with SPA fallback.

Chat pane: message list via WS streaming (useSessionStream hook),
input bar, POST /api/sessions/:id/messages on submit, markdown
rendering via react-markdown + remark-gfm, inline tool-call display.

Diff pane: fetches GET /api/sessions/:id/pending, shows pending
changes with file path + operation badge (create/edit/delete),
before/after diff for edits, Approve/Reject per change and
Approve All/Reject All buttons.

Layout: fixed two-pane split (chat 60%, diff 40%). Dark theme
(bg-zinc-900). Desktop-first for v2.0.0.

Session picker (Home page): lists projects and sessions from the
shared DB. No CRUD — use BooChat's UI for that.

Dockerfile updated: builds web app in builder stage, copies dist
to runtime. index.ts registers fastifyStatic + SPA fallback route.

Tailwind v4, React 18, TypeScript strict. ~20 new files, ~370KB
built output. Functional developer tool UI, not polished consumer
product — Phase 7 (v2.0.3) handles polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:04:52 +00:00
d2108b2f8d verification discipline rules + chat naming from assistant response
BOOCHAT.md + BOOCODER.md: 4 verification rules added to both —
verify against running container not source files, never count dist/,
run commands before claiming success, derive counts from commands.

auto_name.ts: chat titles now derived from the assistant's first
response only (user message dropped from naming input). System prompt
updated to "summarize the topic or outcome — do NOT copy the first
few words verbatim." Produces titles like "Fastify Route Setup"
instead of echoing the assistant's opening sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:52:49 +00:00
ce31577d1e v2.0.0-beta: write tools, pending-changes queue, inference loop, API routes
Phase 2 of v2.0. BooCoder is now a functional write-capable chatbot.

Write-path guard: resolveWritePath() uses resolve() (no realpath — files may
not exist for creates) + prefix-check + secret-file deny list (.env, *.pem,
id_rsa*, etc.). 23 unit tests cover traversal attacks.

Pending-changes service: queueEdit/Create/Delete → applyOne/All →
rejectOne/All → rewindOne. Edit diffs stored as JSON {old, new}. All writes
queue before touching disk; apply re-validates the path guard.

5 write tools: edit_file, create_file, delete_file, apply_pending, rewind.
Registered alongside 25 read-only tools from BooChat (30 total, alpha-sorted).
Write tools use a module-level inference context for sql+sessionId injection.

Inference loop via workspace dependency: apps/coder imports
createInferenceRunner, createBroker, ALL_TOOLS from @boocode/server (dist/).
apps/server gains declaration: true + exports map with typed subpath entries.
No code duplication — one inference engine shared by both apps.

API routes: POST /api/sessions/:id/messages (user msg → inference), POST stop,
GET/POST pending-changes CRUD (5 endpoints), WebSocket session streaming.

Dockerfile updated to build apps/server first (coder depends on its .d.ts).
Health endpoint reports tool count: {"ok":true,"db":true,"tools":30}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:53:38 +00:00
006226cce5 v2.0.0-alpha: BooCoder foundation — container, schema, DB rename
Phase 1 of v2.0. BooCoder is live at port 9502 with a health endpoint.

- Database renamed: ALTER DATABASE boocode RENAME TO boochat (one-time).
  All services updated to connect to /boochat. Docker service name stays
  boocode_db (rename is internal to Postgres, not Docker).

- New apps/coder/ app skeleton: Fastify server with health endpoint,
  postgres connection, schema apply on boot. Mirrors apps/server pattern
  but minimal (no inference loop yet — Phase 2).

- Schema: pending_changes (operation queue before /apply), tasks (dispatch
  DAG with state machine), available_agents (startup-probed agent registry),
  human_inbox view (tasks WHERE state IN blocked/failed). All IF NOT EXISTS,
  idempotent on re-run. Same boochat database, different tables.

- Dockerfile: Node 20 bookworm-slim (glibc for future node-pty in Phase 5).
  Multi-stage build matching the existing boocode image pattern.

- docker-compose.yml: boocoder service on 100.114.205.53:9502, /opt:/opt:rw
  mount (write-capable, policy-gated at tool layer), depends on boocode_db.

- BOOCODER.md: container guidance declaring write-tool capability +
  pending-changes discipline.

All 4 services boot and pass health checks. 9 tables in the shared DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:20:29 +00:00
62d818af23 v2.0 implementation plan: 8 phases from foundation to production
Detailed execution plan for all v2.0 sub-versions:

Phase 1 (v2.0.0-alpha): container skeleton, DB rename, schema migration
Phase 2 (v2.0.0-beta): write tools + pending-changes service + fuzz tests
Phase 3 (v2.0.0): frontend diff pane + chat pane + Caddy routing
Phase 4 (v2.0.0-final): dispatcher worker + task queue + agent probing
Phase 5 (v2.0.1): ACP client + PTY fallback + worktree management
Phase 6 (v2.0.2): MCP server (6 tools, stdio, 10-question eval)
Phase 7 (v2.0.3): CLI + human inbox + cost tracking + observation hooks + Boomerang
Phase 8 (v2.0.x): path-guard fuzz, integration tests, docs, production deploy

~2050 LoC total. Phases 1-4 sequential, 5-7 parallelizable after 4.
Risk register covers path-guard bypass, ACP instability, worktree cleanup,
DB rename, MCP eval, Boomerang context leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:09:05 +00:00
531d39ace9 v2.0 proposal update: add AGENTS.md extensions, Boomerang pattern, observation hooks, follow-up batches
Additions from second pass of boocode_code_review.md:

- AGENTS.md extensions: output_schema, exit_expression, execution_strategy
  (qodo-ai/agents MIT), expert_model escape hatch (RA.Aid Apache-2.0)
- Subagent isolation via Boomerang Tasks pattern: orchestrator-only-dispatches,
  down-pass/up-pass context discipline, fresh session per subtask
- Observation hooks: 5-event taxonomy from budi (SessionStart, UserPromptSubmit,
  PostToolUse, SubagentStart, Stop) mapped to WS frames
- Follow-up batches table: PR-resolver, HMAC audit log, blind-validation gate,
  majority-vote ensembler, drift detection, anti-slop, globstar gate, Docker
  sandbox, multi-provider LLM
- Additional repo to clone: qodo-ai/agents for agent.toml schema reference

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:22:57 +00:00
f2974d6887 v2.0 proposal: BooCoder — write tools, pending changes, ACP dispatch, MCP server
Comprehensive roadmap for the v2.0 major version bump. Covers:
- Schema: pending_changes, tasks, available_agents tables + human_inbox view
- Path A: native write tools (edit_file, create_file, delete_file) queuing
  through pending_changes before /apply flushes to disk
- Path B: external agent dispatch via ACP (opencode, goose) or PTY fallback
  (claude, pi) with per-task git worktrees and automatic diff-on-completion
- BooCoder MCP server: 6 tools exposing task primitives over stdio
- Code lifts: agent-hub (Apache-2.0, task DAG), plandex (MIT, diff UX),
  ACP SDK (Apache-2.0, subprocess protocol), Paseo (AGPL, design-only)
- Sub-versions: v2.0.0 (Path A), v2.0.1 (Path B), v2.0.2 (MCP server),
  v2.0.3 (CLI + polish)
- Estimate: ~2200 LoC total

All v1.x dependencies shipped (v1.13 parts, v1.14 outer loop, v1.15 MCP
client, v1.16 codesight). v2.0 is unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:11:16 +00:00
29c7d051b6 v1.16.0-codesight-merge: 4 new codecontext tools — blast radius, hot files, routes, middleware
BooCode wrapper tools for the 4 new MCP tools added to the codecontext
sidecar (Go side committed separately at /opt/forks/codecontext).

- get_blast_radius: reverse-edge BFS — "what breaks if I change this?"
- get_hot_files: most-imported files by incoming edge count
- get_routes: Fastify/Express route extraction via tree-sitter AST
- get_middleware: middleware detection via import + registration patterns

Wrappers follow the existing codecontext pattern: Zod input → callCodecontext
→ ToolDef export. Registered in ALL_TOOLS (alpha-sorted). All 4 are read-only.

codecontext sidecar rebuilt from commit b19e646 with the 4 new Go handlers
(2130 lines, 29 tests). Reviewer fixes applied: defer RUnlock on Tier 2
handlers, extractObjectProperty delegates to extractStringValue for
template-literal route paths.

363/363 server tests passing. No schema changes, no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 05:19:52 +00:00
d27a977d59 v1.15.0-mcp-multi: multi-server MCP client + stdio transport + config file + tool globs
Generalizes the v1.14.1 single-server Context7 PoC into a multi-server MCP
client registry with per-server graceful degradation. JSON config at
/data/mcp.json (bind-mounted alongside AGENTS.md) matches opencode's
mcpServers schema shape. Config file missing = no MCP (opt-in by presence).

Two transports: Streamable HTTP (remote servers like Context7) and stdio
(local subprocess servers like codecontext). Stdio spawns a persistent child
via the SDK's StdioClientTransport; shutdown hook closes all transports.

Tool prefix generalized from context7_<name> to <serverName>_<toolName> with
a toolToServer reverse map for dispatch routing. AGENTS.md tools: field now
supports glob patterns (context7_*, !web_*) via matchToolGlob — last-match-
wins with ! deny prefix. Replaces exact-match .includes() in stream-phase.ts.

refreshToolNames() in agents.ts rebuilds the DEFAULT_TOOLS snapshot after
appendMcpTools so agents without explicit tools: lists see MCP tools —
reviewer caught that the module-load-time snapshot would permanently exclude
late-registered tools.

Read-only invariant: readOnlyHint === false rejected at discovery. Result
size capped at 5MB. v1.14.1 env vars removed — superseded by config file.
Default data/mcp.json ships with Context7 disabled.

363/363 server tests passing. No schema changes, no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:08:42 +00:00
5692e99a5d v1.14.1-mcp-poc: single-server MCP client against Context7
Validates the MCP-client loop end-to-end against one real MCP server before
the full v1.15 port. New services/mcp-client.ts wraps @modelcontextprotocol/sdk
v1.29.0 with Streamable HTTP transport. On startup (when MCP_CONTEXT7_URL is
set), connects to Context7, discovers tools via tools/list, wraps each as a
ToolDef prefixed context7_<name>, and appends to ALL_TOOLS via appendMcpTools.

Read-only invariant guard rejects any tool with readOnlyHint: false. Tool
dispatch is transparent — executeToolCall routes MCP calls through the ToolDef
execute wrapper, which strips the prefix before calling the MCP server. Result
size capped at 5MB with truncation. Graceful degradation: server down at
startup → zero tools; server down mid-session → error result, model
self-corrects.

Adversarial review caught that a Zod .default() on the URL config made MCP
always-on instead of opt-in — fixed by removing the default. MCP_CONTEXT7_URL
must be explicitly set to enable.

ALL_TOOLS changed from ReadonlyArray to mutable to support late-registration.
appendMcpTools re-sorts and rebuilds TOOLS_BY_NAME after append.

348/348 server tests passing (16 new mcp-client tests). No schema changes,
no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:58:09 +00:00
f4a97808ad v1.14.0-outer-loop: explicit while loop replaces inference recursion
Converts the ad-hoc executeToolPhase → runAssistantTurn recursion into an
explicit while (stepNumber < effectiveCap) loop. A step is one stream-and-
tool-execute iteration; the loop terminates on non-tool finish, step-cap hit,
doom-loop, budget exhaustion, abort, or synthesis success.

MAX_STEPS = 200 hard ceiling (4x old effective limit from budget). Per-agent
steps: field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5,
Architect: 20, others: unset = bounded only by MAX_STEPS). Resolution:
effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS).

executeToolPhase no longer recurses — returns ToolPhaseResult struct
(action: 'continue' | 'paused' | 'synthesis_done') so the caller decides
whether to continue or break. steps: 0 handled as "no tool calls allowed"
via runTextOnlyTurn (one text-only stream phase, tool calls ignored with
warn log).

Step-cap hits produce a sentinel summary (reuses cap_hit kind so
CapHitSentinel.tsx renders without frontend changes; text distinguishes
"Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated
to top of loop body — same predicate, same threshold (3), break instead of
return.

step_start parts are in the schema CHECK but not emitted as message_parts —
writing before the stream phase creates a sequence-0 collision with
partsFromAssistantMessage. Structured log line emitted instead. Adversarial
review caught the collision pre-deploy.

332/332 server tests passing. No frontend changes. No schema changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:29:21 +00:00
211e903620 v1.13.20-drop-legacy-cols: final phase of v1.13.0 strangler-fig
Removes the dual-write into messages.tool_calls / messages.tool_results JSON
columns and drops the columns. message_parts is now the only source of truth
for tool calls and tool results.

10 dual-write sites stripped (5 in tool-phase.ts, 2 in routes/skills.ts, 2 in
routes/messages.ts, 1 in routes/chats.ts fork-clone). The recon-driven grep
caught 2 sites beyond the original v1.13.2 roadmap inventory and an extra
fixture file (tool_cost_stats.test.ts) with a direct legacy-column INSERT.

messages_with_parts view rewritten to parts-only subselects (COALESCE
fallbacks gone). View runs via CREATE OR REPLACE so it lands before the
column DROPs in startup DDL — Postgres rejects column-drop on view-referenced
cols. v1.12.1 cleanup DO block (DROP CONSTRAINT messages_status_check /
messages_role_check) removed; those one-shots have done their work.

Adversarial review caught a runtime bug the green test suite missed: the
discard_stale endpoint (chats.ts) had a RETURNING ... tool_calls, tool_results
clause that would have crashed on every 60s-no-token-activity recovery in
production. Fixed by switching to two-step UPDATE returning id, then SELECT
from messages_with_parts so parts-synthesized fields keep flowing on the wire.

Message API type retains tool_calls? / tool_results? — the view synthesizes
those keys from parts so the wire shape is unchanged; frontend reads need no
update. Override on the original v1.13.2 plan, captured in the openspec
proposal.

339/339 server tests passing (including 7 DB-integration tests that applied
the schema migration to a live DB and ran the parts-only view end-to-end).
tsc + web build clean.

Pairs with v1.13.0-ai-sdk-v6 (introduced the dual-write) and v1.13.1-B (moved
the read path to messages_with_parts). Umbrella v1.13 tag ships on this same
commit, marking the strangler-fig closed.

CLAUDE.md picks up Sam's pre-existing edits documenting tag-naming and
CHANGELOG conventions — both already in use by v1.13.19 / v1.13.20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:03:51 +00:00
ad45b28250 v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.

Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.

Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.

31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:43:13 +00:00
1a889dcde3 v1.13.18-codecontext-file-path: resolve file_path against project root in codecontext wrappers
Four codecontext sidecar wrappers — get_file_analysis (required
file_path), get_symbol_info, get_dependencies, and get_semantic_neighborhoods
(optional) — forwarded file_path to the HTTP sidecar unchanged. The
sidecar's internal file index is keyed on absolute paths, so any
relative path from the model returned "File not found in graph".
Three back-to-back failures observed in one chat on 2026-05-22
17:56 UTC, ~48 s of wasted tool budget.

## Resolver

Add resolveProjectPath(projectRoot, rawPath) in codecontext_client.ts:
trim check → absolute/relative branch (both go through resolve() so
dot-segments normalise) → realpath with ENOENT fallthrough → escape
check using the realpathed value. Error shape mirrors the existing
target_dir escape error byte-for-byte; only the field name differs.

Wired into callCodecontext at the args-spread site, guarded on
file_path presence + non-empty. All four wrappers benefit from one
call site; wrappers without file_path (overview, framework, watch,
search) are unaffected.

## Schema trim

.trim() added to all four file_path Zod schemas:

  get_file_analysis:                  z.string().trim().min(1)
  get_symbol_info:                    z.string().trim().optional()
  get_dependencies:                   z.string().trim().optional()
  get_semantic_neighborhoods:         z.string().trim().optional()

Absorbs trailing newlines / whitespace from model output before the
resolver sees the value.

## Adversarial review fixes

Adversarial pass surfaced two P2 findings:

1. Absolute path with `..` resolving outside the project root (e.g.
   `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip
   through the literal prefix-check: the raw string starts with
   `<projectRoot>/`. Fix: resolve() the absolute branch's candidate
   too, so dot-segments normalise before the prefix check.

2. No symlink-escape test coverage. Realpath's stated purpose
   (catching in-project symlinks pointing outside the project) was
   never tested. Added: create a tmpdir outside projectRoot,
   symlink projectRoot/evil-link → outside file, assert rejection.

## Tests

codecontext_client.test.ts: 19 tests (10 baseline + 9 new file_path
resolution cases). Cases cover: relative→absolute, absolute-inside,
relative-escape, absolute-outside, ENOENT-fallthrough, empty-string,
wrapper-without-file_path, absolute-with-`..`-ENOENT,
symlink-leaving-root.

codecontext_tools.test.ts: one assertion updated to expect the
resolved-absolute file_path on the wire (previously asserted the raw
relative path passed through, which is exactly the bug being fixed).

Full suite: 301 passed, 7 skipped.

## Affected / unaffected

- get_codebase_overview, get_framework_analysis, watch_changes,
  search_symbols: no file_path arg → resolver guard skips them. No
  behavior change.
- get_semantic_neighborhoods IS in SYNTHESIS_TOOLS — previously-failing
  relative-path calls will now successfully synthesize. Desirable, not
  a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:54:16 +00:00
b52c5df705 v1.13.17-cross-repo-reads: on-demand read access to paths outside the project root
When the agent needed context from another repo, pathGuard rejected every read
with no recovery path. This batch adds a reactive request_read_access flow:
pathGuard's error now hints at the tool, the model emits a structured request,
the inference loop pauses (same mechanism as ask_user_input), the user picks
Allow/Deny via inline chips, and subsequent reads under the granted root succeed
for the rest of the session.

Schema: sessions.allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]
(idempotent ADD COLUMN IF NOT EXISTS).

Grant unit (design D1): nearest registered projects.path ancestor →
nearest repo-shaped ancestor (.git/ / package.json / go.mod / Cargo.toml)
under PROJECT_ROOT_WHITELIST → else refuse. grant_resolver.ts walks
ancestors with a per-iteration whitelist invariant check so symlinked
input can't escape the whitelist mid-walk (Sam's checkpoint-1 ask).

Path-guard: optional extraRoots arg threaded from session.allowed_read_paths
through executeToolCall to view_file / list_dir / grep / find_files. The
ToolDef.execute signature gets an optional third param; non-FS tools
ignore it. view_file re-anchors the secret-guard check on basename(real)
whenever a relative path starts with "../" so .env / id_rsa* etc. still
deny across grant roots.

Endpoint: POST /api/chats/:id/grant_read_access mirrors /answer_user_input.
On 'allow' it re-resolves the grant root (state may have changed since
prompt — auto-falls to denial reason text on failure, not 500), array_appends
to sessions.allowed_read_paths with in-memory dedup, then publishes
tool_result + session_updated frames and enqueues the next assistant turn.

PATCH /api/sessions/:id allowed_read_paths supports revocation only. Zod
refines absolute + no traversal markers; runtime findUnauthorizedAdditions
guard rejects any entry not already present in the row, so a malicious
curl -X PATCH -d '{"allowed_read_paths":["/etc"]}' returns 400 instead of
bypassing the grant flow (Sam's compliance-review action item).

Frontend: RequestReadAccessCard renders pending (path + reason + Allow/Deny)
and answered (granted/denied summary with the resolved root) variants;
MessageList.flatten/group special-cases the tool name; SettingsPane adds a
per-session grants list with per-row revoke that PATCHes the shortened
array.

Tests: 11 grant_resolver, 8 path_guard, 8 sessions PATCH subset, including
explicit cases for symlink escape mid-walk, walk-bound termination at
whitelist root, /etc bypass attempt via PATCH, and nearest-project
disambiguation. 292 total server tests green.

Pairs with v1.13.16-xml-parser — the model now self-recovers from both
a wrong tool name AND from a refused path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:45:52 +00:00
2e1a81de72 v1.13.16-xml-parser: Anthropic <invoke> support + unknown-tool recovery hints
Two-part fix for the model-emitted XML drift the v1.13.15-codecontext-synth
investigation surfaced (1 raw <invoke> leak observed out of 190 qwen3.6
turns — qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted
as an Architect-style agent because Claude Code documentation in its
pre-training corpus uses that shape).

## Parser extension

xml-parser.ts now recognizes BOTH XML tool-call flavors:

  - Qwen/Hermes:   <tool_call><function=NAME>...<parameter=K>V</parameter>...</function></tool_call>
  - Anthropic:     <invoke name="NAME"><parameter name="K">V</parameter></invoke>

Both route through the same synthetic-id xml_call_${idx} ToolCall path.
extractToolCallBlocks() and partialXmlOpenerStart() handle both openers
(<tool_call> and <invoke...) so partial buffers don't get prematurely
flushed during streaming.

The existing Qwen parser was tightened to tolerate whitespace around `=`
(<function = name>, <parameter = key>...) so a stray space doesn't get
absorbed into the function name. Name capture is non-whitespace,
non-`>`.

## Unknown-tool recovery hint

New tool-suggestions.ts exports levenshtein() + suggestToolName() +
formatUnknownToolError(). When tool-phase.ts:executeToolCall receives a
toolCall.name that isn't in TOOLS_BY_NAME, the error returned to the
model now includes a "Did you mean: X?" hint based on Levenshtein
distance ≤3 or substring match against Object.keys(TOOLS_BY_NAME).
Targets the qwen3.6 drift to read_file → suggest view_file. Applies to
all unknown tool names, not just <invoke>-derived ones — at the
dispatch layer we no longer know which format produced the call, and
the extra signal is harmless for Qwen-derived calls.

## Test coverage

xml-parser.test.ts: 46 tests, all green. Covers both parsers
(well-formed, malformed, multi-parameter, nested-content), the
partial-opener detector for both flavors, the unified extraction
helper, and the unknown-tool error formatter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:59:25 +00:00
61308cf17c v1.13.15-codecontext-synth: remove "tag pending" qualifier in roadmap
Trivial follow-up after the v1.13.15-codecontext-synth tag landed.
Retrospective bullet now describes the shipped state; cleanup-order
tracker marks the batch .

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:09:39 +00:00
3992a9fcb7 v1.13.15-codecontext-synth: forced second-inference synthesis for codecontext overview tools
After a codecontext overview-class tool call lands (get_codebase_overview,
get_framework_analysis, get_semantic_neighborhoods), the pipeline runs a
second inference pass that replaces the recursive runAssistantTurn. The
synth pass auto-fetches the top-N source files referenced in the
codecontext output plus project docs (BOOCHAT.md, AGENTS.md,
*roadmap*.md, CONTEXT.md), applies a 32k-token budget with explicit
drop-priority, and streams a structured response that grounds the model
in real load-bearing code rather than relying on the codecontext summary
alone. Smoke #1 (default) and #2 (Architect) both cite the correct
inference/turn.ts + tool-phase.ts + stream-phase.ts files; smoke #6
(fault injection) verifies the fall-through path marks the synth message
status='failed' and yields cleanly to the recursive turn.

## Truncation-aware extraction

codecontext's wrapper inline-truncates results at 32k chars. Without the
expansion step, the top-N file selection only saw the alphabetical head
of the codebase (apps/booterm/dist/*) and auto-fetched the wrong sources.
The pipeline now calls in-process readTruncation(outputPath) before
extracting referenced files, so top-N selection sees the full 80k+ char
output. The 32k truncated head still ships to the synth model — the
expansion is reference-extraction-only, preserving the token-budget
contract. Graceful degradation on readTruncation null/throw: log warn,
fall back to the truncated head.

## Schema deviation from dispatch

The dispatch claimed no schema migration was needed for the new
'synthesis' part kind. Reality: message_parts.kind has an explicit
CHECK constraint (schema.sql:54) that would reject the new value. Added
a DROP CONSTRAINT IF EXISTS + DO $$ pg_constraint idempotency-guarded
re-add matching the CLAUDE.md migration pattern. The inline CREATE TABLE
constraint also updated so fresh installs land with the extended enum.

## User-abort marks synth-message failed

Deviation from review-time spec ("user-abort path does NOT mark the
message failed"). The outer abort handler in error-handler.ts operates
on the parent turn's assistantMessageId, not the new synth row that
runSynthesisPass created. Without explicit marking, the synth row would
sit in status='streaming' until the 5-min stale-streaming sweeper
(v1.13.1-cleanup-bundle), tripping the frontend's 60s no-token-activity
banner in the meantime — exactly the UX bug class the v1.13.1 sweeper
was added to handle. Marking failed on every catch path (including
user-abort) closes the gap. Cost: one extra DB write + one publish on
the rare user-abort-during-synth path.

## Race-safe synth-tool capture

tool-phase.ts uses synthEntries: Array<{tc, output, error?}> with
per-callback push under Promise.all. find() picks the first non-error
entry by call-order (toolCalls array index). Multiple synth-tools in
one batch are uncommon but handled deterministically.

## Roadmap rebase

Updated boocode_roadmap.md retrospective section + cleanup-order tracker
+ schema-changes summary to use the new vMAJOR.MINOR.PATCH-slug tag
names per the 2026-05-22 retag (CHANGELOG.md is the canonical record).
v1.13.15 listed as "this batch, tag pending"; a one-line follow-up
commit will remove that qualifier after the tag lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:08:47 +00:00
0fa46cd06c v1.13.12: skills audit + token-tracking fix + codecontext + cap50 + UI cleanups
Multi-topic batch. The big-ticket item is the skills audit; the rest are
smaller patches that compounded during the audit work.

## Skills audit (rules→recipes split)

Vendored all 26 skills from /home/samkintop/opt/skills/ into data/skills/
(the boocode-repo-local skill library — see docker-compose change below).
Audited via 5 parallel Claude Code agent-teams running the
mgechev/skills-best-practices 4-step protocol (Discovery → Logic → Edge
Case → self-Architecture-Refinement) per skill, ~2 min wall-clock vs the
~3.7-hour serial estimate.

Result: 14 skills surviving (renamed to gerund form, frontmatter matched),
11 deleted (duplicates, BooCode-irrelevant patterns, Claude-already-does-
natively), 1 migrated to BOOCHAT.md/BOOCODER.md as an always-true rule
(verification-before-completion). Each surviving skill had its description
refined to fix specific trigger gaps surfaced by the protocol — 4
real-bug findings landed (dead refs, stale tags, broken sub-file
references in the original vendored content).

Audit decisions documented in openspec/changes/v1.13.12-skills-audit/
audit-notes.md. Convention codified in BOOCHAT.md/BOOCODER.md "rules vs
recipes" sections — future workflow rules go to those files (100%
present), recipes stay in data/skills/ (~6% invoke rate in multi-turn
per the Codeminer42 measurement).

## Token tracking + stale-stream banner fix (same root cause)

ws-frames.ts IsoTimestamp was z.string().min(1) but postgres returns
timestamp columns as JS Date objects. Every message_complete /
session_updated / chat_updated frame was failing the v1.13.11 Zod gate
and being silently dropped. Symptoms: token tracking blank in the UI
(no usage frames landed); the 60s no-token-activity timer tripped the
stale-stream banner because the frontend's local message state never
saw status='streaming' flip to 'complete'.

Fix: z.preprocess(v => v instanceof Date ? v.toISOString() : v,
z.string().min(1)) applied to the IsoTimestamp primitive. Centralized,
no publisher changes, works identically server + web (the parity test
still passes).

## Codecontext .codecontextignore auto-install

services/codecontext_client.ts now copies the
codecontext/.codecontextignore.template into any project's root on the
first call to that project if no .codecontextignore exists. One file
written per project, idempotent (in-memory Set guard + access-check),
silent fallback on read-only project. Stops the upstream empty-source-
file parser crash on foreign projects' node_modules — previously
required manually copying the template per project.

## Tool-call budget cap 30 → 50

services/inference/budget.ts: BUDGET_READ_ONLY and BUDGET_NO_AGENT
bumped to 50 (from 30). BUDGET_NON_READ_ONLY stays at 10 (no write
tools landed yet). Real recon sessions were hitting 30 with ~3 turns
wasted on codecontext parse failures; legitimate need was ~27, and
Architect-class system overviews want deeper recon. Headroom of 20
absorbs failure-retry turns without changing the safety floor — the
doom-loop guard (3 identical calls → abort) catches the actual
failure mode this cap was guarding against.

v1.14 (Phase C outer agent loop) will supersede this via per-agent
agent.steps. Throwaway-ish patch but unblocks deeper recon today.

## UI cleanups

- ChatPane queued-message dropdown removed. Each queued message now
  has three buttons: edit (pop back into ChatInput via sendToChat
  event), force-send (was the dropdown's only useful action), and
  cancel. Default behavior (send when streaming completes) needs no
  UI — it's the implicit do-nothing path.
- ChatThroughput removed from desktop tab strip (ChatTabBar.tsx).
  Mobile tab switcher still shows it.

## Plumbing

- .gitignore: data/* + !data/AGENTS.md + !data/skills/ negation
  patterns so the vendored skill library + agent registry become
  git-tracked while session DB state stays out.
- docker-compose.yml: removed /opt/skills:/data/skills override
  mount. Skills now live in the boocode repo at data/skills/,
  auditable per-batch. The host-level /opt/skills/ is preserved
  untouched for any other tools that read from it.
- .codecontextignore at repo root: auto-installed when codecontext
  was first called against /opt/boocode itself; matches the template.
- CLAUDE.md: updated to document the v1.13.11 publishFrame wrapper +
  message_parts table + tool_cost_stats view + DB-integration test
  pattern + host-side smoke endpoint quirk. (Pre-existing in working
  tree before this batch; shipped here for completeness.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:58:30 +00:00
bc376c878d v1.13.11-b: convert raw broker.publish call sites to typed publishFrame
Second half of the WebSocket-frame-typing batch. Phase A (8b568b3)
landed the schemas + frontend receive validation + publishFrame /
publishUserFrame wrappers. This commit converts the existing publish
call sites so every server-emitted WS frame now goes through Zod
validation at the broker boundary.

Conversion strategy: change once in the inference / skills adapters in
index.ts (so ctx.publish / ctx.publishUser propagate to publishFrame /
publishUserFrame for ALL ~50 inference + auto_name call sites in one
move), then bulk-replace the ~30 direct broker.publish* call sites in
the routes + compaction.

Files touched:
- index.ts: inference + skills route adapters now call publishFrame /
  publishUserFrame internally; raw broker.publishUser('default', ...)
  call in the stale-row sweeper also converted.
- routes/projects.ts (7 sites), routes/chats.ts (9 sites),
  routes/sessions.ts (8 sites): all broker.publishUser(...) → broker.
  publishUserFrame(...).
- services/compaction.ts (3 sites): 2 publishUser, 1 publish.

Real protocol drift surfaced by Zod, fixed in the same commit:

  services/compaction.ts:442 was publishing chat_status with status:
  'working' — the v1.12.1 chat_status widening (CLAUDE.md:55) dropped
  this enum value in favor of streaming|tool_running|waiting_for_input|
  idle|error. The compaction.ts site was missed during v1.12.1; the
  frame had been published with an unknown enum value ever since (the
  frontend useChatStatus quietly ignored it). Corrected to 'streaming'
  — compaction's LLM call has the same dot-state semantic as an
  inference turn. This is exactly the class of bug v1.13.11 exists to
  catch.

Schema relaxation: OpaqueObject (the bag type for nested entities like
Project / Chat / Session / WorkspacePane embedded in WS frames) was
z.object({}).passthrough(), which Zod outputs as {} & {[k:string]:
unknown}. The strict-typed entities don't have index signatures so
TypeScript rejected them at publishFrame call sites. Relaxed to
z.unknown() — runtime validation still accepts the value, dev-time
narrowing happens via the existing hand-maintained types. Trade-off:
frame-level drift detection stays sharp; nested-payload validation
goes to follow-up work as the brief intended.

Schema audit:
  grep -rn "broker\.publish(\|broker\.publishUser(" apps/server/src \
    --include="*.ts" | grep -v "broker.ts\|__tests__\|.bak"
  → 0 results. Every server publish goes through publishFrame /
  publishUserFrame. The remaining ctx.publish / ctx.publishUser sites
  in services/inference/* + services/auto_name.ts route through the
  index.ts adapter, which calls publishFrame internally.

Tests: 219/219 pass (unchanged from v1.13.11-a; the Phase B conversion
is mechanical and doesn't add test cases).

Smoke: clean container boot, no ws-frame-validation-failed entries
under normal traffic. Sidebar list refresh + agent picker open both
pass through useUserEvents without drops.

~70 LoC across 7 files. v1.13.11 closed.
2026-05-22 15:54:00 +00:00
8b568b36d3 v1.13.11-a: WS frame schemas + frontend receive validation
First half of the WebSocket-frame-typing batch (split per recon — total
scope was ~535 LoC, larger than the roadmap's ~300 estimate, so the
server-side publish-site conversion lands separately in v1.13.11-b).

Phase A scope:

(1) apps/server/src/types/ws-frames.ts (NEW) — Zod schemas for all 27
wire-format WS frame types. Discriminated union (WsFrameSchema) plus
KNOWN_FRAME_TYPES const for diagnostic lookup. UUIDs are z.string().
uuid(); model-emitted tool_call_id stays z.string().min(1) since OpenAI-
compatible APIs emit "call_<random>" not UUID. Per-kind payload narrowing
(tool args, message_parts payloads) intentionally stays z.unknown() —
frame-level drift detection is the goal; deep payload validation is
follow-up work.

(2) apps/web/src/api/ws-frames.ts (NEW) — byte-identical mirror of the
authoritative server file. No path alias from web→server in the existing
tsconfig setup; sync-by-hand was chosen over a new packages/shared/ dir.
A ws-frames.test.ts test asserts the two files match.

(3) apps/server/src/services/broker.ts — adds publishFrame() and
publishUserFrame() methods to the Broker interface. Both validate via
WsFrameSchema and fail-closed: log + drop on invalid. createBroker now
accepts an optional FastifyBaseLogger so validation failures land in
the pino stream (with console.error fallback for unit tests). The
existing publish() / publishUser() raw methods stay legal — they get
converted to the typed variants in v1.13.11-b.

(4) apps/web/src/hooks/useSessionStream.ts + useUserEvents.ts — wrap
ws.onmessage with WsFrameSchema.safeParse. Fail-closed: invalid frames
log + return without dispatching. Hand-maintained WsFrame and
SessionEvent types stay in place; one cast bridges Zod-typed → narrowed
shape (Zod uses OpaqueObject for nested Message[] / WorkspacePane[] etc.,
which are dev-time-narrowed via the existing hand-maintained types).

(5) apps/web/package.json — adds zod ^3.23.8 as a direct dep. Was a
transitive dep via ai-sdk / postgres; promotion makes the import legal.

(6) Tests: 15 new in ws-frames.test.ts covering happy-path per major
frame type, drift-catchers (unknown type, invalid enum, non-UUID, negative
tokens), parts-authoritative read variants, the mirror-file diff check,
and four broker fail-closed scenarios. 219/219 server tests pass (was
204; +15 new).

Two recon corrections to the dispatch brief, both flagged before
implementation:

- No 'parts_appended' frame exists. The brief assumed one; the codebase
  reads parts via the messages_with_parts view after message_complete
  triggers a refetch. MessagePartSchema is therefore unused this batch.
- No 'tool_running' frame exists. The brief listed it as standalone; it
  is in fact a 'chat_status' variant ({ status: 'tool_running' }), already
  covered by ChatStatusFrame.

Smoke: clean container boot, no validation errors in the server log. Real
production frames pass validation (the schemas were derived from the
existing hand-maintained types in api/types.ts and sessionEvents.ts).

v1.13.11-b will follow immediately: convert all ~85 raw broker.publish /
ctx.publish call sites across 11 server files to publishFrame /
publishUserFrame. Mechanical edit; the wiring done here means the diff
in -b is just the call-site swaps.

~310 LoC across 9 files (4 new + 5 modified).
2026-05-22 15:48:32 +00:00
34cbecf975 v1.13.15-tools: tiered tool loading via BOOCODE_TOOLS env var
Pattern lift from eyaltoledano/claude-task-master (MIT + Commons Clause
— pattern only, no code lift). Adds BOOCODE_TOOLS env var with three
tiers:

- core (4 tools): view_file, list_dir, grep, find_files. ~2k token
  schema cost.
- standard (15 tools): core + web_search, web_fetch, git_status, all
  8 codecontext_* tools. ~10k token schema cost.
- all (default; current behavior): every tool in ALL_TOOLS (20). ~21k
  token schema cost.

The env var is a CEILING — narrows agent whitelists, never expands.
Default behavior unchanged when var is unset. resolveToolTier is
case-insensitive and falls back to 'all' on unknown values.

CORE_TOOL_NAMES + STANDARD_TOOL_NAMES validated at module load against
TOOLS_BY_NAME via two top-level for-loops that throw on the first
missing name. Module fails to import if a tier references a tool that
doesn't exist in the registry — catches typos and stale tier
definitions at boot rather than silently filtering valid tools out of
agent whitelists.

Wiring: agents.ts parseAgentBlock now reads BOOCODE_TOOLS from
process.env per parse, intersects with the agent's declared frontmatter
tools (or DEFAULT_TOOLS when frontmatter omits the field). Per-parse
read is fine — agents are re-parsed on the existing 60s cache TTL.

Tests: tools.test.ts grows from 1 to 10 tests. Covers resolveToolTier
across tiers/case/unknown values + the CORE-subset-of-STANDARD invariant
+ TOOLS_BY_NAME existence for both tier sets. 204/204 pass (was 195;
+9 new).

Deviation from the brief: the codecontext tools in the actual registry
have NO codecontext_* prefix (the brief's STANDARD list assumed it).
Used the actual names (get_codebase_overview, search_symbols, etc.).
Module-load validation would have failed boot with the prefixed names.

Smoke: with BOOCODE_TOOLS unset, agents return their full 12-tool
whitelists. With BOOCODE_TOOLS=core in .env + container restart, the
same agents narrow to 4 tools (find_files, grep, list_dir, view_file)
— intersection of declared whitelist ∩ core tier. Reverted after
confirmation.

CLAUDE.md updated with BOOCODE_TOOLS in the Environment section's
Optional list. .env.example gained a commented BOOCODE_TOOLS=all line
with the per-tier token-cost table.

~110 LoC across 5 files (4 modified + 1 test expansion). Under the
brief's ~30 LoC estimate for code; the test suite expansion drove
most of the growth.
2026-05-22 14:59:01 +00:00
5a3f357ce9 v1.13.15-openspec: reformat batch docs to OpenSpec directory structure
Adopt Fission-AI/OpenSpec's openspec/changes/<change-name>/{proposal,
specs,design,tasks}.md shape for BooCode's own batch docs. Zero-dep
documentation reformat; replaces ad-hoc boocode_batchN.md /
handoff_vN.N.N.md convention.

Existing batch docs moved into openspec/changes/archived/ via git mv
(preserves history):
- boocode_batch10.md
- handoff_v1.13.8_prefix_verify.md
- handoff_v1.13.10_per_tool_cost.md

Pre-v1.13.15 docs were NOT split into proposal/tasks/design files. The
work was already shipped; the originals are preserved as archived
snapshots. New v1.13.15+ batches land directly in
openspec/changes/<slug>/proposal.md (+ tasks.md, + design.md when
applicable) per the convention documented in openspec/README.md.

CLAUDE.md gained a one-line pointer to the convention (workflow
section). File grew from 153 → 154 lines, 27,682 → 27,925 chars; both
remain well under the AgentLint hard caps.

specs/ directory is reserved for future OpenSpec CLI adoption (v1.14+).
No CLI dep added in this batch — directory structure only. If/when the
full OpenSpec lifecycle is adopted, that lands as a separate batch.
2026-05-22 14:54:17 +00:00
fc11e8dc91 v1.13.15-agentlint: instruction-file audit against AgentLint 31-check standard
Manual audit pass against 0xmariowu/AgentLint's evidence-backed checks
(MIT, drawn from 265 versions of Anthropic's internal Claude Code
system prompt).

Findings and fixes:
- Identity sections ("You are the assistant running inside ...") removed
  from BOOCHAT.md (line 3) and BOOCODER.md (line 5). The model already
  knows where it's running; the openers were emphatic decoration.
- CLAUDE.local.md added to .gitignore (.env was already covered).
  Claude Code's Glob tool ignores .gitignore by default, which means
  any local override file was otherwise readable by any agent walking
  the workspace.
- CLAUDE.md unchanged — already passes all 10 checks. Emphasis density
  0.58/1000 words (under Anthropic's 1.4/1000 endpoint); two IMPORTANT/
  MUST references are load-bearing (tsc-noEmit footgun, v1.13.7
  includeUsage invariant); zero identity sections; zero --no-verify
  references; 27,682 chars (under the 40,000-char silent-drop limit).
  Line count (153) is over the 60-120 target band, but the brief
  explicitly forbids structural rewrites in the audit pass.

Targets not in scope:
- /opt/boocode/AGENTS.md does not exist in this repo (removed in v1.12,
  per CLAUDE.md:152). The global agent registry lives at /data/AGENTS.md
  (bind-mounted from outside the repo); can't be touched by this batch.
- No .github/workflows/ directory — SHA-pin audit (step 8) skipped.

Cumulative effect: model spends fewer tokens parsing instruction-file
ceremony in BOOCHAT/BOOCODER and receives sharper priority signal per
Anthropic's measured-evolution data. Zero code changes.
2026-05-22 14:52:37 +00:00
9ce638c916 v1.13.10: per-tool token cost accounting (rolling 100-call view)
Surfaces per-tool prompt/completion-token rolling averages in
AgentPicker for at-a-glance agent-cost hints. Implementation is a
SQL view on top of messages_with_parts plus a read endpoint and
AgentPicker tooltip extension. No new write site; all source data
already lands via the existing tool-phase.ts:94-95 / error-handler.ts:
109-110 / sentinel-summaries.ts UPDATEs that v1.13.7's includeUsage:
true fix made non-NULL.

(1) schema.sql — new tool_cost_stats view. Window-functions over
messages_with_parts.tool_calls with LATERAL jsonb_array_elements.
Attribution: equal split — multi-tool turn divides tokens N-ways;
the 100-call rolling mean absorbs split noise. Filters: status=
'complete' + metadata.kind NOT IN ('cap_hit','doom_loop') exclude
failed turns and sentinels respectively; tool_calls IS NOT NULL is
defense-in-depth since sentinels are role='system' rows. CREATE OR
REPLACE means schema apply is idempotent.

(2) routes/tools.ts NEW + index.ts wire-in. GET /api/tools/cost_stats
returns { stats: ToolCostStat[] } with mean_prompt_tokens / mean_
completion_tokens computed at read time (sum / n_calls). Sorted by
tool_name ASC. No pagination — ≤30 tools.

(3) __tests__/tool_cost_stats.test.ts NEW — 7 integration tests
keyed off DATABASE_URL env var. Tests skip gracefully when unset
(no-DB default). beforeAll applies the schema via sql.unsafe(read
FileSync(schema.sql)) for self-contained runs. Helper insertAssistant
Turn shared across cases. Covers: empty state, single-tool attribution,
multi-tool equal split, 100-call FIFO window, NULL-tokens exclusion,
parts-authoritative read via messages_with_parts, failed/sentinel
exclusion.

(4) web/api/types.ts + client.ts — ToolCostStat interface + api.tools.
costStats() method binding.

(5) AgentPicker.tsx — fetch costStats on mount, compute per-agent
sum-of-means across whitelisted tools, render muted cost line below
description: "~5.2k prompt / 280 completion · 6/8 tools · last call
3h ago". Skips line entirely when no tool history; preserves existing
native title= for layout backward-compat. formatK/formatAgo colocated.

Tests: 202/202 pass (195 prior + 7 new view-integration). Server +
web tsc clean.

Smoke: schema applied cleanly; GET /api/tools/cost_stats returns
canonical JSON; view + endpoint agree. Single-row result expected
given the v1.13.1-A → v1.13.7 NULL latent regression window; new
traffic populates organically.

Roadmap row at boocode_roadmap.md:114 plus schema row at :474 both
match. View vs table decision documented in handoff_v1.13.10_per_
tool_cost.md (rollback-safe, microsecond-fast at BooCode scale).

~270 LoC across 8 files (5 modified + 3 new).
2026-05-22 14:42:09 +00:00
8126d78b34 docs: capture v1.13.7-v1.13.9 invariants in CLAUDE.md
Five additions surfacing session-discovered constraints future Claude
sessions need:
- AI SDK v6 includeUsage:true requirement (avoids re-introducing the
  v1.13.1-A→v1.13.7 NULL-tokens regression)
- \n text-delta trim guards in MessageList/MessageBubble + payload.ts
  failed/empty-assistant skip rules (avoid undoing v1.13.7)
- 0.85 × ctx_max overflow formula (v1.13.9) replacing the stale
  ctx_max - 20k line
- New services/system-prompt.ts bullet documenting the v1.13.8
  fingerprint instrumentation surface
- New services/inference/budget.ts bullet with current BUDGET_NO_AGENT=30
  and read-only-tools rationale
2026-05-22 14:07:11 +00:00
b06a4a8e55 v1.13.9: compaction overflow trigger — 0.85 × ctx_max early trigger
Opencode pattern (session/overflow.ts): fire compaction at 85% of
ctx_max, replacing the v1.11.0-era `ctx_max - 20_000` formula.

Old formula: usable = ctx_max - 20_000
  - ctx=262144 → trigger at 242144 (92.4%) — only 7.6% headroom
  - ctx=100000 → trigger at  80000 (80.0%)
  - ctx= 32000 → trigger at  12000 (37.5%) — over-eager
  - ctx<=20000 → trigger at      0 — never fires

New formula: usable = floor(0.85 * ctx_max)
  - ctx=262144 → trigger at 222822 (85.0%) — 15% headroom for summarizer
  - ctx=100000 → trigger at  85000 (85.0%)
  - ctx= 32000 → trigger at  27200 (85.0%)
  - ctx=  8192 → trigger at   6963 (85.0%)

Ratio gives consistent headroom at any context scale. The qwen3.6
daily driver gets ~19k tokens more breathing room before overflow;
small-ctx models no longer degenerate to never-triggering.

usable() is the only consumer of COMPACTION_BUFFER → constant deleted.
New EARLY_TRIGGER_RATIO constant takes its place.

isOverflow() and the maybeFlagForCompaction() call site at
payload.ts:184 are unchanged — formula swap is internal to compaction.ts.
payload.ts comment touched only to drop the stale COMPACTION_BUFFER
reference (PRUNE_TRIGGER_TOKENS stays at 20k as the prune-freed
threshold; independent of the overflow formula).

Tests: 4 new usable() corner cases (262k/100k/8k/zero+negative), plus
5 isOverflow() numbers shifted to match the 85k budget at ctx=100k.
195/195 server tests pass (was 194).

Smoke: ratio math verified by unit tests at all four corners. Live
cap-hit verification deferred — requires accumulating >222k tokens
in a session under qwen3.6-35b-a3b-mxfp4 (was >242k pre-fix); will
surface organically in extended use.
2026-05-22 13:59:14 +00:00
a0c8d212cb v1.13.8: system-prompt prefix stability verify-and-measure
Recon during planning disproved the original v1.13.7 (DB-cache) premise:
buildSystemPrompt already runs over inputs mtime-cached at the file layer
(BOOCHAT.md in system-prompt.ts:25, AGENTS.md global+per-project in
agents.ts:245), and DB scalars are byte-stable until edited. The output
is microsecond pure-string concat with no I/O. Skills aren't in the
prefix; tools live in a separate request body field alpha-sorted by
v1.13.3.

This batch closes the verification gap with instrumentation, not
implementation:

- system-prompt.ts: buildSystemPromptWithFingerprint canonical impl
  computes SHA-256 over the assembled prefix, runs a per-session
  Map<sessionId, lastHash> observer, emits PrefixFingerprint per call
  and PrefixDrift (with field-level changed_inputs) on hash change.
  buildSystemPrompt is now a thin shim returning .prompt.
- agents.ts: getAgentsMtimes accessor — cache-read only, no I/O.
- payload.ts: buildMessagesPayload takes optional log argument; when
  passed, emits prefix-fingerprint (info) + prefix-drift (warn).
- turn.ts + sentinel-summaries.ts: pass ctx.log at 3 production call
  sites; sentinel summaries log too so any drift across cap-hit /
  doom-loop paths surfaces.
- system-prompt.test.ts: 4 new tests (byte-identical, no-drift-on-
  stable, drift-fires-with-changed-inputs, cross-session-no-drift).

194/194 tests pass (was 190).

Smoke: 5 messages in a fresh session produced 7 prefix-fingerprint
logs (extras from buildMessagesPayload being called from sentinel
summary paths), all with identical prefix_hash and prefix_length=2907,
zero prefix-drift. Prefix is byte-stable in steady-state.

Decision: original system_prompt_cache DB table from the roadmap is
permanently dropped. The v1.12.0 mtime caches at the input layer plus
alpha tool ordering at the request body (v1.13.3) already address the
load-bearing cache-stability surfaces. Instrumentation stays so the
claim can be re-verified at any time.
2026-05-22 13:42:18 +00:00
0ce6115976 docs: renumber v1.13.8 to verify-and-measure, drop system_prompt_cache table, add v1.13.8 dispatch brief 2026-05-22 13:24:29 +00:00
ff29b48e3a v1.13.7: stability bundle — usage capture + payload/UI sanitization
Five fixes for latent regressions surfaced during the v1.13.x.cosmetic
revert investigation. None alter schema or compaction; all cleanup
against the v1.13.1-A AI SDK migration's hidden surface.

(1) provider.ts — includeUsage: true on createOpenAICompatible.
@ai-sdk/openai-compatible defaults this false, omitting
stream_options.include_usage from the request body; llama-swap never
emitted the usage block, so result.usage.inputTokens/outputTokens
resolved undefined and tokens_used / ctx_used landed NULL in every
assistant row since v1.13.1-A. No historical backfill.

(2) MessageList.tsx — hasText = m.content.trim().length > 0.
AI SDK v6 streaming occasionally emits a leading "\n" text-delta on
tool-call-only turns; the literal newline passed length > 0 and
rendered an empty bubble + ActionRow between every tool call. Trim
catches it without changing semantics for genuine content.

(3) MessageBubble.tsx — same trim on hasContent for the no-tool-calls
path. Defensive symmetry with MessageList.flatten.

(4) payload.ts — buildMessagesPayload skips assistant rows with
status='failed' AND assistant rows with status='complete' + empty
content + no tool_calls. Without this, a trailing empty/failed
assistant + the next attempt's placeholder produced "Cannot have 2
or more assistant messages at the end of the list" rejections from
the OpenAI-compatible upstream after cap-hit + Continue.

(5) budget.ts — BUDGET_NO_AGENT 15 → 30. Every tool in ALL_TOOLS is
read-only today; the 15-cap was forward-looking for write tools that
haven't landed. No-agent mode now matches BUDGET_READ_ONLY.

47 LoC across 5 files. 190/190 server tests pass.

Verified live: new assistant turns populate StatsLine token data;
single-tool-call turns no longer render the stray empty-bubble +
ActionRow between tool calls; Continue after cap-hit no longer hits
the trailing-assistant API rejection.
2026-05-22 13:24:19 +00:00
81d837c04e v1.13.6: compaction head-assembly audit + reasoning fix
Audit traced compaction's summary path post-v1.13.1-B read flip:
- Q1: reads from messages_with_parts (view) — clean
- Q2: parts shape correctly threaded through buildHeadPayload — clean
- Q3: reasoning omitted from summary input — FIX NEEDED

v1.13.1-C wired reasoning end-to-end into inference/payload.ts but
missed this read site. Summarizer model couldn't see the reasoning
trail for tool-bearing turns, quietly degrading summary quality for
reasoning-channel models (qwen3.6).

Fix:
- CompactionMessage extended with reasoning_parts field
- SELECT pulls reasoning_parts from messages_with_parts
- buildHeadPayload (now exported for tests) prefixes assistant content
  with <reasoning>...</reasoning>\n\n<content>... when reasoning is
  present; standalone <reasoning>...</reasoning> for tool-call-only
  turns; omits the tag when reasoning is null or empty

4 new render branch tests (190 total).

Smoke deferred: forcing real compaction requires either threshold
pollution or building up a >40k-token chat with reasoning_parts.
Render branches are unit-covered; integration would only re-prove
structural correctness.
2026-05-22 08:18:47 +00:00
f8fc5db929 v1.13.5: opencode truncate.ts port — full tool output retrievable via opaque id
- New services/truncate.ts. Tmpfs storage at /tmp/boocode-truncations/
  (BOOCODE_TRUNCATION_DIR env var overrides for tests). 12-char base32
  opaque ids (~60 bits entropy, "tr_<id>"). Three exports: storeTruncation,
  readTruncation, truncateIfNeeded (wrap-or-passthrough helper).
  cleanupTruncations does TTL-pass (7 days) + orphan-reap (parts query on
  payload->'output'->>'outputPath') in one shot.
- Wired four tools through truncateIfNeeded: view_file (raw full file),
  list_dir (full filtered+secret-filtered entries serialized one-per-line),
  web_fetch (textRaw pre-slice), codecontext_client (body.result pre-slice).
  Each returns the existing sliced view plus an optional outputPath field
  when truncation fires.
- New view_truncated_output ToolDef. Resolves opaque id → on-disk content
  internally; model never sees the truncation dir. Same start_line /
  end_line slicing semantics as view_file. Registered in ALL_TOOLS (alpha
  sort places it after view_file automatically) and READ_ONLY_TOOL_NAMES.
- cleanupTruncations piggybacks on the v1.13.3 stuck-row sweeper's 60s
  setInterval. No-op when truncation dir is empty.

Not wired (TODO follow-up): grep and find_files. file_ops returns post-cap
results to the tool execute path, so the "full content" isn't recoverable
without a refactor of fileOps.grep / fileOps.findFiles to expose the
uncapped result. web_search is silent-slice (no truncated flag); outside
scope. Five sites of seven covered; the remaining two are the only ones
needing a file_ops change.

Tests: 7 new in truncate.test.ts (roundtrip, unknown id, malformed id,
truncateIfNeeded false/true/over-cap/storage-failure paths). 186 total
(was 179). cleanupTruncations file-system half implicitly via TTL pass;
orphan-reap branch covered by the live container smoke.

Smoke verified end-to-end against the live container:
- view_file with start_line=1, end_line=3 on CLAUDE.md → tool_result part
  carried outputPath "tr_cdpn1o04k6ma" + truncated=true.
- /tmp/boocode-truncations/tr_cdpn1o04k6ma exists, 15876 bytes, mode 0o600,
  parent dir mode 0o700.
- Follow-up view_truncated_output(id, start_line=50, end_line=55) returned
  the actual lines 50-55 of CLAUDE.md (the 808notes/BooCode bullets).
- ALL_TOOLS count=20 (was 19); alpha sort places view_truncated_output
  between view_file and watch_changes.

Closes a v1.12 catalog row that was scoped but deferred. The v1.13 parts
table made outputPath ride on the existing tool_result payload with no
schema change beyond the storage helper itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:55:55 +00:00
ec8593cf77 v1.13.4: two-tier compaction prune — opencode pattern half-shipped in v1.11.0
- message_parts.hidden_at timestamptz column (NULL by default) with a
  partial index on (message_id) WHERE hidden_at IS NULL for the common
  visible-parts filter.
- messages_with_parts view changed from COALESCE(parts, legacy) to
  CASE WHEN EXISTS(any parts of kind) THEN visible-parts ELSE legacy.
  COALESCE would have leaked hidden parts back via the legacy fallback
  when every part was pruned (smoke caught it pre-commit). The CASE
  distinguishes "no parts at all → fall back to legacy column for
  pre-v1.13.0 history" from "all parts hidden → return null/empty so
  the row drops out of the model payload" exactly.
- prune.ts: scans tool_result parts newest-first, protects the last 40k
  tokens (PROTECTED_TOKENS), marks older candidates hidden when their
  combined estimate clears 20k (PRUNE_TRIGGER_TOKENS — equal to
  COMPACTION_BUFFER from v1.11.0, so a successful prune is exactly the
  budget the summary path would have freed). Stops at chats.tail_start_id
  so it doesn't double-erase across the last summary boundary. Pure
  decision helper selectPruneTargets exported separately for unit tests.
- Wired into maybeFlagForCompaction: prune runs synchronously when
  overflow is detected; if it freed >= PRUNE_TRIGGER_TOKENS, the
  needs_compaction flag is NOT set and the (expensive) summary inference
  call is skipped this turn. The next turn's overflow check re-evaluates
  from scratch.
- 6 new unit tests in prune.test.ts cover: empty input, protection-only
  (no candidates), candidates below trigger, candidates above trigger,
  candidates straddling a summary boundary, exactly-protection-tokens.
  179 tests total (was 173).

Smoke verified post-rebuild:
- \\d message_parts shows hidden_at + partial index.
- View definition shows AND p.hidden_at IS NULL filters on all three
  subselects.
- Synthetic hide-then-restore confirmed the view drops the tool_result
  jsonb to null when its only part is hidden, and restores when un-hidden.
- EXPLAIN ANALYZE on the 42-message stress chat: 0.325ms (faster than
  v1.13.1-B's 1.018ms — EXISTS short-circuits cleanly for the common
  no-parts case).
- Normal turn (plain text prompt) completes unaffected.

Closes a v1.11.0 design item that was scoped but never implemented. With
v1.13's parts table the prune is dramatically cheaper to write — pre-parts
it would have meant editing JSON blobs in-place; now it's a hidden_at
flag and a view subselect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:02:17 +00:00
a08d809b73 v1.13.3: cleanup bundle — statement timeout + alpha ordering + stuck-row sweeper + repairToolCall
Four independent items, all owed from prior dispatches.

- statement_timeout at the database level via:
    ALTER DATABASE boocode SET statement_timeout = '30s';
  Applied operationally; documented as a comment at the top of schema.sql
  (ALTER DATABASE can't run inside a DO block, so it's not idempotent
  inside applySchema). Re-apply after a volume reset.

- Tool registry alpha-sorted at module load. llama.cpp's prompt cache
  hits on byte-identical prefixes; any reordering of the tool list near
  the top of the system prompt would invalidate every cached turn.
  Single-source sort at the ALL_TOOLS export so toolJsonSchemas() and
  TOOLS_BY_NAME inherit the order automatically. New tools.test.ts
  asserts the invariant; total tests 173 (was 172).

- Periodic in-process stuck-row sweeper. Runs every 60s, marks
  'streaming' rows older than 5 minutes as 'failed', and publishes
  chat_status='idle' on the user channel so the UI dot drops without a
  refresh. Closes the mid-session crash UX gap; the v1.12.1 boot sweep
  only fires once at startup, so sessions used to stay stuck until next
  reboot. setInterval cleaned up via app.addHook('onClose'). Mirrors
  handleAbortOrError's publish pattern.

- experimental_repairToolCall wired through AI SDK v6 streamText. Pass-
  through implementation: log + return the original toolCall so the
  stream keeps going. executeToolPhase's existing error paths (unknown
  tool name → 'unknown tool: X' result; zod-reject → 'tool X rejected
  — field: required') already surface bad calls to the model; the value
  here is preventing the AI SDK from THROWING on parse errors and
  killing the whole stream. Owed since v1.13.1-A.

Smoke verified:
- statement_timeout = '30s' confirmed via SHOW.
- Tool path normal flow intact (list_dir prompt → tool_call → result
  → final assistant). No malformed tool calls in the test run; repair
  log will surface them when qwen3.6 actually emits one.
- Alpha order verified at runtime via the dist bundle: match: true.
- Sweeper logic not traffic-tested (no stuck rows to find), but the
  SQL UPDATE + broker.publishUser pattern is identical to handleAbort
  and the boot sweep — synthesis-only verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:46:03 +00:00
ac1a71f583 v1.13.1-C: port ask_user_input correlation to parts + wire reasoning_parts end-to-end
Pass 1 — ask_user_input correlation port (messages.ts:478, :549):

- The two correlation queries that backed the elicitation flow used to scan
  messages.tool_calls and messages.tool_results JSON columns directly. They
  now JOIN message_parts on payload->>'id' (for the caller assistant) and
  payload->>'tool_call_id' (for the pending tool row). Semantics preserved:
  ORDER BY m.created_at DESC LIMIT 1 still picks the latest issuance, the
  already-answered 409 guard now reads payload.output, and the UPDATE +
  parts replace inside sql.begin is unchanged from v1.13.0.
- Pre-v1.13.0 history has no parts rows and is unreachable to this lookup
  path (404). Acceptable per dispatch decision — no pending elicitation
  from before v1.13.0 will still be open. JSON-column fallback can land as
  a hotfix if it ever surfaces.

Pass 2 — reasoning_parts wired end-to-end:

- types.ts/StreamResult gains `reasoning: string`. stream-phase.ts accumulates
  reasoning-delta text per stream (replacing the v1.13.1-A counter-only
  diagnostic) and returns it on the result.
- parts.ts/partsFromAssistantMessage gains an optional `reasoning` param.
  When present it emits a kind='reasoning' part at sequence 0, ahead of
  the text and tool_call parts.
- error-handler.ts/finalizeCompletion and tool-phase.ts/executeToolPhase
  both thread result.reasoning into the dual-write call so reasoning-channel
  models (qwen3.6) get persistent reasoning rows.
- payload.ts: loadContext SELECT pulls reasoning_parts from the v1.13.1-B
  view; OpenAiMessage gains an optional `reasoning` field; buildMessagesPayload
  collapses reasoning_parts into a single string per assistant message.
- stream-phase.ts/toModelMessages converts assistant messages with reasoning
  into an AI SDK ModelMessage content array starting with a ReasoningPart,
  matching the @ai-sdk/provider-utils AssistantContent union. Reasoning
  models can now replay prior reasoning context across tool-call boundaries.
- types/api.ts and apps/web/src/api/types.ts Message interface gain
  reasoning_parts (optional, nullable). Frontend doesn't render this yet —
  field reserved for a v1.14 UI surface.

Tests: 2 new in parts.test.ts cover reasoning-at-sequence-0 with and
without text content. 172 tests pass (170 prior + 2 new).

Smoke verified against the live container:
- A reasoning-prompt ("walk through 17 × 23 step by step") produced one
  message with kind='reasoning' (361 chars) at sequence 0 and kind='text'
  (429 chars) at sequence 1. Adapter log confirmed reasoning capture.
- The new correlation SQL was validated against existing tool_call /
  tool_result parts: returns the expected message_id + payload shape with
  pending state correctly identified via payload.output IS NULL.
- ask_user_input end-to-end through the UI is Sam's smoke — the Prompt
  Builder agent does not always trigger ask_user_input for these prompts,
  so synthetic verification via SQL substituted for traffic-driven cover.

Annotation: the v1.13.1-A abort-throw site in stream-phase.ts got a
one-liner comment ("AI SDK v6 fullStream returns normally on abort; check
signal explicitly.") to prevent a future refactor removing it.

v1.13.2 drops the dual-write + the JSON columns + collapses the view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:34:10 +00:00
13c3aa5b4e v1.13.1-B: read-path flip from tool_calls/tool_results JSON columns to message_parts
- schema.sql: new messages_with_parts view. tool_calls aggregates parts
  with kind='tool_call' as a jsonb array of {id, name, args}; tool_results
  picks the single sequence=0 part with kind='tool_result' as a jsonb
  {tool_call_id, output, truncated, error?}. COALESCE against the legacy
  jsonb columns means pre-v1.13.0 history (no parts rows) still reads
  correctly via the fallback, and fresh inserts (where parts dual-write
  follows the row INSERT) hit the legacy columns until the parts land.
- reasoning_parts column added to the view but not selected by any caller
  yet — v1.13.1-C extends the Message type and pulls it into the model
  payload alongside the type extension.
- Read sites switched to FROM messages_with_parts:
  - routes/chats.ts:427 (chat history GET)
  - routes/messages.ts:95 (session history GET)
  - routes/ws.ts:27 (WS snapshot on session connect, resume path)
  - services/inference/payload.ts (loadContext for model assembly)
  - services/compaction.ts (compaction's payload assembly)
- chats.ts:394 (discard_stale UPDATE RETURNING) unchanged — UPDATEs target
  messages directly and the returned shape is for a freshly-modified row
  where the legacy column is dual-written and correct.
- messages.ts:478/549 (ask_user_input correlation) intentionally not
  migrated — those query a different shape, ported in v1.13.1-C.
- Writes still target `messages` directly; the view is read-only.

Smoke verified against the live container:
- Equivalence: 5/5 messages with both legacy column and parts row return
  identical tool_calls jsonb between FROM messages and FROM messages_with_parts.
- Perf: EXPLAIN ANALYZE on the 42-message stress chat returns in ~1ms
  (50ms threshold). Bitmap Index Scan on message_parts_msg_seq_idx
  carries the parts lookups.
- API contract: GET /api/chats/:id/messages returns identical
  {id, name, args} tool_calls and {tool_call_id, output, truncated, error}
  tool_results shapes to frontend consumers — no UI changes needed.
- Inference path: sent a view_file prompt; assistant turn 1 emitted the
  tool_call, tool message captured the result, follow-up assistant turn
  read the result back via loadContext (now view-backed) and answered
  correctly. End-to-end loop intact.

v1.13.2 drops the dual-write + the JSON columns + simplifies the view
to just SELECT FROM message_parts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:22:47 +00:00
c2c4f78a26 v1.13.1-A: install AI SDK v6 + swap streamText into stream-phase.ts adapter
- Add ai@^6 and @ai-sdk/openai-compatible@^2 to apps/server.
- New services/inference/provider.ts: createOpenAICompatible against
  llama-swap (baseURL threaded from config.LLAMA_SWAP_URL, cached per
  baseURL). No apiKey — Authelia + Tailscale gate llama-swap, not keys.
- streamCompletion rewritten as an adapter over streamText. AI SDK
  fullStream parts (text-delta, tool-call, finish, error) map back to
  the legacy {content?, tool_calls?, finishReason} StreamResult shape
  that executeStreamPhase already consumes. No layer above
  streamCompletion changes.
- toModelMessages converts BooCode's OpenAI-shaped history to AI SDK
  ModelMessage[]; tool messages need toolName which we look up by
  scanning earlier assistant tool_calls for the matching id.
- buildAiTools wraps BooCode's JSON-schema tool defs via
  tool({ inputSchema: jsonSchema(parameters) }) with NO execute —
  BooCode dispatches tools in tool-phase.ts, not the AI SDK loop.
- XML fallback parser preserved as-is — qwen3.6 still emits XML tool
  calls in text content that the structured tool-call layer misses.
- reasoning-delta parts dropped with a debug-level counter — captured
  properly in v1.13.1-C.
- Abort path: streamText({ abortSignal }) wires ctx.signal through, but
  AI SDK v6 swallows the abort (fullStream iterator exits cleanly
  rather than throwing). Post-iteration `if (signal?.aborted) throw` so
  handleAbortOrError owns the row and writes status='cancelled'. Caught
  by smoke D; would have shipped as status='complete' on stop otherwise.
- Usage frame reads result.usage (inputTokens / outputTokens v6 names)
  AFTER stream drain. Single trailing publish through the existing 500ms
  throttle. Known regression: ChatThroughput's live mid-stream tick
  (v1.12.2) is gone — it now shows a single value at stream end.
  TODO(v1.13.1-followup): interpolate outputTokens during streaming
  via a delta-cadence counter (e.g. part.text.length/4 token proxy)
  and publish every 500ms; reconcile against result.usage at finish.
- Write-path dual-write from v1.13.0 unaffected.

Read path stays on JSON columns. v1.13.1-B flips reads to message_parts.

Smoke verified end-to-end against running container:
- A. Plain text: status='complete', 1 text part.
- B. Single tool prompt → multi-tool chain (4 calls): every assistant
     with tool_calls has 2 parts (text+tool_call), every tool row has
     1 part (tool_result).
- C. Multi-step covered by B's chain.
- D. Stop mid-stream: status='cancelled' written via handleAbortOrError
     after the post-iteration abort throw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:17:56 +00:00
1cb6eee24c v1.13.0: message_parts table + dual-write at every tool_calls/tool_results site
Adds a granular message_parts table (one row per text/tool_call/tool_result
chunk) without changing any read path. Old messages.content / tool_calls /
tool_results columns remain authoritative for v1.13.0; this dispatch is
write-only mirroring so the AI SDK migration in v1.13.1 can flip read
authority without a backfill window.

Schema:
  CREATE TABLE message_parts (id, message_id FK ON DELETE CASCADE,
    sequence int, kind text CHECK (text|tool_call|tool_result|reasoning|step_start),
    payload jsonb, created_at, UNIQUE (message_id, sequence))

New module services/inference/parts.ts with two pure derive helpers
(partsFromAssistantMessage, partsFromToolMessage) and insertParts that
fan-outs a multi-row INSERT via postgres-js.

Wired dual-write at every site that writes tool_calls or tool_results:
- tool-phase.ts: assistant finalize UPDATE, executed-tool UPDATE,
  ask_user_input sentinel UPDATE
- messages.ts answer flow: DELETE pending tool_result part + INSERT
  answered one inside the existing sql.begin
- skills.ts: synthetic assistant + tool INSERTs both inside existing tx
- chats.ts fork: CTE clones parts via ROW_NUMBER pairing (source→dest
  message id mapping in one statement, no N+1)
- error-handler.ts finalizeCompletion: text part for plain text-only
  assistant turns

Deviation: tool-phase.ts finalize UPDATEs and finalizeCompletion text-part
write are not wrapped in fresh sql.begin transactions. Safe in v1.13.0
because JSON columns are authoritative for reads. v1.13.1 must wrap these
sites before flipping read authority — TODO comments added at each
unwrapped site referencing v1.13.1.

Tests: 8 new unit tests for the derive helpers in
services/__tests__/parts.test.ts. Existing 162 tests untouched. 170 total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 05:46:29 +00:00
ca64bf9f0a docs: CLAUDE.md updates from /claude-md-management session
- services/inference.ts → services/inference/ directory map (v1.12.4 split)
- workspace_panes server-side jsonb (was: localStorage-only line)
- chat_status 5-state model + ChatThroughput + discard_stale endpoint
- boot-time stale-streaming sweep documented
- WS frame sync gotcha (server InferenceFrame ↔ web WsFrame)
- session_panes table noted as dropped (not deprecated)
- messages_status_check/role_check drift cleanup noted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 05:46:14 +00:00
9ef00c0268 v1.12.4: complete inference.ts split into services/inference/
- sentinel-summaries.ts: runCapHitSummary, insertCapHitSentinel,
  runDoomLoopSummary, insertDoomLoopSentinel
- inference.ts → inference/turn.ts: residue is runAssistantTurn,
  runInference, createInferenceRunner orchestration only
- inference/index.ts: re-export shim preserves the public surface
  (createInferenceRunner, runInference, runAssistantTurn,
  detectDoomLoop, DOOM_LOOP_THRESHOLD, buildMessagesPayload, plus
  type-side InferenceContext/InferenceFrame/StreamResult/TurnArgs/
  FramePublisher)
- src/index.ts + auto_name.ts + the two vitest test files updated to
  import from ./services/inference/index.js explicitly (NodeNext ESM
  doesn't honor directory-index resolution)

Final tally: 11 files under services/inference/, the largest being
sentinel-summaries.ts at 523 LoC (two near-clone summary paths kept
side-by-side until a third sentinel justifies factoring out a shared
runWrapUpSummary). turn.ts is now 326 LoC, the next-largest is
stream-phase.ts at 380. Public import surface unchanged.

tool-phase.ts → turn.ts back-edge for runAssistantTurn remains
(cycle is safe; resolved at call time).

Prepares the file structure for v1.13 AI SDK migration — streamText
swap targets stream-phase.ts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:36:35 +00:00
c87df6981a v1.12.4-rc3: extract stream-phase + tool-phase from inference.ts
- stream-phase.ts: streamCompletion, executeStreamPhase (plus sseLines,
  StreamOptions, ChatCompletionDelta/Chunk as private helpers)
- tool-phase.ts: executeToolPhase + private executeToolCall
- types.ts: shared StreamPhaseState + DB_FLUSH_INTERVAL_MS so the
  summary functions still in inference.ts can reference them without
  pulling from a phase file

Cycle: executeToolPhase recurses into runAssistantTurn, which stays in
inference.ts. Resolved by direct value back-edge — tool-phase.ts does
`import { runAssistantTurn } from '../inference.js'` and runAssistantTurn
is now exported. Safe because the dereference happens inside an async
function body, after both modules have fully evaluated. No
callback-through-args fallback needed.

inference.ts shrinks from ~1401 to ~828 LoC. Final Dispatch D moves the
sentinel summaries out and renames the residue to inference/turn.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:28:23 +00:00
8fa7b7fce9 v1.12.4-rc2: extract payload + error-handler from inference.ts
- payload.ts: buildMessagesPayload (re-exported), loadContext,
  maybeFlagForCompaction
- error-handler.ts: handleAbortOrError, finalizeCompletion

Both new files type-import InferenceContext/StreamResult/TurnArgs from
inference.ts; ESM elides type imports so there's no runtime cycle.
handleAbortOrError turned out not to call the summary functions, so
no back-edge needed.

inference.ts shrinks from ~1676 to ~1401 LoC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:09:50 +00:00
ea468ca7fb v1.12.4-rc1: extract budget, sentinels, xml-parser from inference.ts
Pure file moves. No behavior change. inference.ts retains createInferenceRunner
public surface; new files are internal to services/inference/.

- budget.ts: resolveToolBudget
- sentinels.ts: detectDoomLoop (re-exported through inference.ts),
  isCapHitSentinel, isDoomLoopSentinel, isAnySentinel
- xml-parser.ts: parseXmlToolCall, partialXmlOpenerStart

First of four refactor batches preparing inference.ts for the v1.13
AI SDK migration. inference.ts goes from 1780 LoC to ~1620.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:42:41 +00:00
eef4782383 v1.12.3: stale-stream banner with Retry/Discard
When an assistant message sits status='streaming' with no token activity
for 60+ seconds, the chat shows a banner above the input offering Retry
or Discard. Both clear the stale row via a new backend endpoint
POST /api/chats/:id/discard_stale that updates status='failed' and
publishes chat_status='idle'.

Closes the UX gap that caused the 2026-05-21 debugging spiral —
slow streams and dead streams now look different to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:48:22 +00:00
a7104691aa v1.12.2: live tok/s + ctx display next to status indicator
ChatThroughput renders inline beside StatusDot while streaming or
tool_running. Subscribes to existing usage frames via sessionEvents.
Hides when status drops to idle/error or data is older than 10s.

Addresses the 2026-05-21 spike's UX gap where slow streams looked
identical to dead streams — now there's a live token velocity readout
that immediately distinguishes the two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:45:53 +00:00
1a0a3b1673 v1.12.1: stop-handler writes terminal status + constraint cleanup + dead code removal
- handleAbortOrError now writes status='cancelled' on user stop; rows
  no longer stuck 'streaming' forever
- Drop stale messages_status_check constraint (only messages_status_chk
  remains, allowing 'cancelled' via TS MESSAGE_STATUSES)
- Remove detectSameNameLoop and DOOM_LOOP_SAME_NAME_THRESHOLD (added
  during 2026-05-21 debugging spike, never fired in any real run,
  existing detectDoomLoop covers actual failure modes)
- Remove 12 ctx.log.info diagnostic markers added during the same
  spike (verbose for production)
- Bundles workspace pane sync + status indicator overhaul +
  startup hung-row sweep landed earlier in v1.12.1 work

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:34:40 +00:00
48ee63a286 v1.12.1: rich status indicator + server-side workspace pane sync
Status indicator (StatusDot): drops the flat amber pulse for a richer set
of states — orbiting amber for streaming, spinning sky ring for tool_running,
static violet for waiting_for_input, plus the existing idle/error. Backend
chat_status frame widens from 'working|idle|error' to discriminate streaming
vs tool execution vs paused for user input.

Workspace pane sync: pane layout moves from per-device localStorage to
server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace
broadcasts session_workspace_updated on the user channel for cross-device live
sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop.
Legacy localStorage seeds the server on first hydrate, then is deleted.
Deprecated session_panes table dropped.

Resilience: startup sweep marks any stale 'streaming' message older than
5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart.
useWorkspacePanes gains validatePanes() to prune dead chatId references from
saved pane state when the chat list lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:32:02 +00:00
d58d553503 v1.12.1: same-name doom-loop guard + runAssistantTurn trace logging
Add detectSameNameLoop (threshold 5) to catch over-verification hangs
where tool args vary but the model is stuck on one tool. Add 12 structured
log points across the inference state machine (runAssistantTurn,
executeToolPhase, runDoomLoopSummary) to diagnose the deterministic hang
surfaced in v1.12.0 smoke testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:15:02 +00:00
fce8c06932 Merge v1.11.10 + doc refinements onto v1.12.0 main
# Conflicts:
#	CLAUDE.md
2026-05-21 15:22:46 +00:00
684612f3cd docs: capture v1.12 learnings in CLAUDE.md (whitelist drift, AGENTS.md single source, MCP NDJSON framing) 2026-05-21 15:19:46 +00:00
cc73ed1957 docs: refine CLAUDE.md (TurnArgs, web tools, env vars, new-tool convention) 2026-05-21 02:57:32 +00:00
3e1e17ecf6 v1.11.10: stream-cap response body at 5MB, abort on overflow 2026-05-21 02:27:31 +00:00
264 changed files with 27120 additions and 2558 deletions

33
.codecontextignore Normal file
View File

@@ -0,0 +1,33 @@
# .codecontextignore — paths codecontext skips during analysis
# Copy to your project root and customize. Same syntax as .gitignore.
# Dependencies / vendored code
node_modules/
vendor/
.venv/
venv/
__pycache__/
target/
# Build artifacts
dist/
build/
out/
.next/
.nuxt/
.svelte-kit/
# IDE / tooling
.opencode/
.vscode/
.idea/
# Test artifacts / coverage
coverage/
.nyc_output/
.pytest_cache/
# Lock files (rarely have meaningful symbols)
package-lock.json
yarn.lock
pnpm-lock.yaml

View File

@@ -10,3 +10,12 @@ POSTGRES_PASSWORD=CHANGE_ME
# Internal Tailscale address that bypasses Authelia. Override if you
# point BooCode at a different SearXNG instance.
SEARXNG_URL=http://100.114.205.53:8888
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
# sessions where the model only needs read-only filesystem access.
#
# core → view_file, list_dir, grep, find_files (~2k)
# standard → core + web_*, git_status, all 8 codecontext_* tools (~10k)
# all → every tool in ALL_TOOLS (~21k)
# BOOCODE_TOOLS=all

6
.gitignore vendored
View File

@@ -1,9 +1,13 @@
node_modules
dist
.env
CLAUDE.local.md
*.log
.DS_Store
.vite
coverage
secrets/
data/
data/*
!data/AGENTS.md
!data/skills/
!data/mcp.json

View File

@@ -1,7 +1,5 @@
# BooChat
You are the assistant running inside BooChat — a self-hosted developer chat app.
## Capabilities
- Read-only file tools: `view_file`, `list_dir`, `grep`, `find_files`
@@ -28,6 +26,25 @@ You are the assistant running inside BooChat — a self-hosted developer chat ap
- Cite file paths + line numbers for any claim about the codebase
- When uncertain about scope or intent, surface options via `ask_user_input` rather than guessing
- Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure.
- Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second.
## Output format
- Stay in Markdown by default for every reply, short or long.
- Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Detection is opportunistic — the BooChat backend tags the assistant message as an HTML artifact, opens it in a sandboxed pane, and offers Download. Do not emit HTML unprompted; long Markdown is the right answer for most explanatory output.
- When asked to produce HTML, avoid generic AI aesthetics: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font. Prefer interactive controls (sliders / knobs / SVG / side-by-side diffs) over passive prose-in-HTML. Pattern reference: claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html (Thariq Shihipar, May 2026).
- The HTML artifact is rendered in a sandboxed iframe with `connect-src 'none'``fetch()`, WebSockets, and tracking pixels do not work. All logic must be client-side.
## Convention: rules vs recipes
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
## Verification discipline
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
## Known limitations

View File

@@ -1,24 +1,39 @@
# BooCoder
# BooCoder — Container Guidance
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
You are the assistant running inside BooCoder — the write-capable companion to BooChat.
## You can
## Capabilities
- Read files (view_file, list_dir, grep, find_files)
- Edit files (edit_file, create_file, delete_file) — all changes queue in pending_changes
- Apply pending changes to disk (apply_pending)
- Revert applied changes (rewind)
- Dispatch tasks to external agents (dispatch_external_agent)
- Use MCP tools from configured servers
- Everything in `BOOCHAT.md`
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
- Shell (pending): `run_command` (Docker-isolated per-session)
## You cannot
## Constraints
- Write outside the project root (path-guard enforced)
- Write to secret files (.env, *.pem, id_rsa*, credentials.json)
- Apply changes without explicit user approval (unless auto-apply is enabled per task)
- Push to git remotes
- Access the internet except via configured MCP servers
- All writes land in a pending-changes virtual layer; nothing touches the real filesystem until `/apply`
- `run_command` executes inside the session sandbox, not the host
- No git commits, pushes, or pulls — Sam owns those
- Stop and ask before destructive operations (delete, overwrite, recreate)
## Pending changes discipline
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
## Behavior
- Show a diff preview before any write
- Group related edits into a single `/apply` batch
- If a tool fails, surface the error verbatim — don't paper over it
- Show diffs clearly. Explain what you're changing and why.
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
- If uncertain about scope, use smaller edits and verify between steps.
- Cite file paths + line numbers for context.
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
## Verification discipline
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.

199
CHANGELOG.md Normal file
View File

@@ -0,0 +1,199 @@
# Changelog
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.
## v1.16.0-codesight-merge — 2026-05-24
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
## v1.15.0-mcp-multi — 2026-05-24
Multi-server MCP client with stdio + Streamable HTTP transports, JSON config file, and per-agent tool glob patterns. Generalizes the v1.14.1 single-server Context7 PoC into a registry of named MCP servers with per-server graceful degradation. JSON config at `/data/mcp.json` (bind-mounted alongside `AGENTS.md`) matches opencode's `mcpServers` schema shape so server entries are copy-pasteable. Config file missing = no MCP (opt-in by file presence). Stdio transport spawns a persistent subprocess via the SDK's `StdioClientTransport` with NDJSON framing; Streamable HTTP reuses the v1.14.1 pattern via `StreamableHTTPClientTransport`. Tool prefix generalized from `context7_<name>` to `<serverName>_<toolName>` with a reverse `toolToServer` map for dispatch routing. Per-agent AGENTS.md `tools:` field now supports glob patterns (`context7_*`, `!web_*`) via `matchToolGlob` (last-match-wins, `!` prefix denies); replaces the exact-match `.includes()` in `stream-phase.ts`. Glob patterns bypass `ALL_TOOL_NAMES` validation in the parser since MCP tool names aren't known at parse time. `refreshToolNames()` in `agents.ts` rebuilds the `DEFAULT_TOOLS` snapshot after `appendMcpTools` so agents without explicit `tools:` lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant preserved: all MCP tools with `readOnlyHint: false` rejected at discovery. Result size capped at 5MB. Shutdown hook closes all transports. v1.14.1 env vars (`MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`) removed — superseded by the config file. Default `data/mcp.json` ships with Context7 disabled; flip `"enabled": true` to activate. 363/363 server tests passing (27 new: multi-server wrapping, glob matching, routing, degradation). No schema changes, no frontend changes.
## v1.14.1-mcp-poc — 2026-05-23
Single-server MCP client PoC against Context7. New `apps/server/src/services/mcp-client.ts` (~200 lines) wraps `@modelcontextprotocol/sdk` v1.29.0 with Streamable HTTP transport. On startup (when `MCP_CONTEXT7_URL` is set), connects to Context7, discovers tools via `tools/list`, wraps each as a `ToolDef` prefixed `context7_<name>`, and appends to `ALL_TOOLS` (alpha-sorted for prompt-cache stability). `appendMcpTools()` in `tools.ts` handles the late-registration; `ALL_TOOLS` changed from `ReadonlyArray` to mutable to support it. Read-only invariant guard rejects any MCP tool with `readOnlyHint: false` (MCP SDK v1.29.0 uses `readOnlyHint`, not `readOnly`). Tool dispatch is transparent — `executeToolCall` routes MCP tool calls through the `ToolDef.execute` wrapper, which strips the `context7_` prefix before calling the MCP server. Graceful degradation: MCP server down at startup → zero tools, warn log; MCP server down mid-session → error-shaped result, model self-corrects. Result size capped at 5MB with truncation (matches native `view_file`'s `MAX_FILE_BYTES`). Adversarial review caught that the Zod `.default('https://...')` on the URL config made MCP effectively always-on instead of opt-in — fixed by removing the default. 348/348 server tests passing (16 new mcp-client tests covering tool wrapping, read-only guard, name prefixing, content extraction). No schema changes, no frontend changes. Proves the MCP tool-discovery → tool-call → result-render loop end-to-end before the full v1.15 port.
## v1.14.0-outer-loop — 2026-05-23
Converts the inference engine's ad-hoc `executeToolPhase → runAssistantTurn` recursion into an explicit `while` loop with a configurable step cap. A step is one stream-and-tool-execute iteration; the loop terminates on non-tool finish, step-cap hit, doom-loop, budget exhaustion, abort, or synthesis success. `MAX_STEPS = 200` is the hard ceiling (4x the old effective limit from budget); per-agent `steps:` field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5, Architect: 20, others: unset = bounded only by MAX_STEPS). `executeToolPhase` no longer recurses — returns a `ToolPhaseResult` struct (`action: 'continue' | 'paused' | 'synthesis_done'`) so the caller (the while loop) decides whether to continue or break. `steps: 0` is handled as "no tool calls allowed" — one text-only stream phase, tool calls ignored with a warn log. Step-cap hits produce a sentinel summary (reuses `cap_hit` kind so `CapHitSentinel.tsx` renders it without frontend changes; text distinguishes "Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated from pre-recursion position to top of loop body — same predicate (`detectDoomLoop`), same threshold (3 identical calls), `break` instead of `return`. `step_start` parts are in the schema CHECK but not emitted as message_parts in v1.14 — writing to the assistant message before the stream phase creates a sequence-0 collision with `partsFromAssistantMessage`; a structured log line is emitted instead. Adversarial review caught the collision pre-deploy. 332/332 server tests passing; no frontend changes. Pairs with `v1.13.20-drop-legacy-cols` (parts is now the sole source of truth, and this batch's loop operates entirely through parts).
## v1.13.20-drop-legacy-cols — 2026-05-23
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves; `message_parts` is now the only source of truth for tool-call and tool-result data. 10 dual-write sites stripped (5 in `tool-phase.ts`, 2 in `routes/skills.ts`, 2 in `routes/messages.ts`, 1 in `routes/chats.ts` fork-clone) — recon's grep-driven inventory caught 2 sites beyond the original v1.13.2 roadmap count. `messages_with_parts` view simplified to parts-only subselects (COALESCE fallbacks gone) and rewritten via `CREATE OR REPLACE VIEW` BEFORE the column DROP since Postgres rejects column-drop on view-referenced cols. Adversarial review caught a runtime bug the green test suite missed: `chats.ts:/api/chats/:id/discard_stale` had a `RETURNING ... tool_calls, tool_results, ...` clause referencing the dropped columns; would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE-then-SELECT-from-view so the response keeps the parts-synthesized fields. `Message` API type retains `tool_calls?` / `tool_results?` fields (override on the original v1.13.2 plan) — the view continues to populate them from parts, so the wire shape is unchanged and the frontend needs no updates. v1.12.1 cleanup block (`DROP CONSTRAINT messages_status_check`/`messages_role_check`) removed — those one-shots have done their work. `tool_cost_stats.test.ts` had a direct `INSERT INTO messages` touching the legacy columns that wasn't in the roadmap's inventory; rewritten to parts-table inserts and confirmed semantically faithful. 339/339 server tests passing including the 7 DB-integration tests (live-DB applied the schema migration and ran the parts-only view end-to-end). Pairs with `v1.13.0-ai-sdk-v6` (which introduced the dual-write) and `v1.13.1-B` (which moved the read path to `messages_with_parts`); umbrella `v1.13` tag ships on the same commit.
## v1.13.19-html-artifact-panes — 2026-05-23
Pane-based artifact viewer with on-request HTML support. Every assistant message gets an "Open in pane" icon button (`PanelRightOpen`, mobile 44px tap-target) in `MessageBubble`'s ActionRow; click opens the message in the workspace splitter as either a Markdown pane (Copy raw source + Download `.md`) or an HTML pane (Download `.html` only, no Copy). The HTML path triggers when the model emits a self-contained `<!DOCTYPE html>` or fenced ` ```html` artifact (opt-in only — `BOOCHAT.md` rule says Markdown is default at every length; HTML only on explicit user request like "render this as HTML"). Backend detection in `finalizeCompletion` (`error-handler.ts`) writes a new `message_parts.kind='html_artifact'` row with payload `{html_content, char_count, title}` (`<title>` → first `<h1>` → first 80 chars of inner text). Schema CHECK extended via the v1.13.13 drop-and-re-add pattern. 1MB cap is graceful — over-cap artifacts skip the part write and plain content lands; decision factored into a pure `decideHtmlArtifactWrite` helper so the warn-and-skip branch is unit-testable without mocking the full InferenceContext. Pane state is reference-only (`{chat_id, message_id, title}`) — content is fetched on mount, keeping `sessions.workspace_panes` jsonb small and avoiding 1MB blobs riding the `session_workspace_updated` WS frame. New `services/artifacts.ts` ships slug derivation (Markdown: first `#` heading → first 6 words; HTML: `<title>``<h1>` → inner text) and write helpers that realpath the artifacts directory after `mkdir` to close a symlink-escape gap (`assertArtifactsDirSafe`). `routes/artifacts.ts` exposes POST `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` (writes to `<projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext>`) plus GET `/api/projects/:project_id/artifacts/:filename` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox` defense-in-depth on LLM-served HTML. iframe sandbox locks to `allow-scripts allow-clipboard-write allow-downloads` with no `allow-same-origin` and uses `srcDoc` (not `src`) for opaque-origin isolation. Frontend extracts `MarkdownRenderer.tsx` from `MessageBubble`'s inline `MarkdownBody` for reuse; `MarkdownArtifactPane.tsx` / `HtmlArtifactPane.tsx` render with loading + error states. 404-vs-real-error discrimination in `openInPane`: a real network/500 failure toasts and bails instead of silently masquerading as a Markdown pane. 31 new server unit tests (slug derivation, detection positive/negative, write helpers, symlink-escape, 1MB cap, real-symlink filesystem test); 332/332 server tests passing; `tsc -p apps/web/tsconfig.app.json --noEmit` clean; `pnpm -C apps/web build` green. Smoke deferred to first deploy.
## v1.13.18-codecontext-file-path — 2026-05-22
Fix: four codecontext wrappers (`get_file_analysis`, `get_symbol_info`, `get_dependencies`, `get_semantic_neighborhoods`) forwarded `file_path` to the sidecar unchanged, but the sidecar's index is keyed on absolute paths — every relative path from the model returned "File not found in graph" (three back-to-back failures in one chat at 17:56 UTC, ~48 s of wasted tool budget). New `resolveProjectPath` helper in `codecontext_client.ts:64-89` realpath-resolves the candidate, applies the same escape check as the existing `target_dir` resolver (matching the error template byte-for-byte except the field name), and falls through with the normalised absolute on ENOENT so the sidecar issues its own self-correctable "File not found" error. Wired into `callCodecontext` once at the args-spread site — all four wrappers benefit without per-wrapper edits. `.trim()` added to all four `file_path` Zod schemas to absorb trailing newlines from model output. Adversarial review caught a P2 escape-bypass: an absolute path with `..` (e.g. `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip through the literal prefix-check, fixed by `resolve()`-normalising the absolute branch too. 9 new test cases in `codecontext_client.test.ts` (7 spec scenarios + symlink-out-of-root + absolute-with-`..` ENOENT) plus a 1-line update in `codecontext_tools.test.ts` asserting the new resolved-absolute contract. Pairs with `v1.13.17-cross-repo-reads` — both harden path traversal, but v1.13.18 stays inside the project root while v1.13.17 widens access outside it.
## v1.13.17-cross-repo-reads — 2026-05-22
On-demand read access to paths outside the session's primary project root. Closes the dead-end where `pathGuard` rejected every cross-repo read with no recovery path. New `request_read_access(path, reason)` tool emits an `ask_user_input`-style pause; user picks Allow/Deny via inline chips in `RequestReadAccessCard.tsx`; on Allow, the new `POST /api/chats/:id/grant_read_access` endpoint re-resolves the grant root and appends to `sessions.allowed_read_paths` (new `TEXT[]` column, default empty). Grant unit per design D1 = nearest registered `projects.path` ancestor → else nearest repo-shaped ancestor (`.git/` / `package.json` / `go.mod` / `Cargo.toml`) under `PROJECT_ROOT_WHITELIST` → else refuse without prompting. `pathGuard` extended with an optional `extraRoots` argument threaded from `session.allowed_read_paths` through `executeToolCall` to the four filesystem tools (view_file, list_dir, grep, find_files); `view_file` re-anchors the secret-guard check on `basename(real)` whenever the path resolved via a grant root so `.env` / `id_rsa*` deny still fires across grants. `grant_resolver.ts`'s ancestor walk checks the whitelist invariant on every iteration (not just final parent) so a symlinked input can't escape mid-walk. PATCH `/api/sessions/:id` exposes `allowed_read_paths` only for revocation: zod refines paths to absolute + no traversal markers, and a runtime subset guard (`findUnauthorizedAdditions`) rejects any entry not already present in the row, so a malicious `curl -X PATCH -d '{"allowed_read_paths":["/etc"]}'` 400s instead of bypassing the grant flow. Settings pane gains a per-session revoke list; archiving the session clears grants implicitly. 11 grant_resolver tests pin the symlink-escape-mid-walk guard (Sam's checkpoint-1 ask) and the nearest-project disambiguation; 8 path_guard tests cover extraRoots traversal; 8 sessions PATCH tests cover the subset guard including the `/etc` bypass attempt. Pairs with `v1.13.16-xml-parser` (model now both self-recovers from a wrong tool name AND from a refused path).
## v1.13.16-xml-parser — 2026-05-22
Two-part fix for the model-emitted XML drift the v1.13.15 investigation surfaced. **Parser extension:** `xml-parser.ts` now recognizes the Anthropic `<invoke name="…"><parameter name="…">…</parameter></invoke>` shape alongside the existing Qwen/Hermes `<tool_call><function=…>…</function></tool_call>` shape. qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted as an Architect-style agent (Claude Code documentation in its pre-training corpus). Both formats route through the same synthetic-id `xml_call_${idx}` ToolCall path. The existing Qwen parser was tightened to tolerate whitespace around `=` (`<function = name>` shape) so a stray space doesn't get absorbed into the function name. **Unknown-tool recovery hint:** new `tool-suggestions.ts` exports `levenshtein()` + `suggestToolName()` + `formatUnknownToolError()`. When the dispatcher (`tool-phase.ts:executeToolCall`) receives an unknown tool name, the error returned to the model includes a "Did you mean: X?" hint based on Levenshtein distance ≤3 or substring match against `Object.keys(TOOLS_BY_NAME)`. Targets the qwen3.6 drift to `read_file` → suggest `view_file`. Test coverage in `xml-parser.test.ts` (46 tests, all green) covers both parsers, the partial-opener detector for both flavors, the unified extraction helper, and the new error formatter.
## v1.13.15-codecontext-synth — 2026-05-22
Forced second-inference synthesis pass for codecontext overview-class tools (`get_codebase_overview`, `get_framework_analysis`, `get_semantic_neighborhoods`). After the tool result lands, the pipeline expands the truncated head via in-process `readTruncation`, extracts referenced file paths from the full content, auto-fetches top-N files + project docs (BOOCHAT.md, AGENTS.md, *roadmap*.md, CONTEXT.md) under a 32k-token budget with explicit drop-priority order, then streams a synthesis turn that replaces the recursive `runAssistantTurn`. The 32k truncated head still ships to the synth model (token-budget contract preserved); the expansion is reference-extraction-only. Falls through to recursion on timeout (90s), model error, or non-2xx; user-abort marks the synth message `status='failed'` and re-throws (the outer abort handler operates on the parent turn's message, not the new synth row — without explicit marking, the row would sit `streaming` until the 5-min sweeper, tripping the 60s stale-stream banner). Adds `'synthesis'` to `message_parts.kind` CHECK constraint via `DROP CONSTRAINT IF EXISTS` + `DO $$ pg_constraint` idempotency-guarded re-add. Smokes #1, #2, #6 all clean; smokes #3#5 are content-quality checks for UI review.
## v1.13.14-skills-audit — 2026-05-22
Multi-topic batch. **Skills audit (headline):** vendored all 26 skills from `/home/samkintop/opt/skills/` into repo-local `data/skills/` (the `/opt/skills:/data/skills` override mount removed from `docker-compose.yml` so skills are auditable per-batch in git). Audited via 5 parallel Claude Code agent-teams running mgechev's 4-step protocol per skill — 14 survive with gerund-form names + refined triggers; 11 dropped (duplicates, BooCode-irrelevant patterns, Claude-already-does-natively); 1 (`verification-before-completion`) migrated to `BOOCHAT.md`/`BOOCODER.md` as an always-true rule. The Codeminer42 "rules vs recipes" split codified in those files. **Token tracking + stale-stream banner fix:** same root cause — `IsoTimestamp = z.string()` in `ws-frames.ts` was failing on postgres `Date` objects, silently dropping every `message_complete` / `session_updated` / `chat_updated` frame through the `v1.13.13-ws-publish` Zod gate; `z.preprocess(v => v instanceof Date ? v.toISOString() : v, ...)` applied to the primitive on both server + web (parity test still passes). **Codecontext ignore:** `codecontext_client.ts` auto-installs `.codecontextignore.template` into any project's root on first call (stops the upstream empty-source-file parser crash on foreign projects' `node_modules`). **Budget bump:** `BUDGET_READ_ONLY` + `BUDGET_NO_AGENT` 30 → 50 (real recon need ~27 + headroom for codecontext failure-retry turns; doom-loop guard catches the loop class anyway). **UI:** queued-message dropdown → edit / force-send / cancel buttons in `ChatPane.tsx`; `ChatThroughput` removed from desktop tab strip (mobile tab switcher keeps it). Audit decisions in `openspec/changes/v1.13.12-skills-audit/audit-notes.md`.
## v1.13.13-ws-publish — 2026-05-22
Second half of the WebSocket-frame-typing batch. Converts the existing ~50 inference + auto_name publish sites (via the `index.ts` adapter) plus ~30 direct `broker.publish*` call sites in routes + compaction, so every server-emitted frame now goes through Zod validation at the broker boundary. Pairs with `v1.13.12-ws-schemas`.
## v1.13.12-ws-schemas — 2026-05-22
First half of the WebSocket-frame-typing batch. Adds `apps/server/src/types/ws-frames.ts` with Zod schemas for all 27 wire-format frame types (discriminated union `WsFrameSchema` + `KNOWN_FRAME_TYPES` diagnostic lookup), duplicated byte-identical at `apps/web/src/api/ws-frames.ts` with a parity test. Introduces the `publishFrame` / `publishUserFrame` wrappers that fail-closed on schema mismatch.
## v1.13.11-tools — 2026-05-22
Tiered tool loading via `BOOCODE_TOOLS` env var (`core` | `standard` | `all`). Core = 4 read-only fs tools (~2k token schema cost). Standard = +web + git + codecontext (~10k). All (default) = every tool in `ALL_TOOLS` (~21k). The var is a ceiling — narrows agent whitelists, never expands. Pattern lifted from `eyaltoledano/claude-task-master`.
## v1.13.10-openspec — 2026-05-22
Adopt `Fission-AI/OpenSpec`'s `openspec/changes/<slug>/{proposal,tasks,design}.md` shape for BooCode's own batch docs. Existing batch docs (`boocode_batch10.md`, `handoff_v1.13.8_prefix_verify.md`, `handoff_v1.13.10_per_tool_cost.md`) moved into `openspec/changes/archived/` via `git mv` to preserve history. Zero-dep documentation reformat.
## v1.13.9-agentlint — 2026-05-22
Manual audit of instruction files against `0xmariowu/AgentLint`'s 31-check standard. Removed identity-opener sections from `BOOCHAT.md` and `BOOCODER.md` (emphatic decoration the model doesn't need). Added `CLAUDE.local.md` to `.gitignore` — Claude Code's Glob ignores `.gitignore` by default, so local overrides were otherwise readable by any agent walking the workspace. `CLAUDE.md` passed all 10 checks unchanged.
## v1.13.8-tool-cost — 2026-05-22
Per-tool prompt/completion-token rolling averages surfaced in AgentPicker as at-a-glance cost hints. Implementation is the `tool_cost_stats` SQL view over `messages_with_parts` (`LATERAL jsonb_array_elements` on `tool_calls`), plus a read endpoint and a tooltip extension. Equal-split attribution — multi-tool turn divides tokens N-ways; the 100-call rolling mean absorbs split noise. Filters out `cap_hit` / `doom_loop` sentinels. Source data already lands via existing UPDATEs that `v1.13.5-stability-bundle`'s `includeUsage: true` fix made non-NULL.
## v1.13.7-compaction-trigger — 2026-05-22
Compaction overflow trigger lowered to `floor(0.85 × ctx_max)`, replacing the v1.11.0-era `ctx_max 20_000` formula. Old formula gave only 7.6% headroom at 262k context and 0 budget for ≤20k contexts (never fired). New formula gives consistent 15% summarizer headroom across all model sizes. Opencode pattern lift from `session/overflow.ts`.
## v1.13.6-prefix-stability — 2026-05-22
System-prompt prefix stability verify-and-measure. Recon during planning disproved the original DB-cache premise: `buildSystemPrompt` already runs over inputs mtime-cached at the file layer (BOOCHAT.md, AGENTS.md global+per-project), and DB scalars are byte-stable until edited. This batch closes the verification gap with instrumentation, not implementation — `buildSystemPromptWithFingerprint` computes SHA-256 over the assembled prefix and a per-session `Map` observer fires `prefix-drift` (warn) on hash change with field-level `changed_inputs` diff.
## v1.13.5-stability-bundle — 2026-05-22
Five fixes for latent regressions surfaced during the cosmetic-revert investigation. (1) `provider.ts``includeUsage: true` on `createOpenAICompatible` (default false omitted `stream_options.include_usage`; llama-swap never emitted usage; tokens_used / ctx_used were NULL on every assistant row since `v1.13.0-ai-sdk-v6`). (2) `MessageList.tsx``hasText = m.content.trim().length > 0` to skip whitespace-only tool-call-only turns rendering empty bubbles. (3) `BUDGET_NO_AGENT` raised 15 → 30 to match read-only agent cap. (4) `payload.ts` skips status='failed' + complete-but-empty assistant rows so cap-hit + Continue doesn't upstream-reject. (5) Misc UI sanitization.
## v1.13.4-reasoning-fix — 2026-05-22
Compaction head-assembly audit caught one fix: reasoning was omitted from the summarizer's view of tool-bearing turns, silently degrading summary quality for reasoning-channel models (qwen3.6). `v1.13.0-ai-sdk-v6` had wired reasoning end-to-end into inference but missed this one read site. `CompactionMessage` extended with `reasoning_parts`; `buildHeadPayload` embeds it as a `<reasoning>...</reasoning>` prose prefix on the assistant content (OpenAI wire shape has no structured reasoning field).
## v1.13.3-truncate — 2026-05-22
Port of opencode's `truncate.ts`. Full tool output retrievable via opaque `tr_<12 base32 chars>` id (~60 bits entropy) and a new `view_truncated_output(id)` tool. Tmpfs storage at `/tmp/boocode-truncations/` (overridable via `BOOCODE_TRUNCATION_DIR`), 5MB cap, 7-day TTL, orphan-reap on the periodic 60s sweeper. Wired through four tools: `view_file`, `list_dir`, `web_fetch`, `codecontext_client`. Each returns the existing sliced view plus an `outputPath` field when truncation fires.
## v1.13.2-compaction-prune — 2026-05-22
Two-tier compaction prune — opencode pattern that was half-shipped in v1.11.0. New `message_parts.hidden_at` column with partial index on `WHERE hidden_at IS NULL`. `messages_with_parts` view changed from `COALESCE(parts, legacy)` to a CASE that distinguishes "no parts at all → fall back to legacy column for pre-v1.13.0 history" from "all parts hidden → drop the row from the model payload" (smoke caught the `COALESCE` leaking hidden parts back via legacy fallback). `prune.ts` scans `tool_result` parts newest-first, protects the last 40k tokens, marks older candidates hidden once the combined estimate clears 20k.
## v1.13.1-cleanup-bundle — 2026-05-22
Four independent items owed from prior dispatches. (1) `statement_timeout = '30s'` at the database level (documented in `schema.sql` but applied operationally — `ALTER DATABASE` can't run inside a `DO` block). (2) Tool registry alpha-sorted at module load — llama.cpp's prompt cache hits on byte-identical prefixes; reordering tools near the top of the system prompt would invalidate every cached turn. (3) Periodic 60s stuck-row sweeper. (4) `experimental_repairToolCall` to keep streams alive on malformed qwen3.6 tool args (pass-through implementation — logs and forwards unmodified; existing zod-reject path routes back to the model).
## v1.13.0-ai-sdk-v6 — 2026-05-22
Major migration to AI SDK v6. Introduces the `streamCompletion` adapter (`services/inference/stream-phase.ts`) over `streamText`, with five known gotchas the LSP can't catch — abort signals swallowed by `fullStream` (post-iteration throw required), usage lands only at stream end via `await result.usage`, tools have no `execute` field (BooCode dispatches in `tool-phase.ts`), and tool-call-only turns may emit a leading `\n` text-delta. Also ships the `messages_with_parts` view (parts-merge read path) and wires `reasoning_parts` end-to-end via a `ReasoningPart` in the v6 ModelMessage. Ports `ask_user_input` correlation queries from JSON columns to `message_parts` JOINs.
## v1.12.4-inference-split — 2026-05-21
Complete `inference.ts` split into `services/inference/`. Pieces: `turn.ts` (orchestration — `runAssistantTurn` / `runInference` / `createInferenceRunner`), `sentinel-summaries.ts` (`runCapHitSummary`, `runDoomLoopSummary`), `stream-phase.ts`, `tool-phase.ts`, `provider.ts`, `payload.ts`, `prune.ts`, `budget.ts`, `xml-parser.ts`, `error-handler.ts`, `sentinels.ts`, `parts.ts`, `types.ts`. Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution).
## v1.12.3-stale-banner — 2026-05-21
Stale-stream banner with Retry/Discard. When an assistant message sits `status='streaming'` with no token activity for 60+ seconds, the chat shows a banner above the input. Both actions clear the stale row via new `POST /api/chats/:id/discard_stale` (updates `status='failed'`, publishes `chat_status='idle'`). Closes the UX gap from the 2026-05-21 debugging spiral — slow streams and dead streams now look different.
## v1.12.2-live-toks — 2026-05-21
Live tok/s + ctx display next to the status indicator. `ChatThroughput` renders inline beside `StatusDot` while streaming or tool_running. Subscribes to existing `'usage'` WS frames (500ms-throttled, carrying `completion_tokens` + `ctx_used` + `ctx_max`) via `sessionEvents`. Hides when status drops to idle/error or data is older than 10s. Addresses the same UX gap as `v1.12.3-stale-banner` — gives users a live token velocity readout that immediately distinguishes slow from dead.
## v1.12.1-stop-handler — 2026-05-21
`handleAbortOrError` now writes `status='cancelled'` on user stop; rows no longer stuck `streaming` forever. Drops stale `messages_status_check` constraint (only `messages_status_chk` remains, allowing 'cancelled' via TS `MESSAGE_STATUSES`). Removes `detectSameNameLoop` and `DOOM_LOOP_SAME_NAME_THRESHOLD` (added during the 2026-05-21 debugging spike, never fired in any real run) plus 12 verbose `ctx.log.info` diagnostic markers from the same spike. Bundles workspace pane sync + status indicator overhaul + startup hung-row sweep that landed earlier in v1.12.1 work.
## v1.12.0-codecontext — 2026-05-21
Adds the `codecontext` sidecar (Go-based code-graph indexer at `codecontext:8080/v1/<tool_name>` over `boocode_net`) plus container guidance and skills runtime updates. Introduces the `chat_status` WS frame (`streaming | tool_running | waiting_for_input | idle | error`, widened from `working|idle|error`). Drops the deprecated `session_panes` table — workspace pane state moves to `sessions.workspace_panes jsonb` for cross-device sync via `PATCH /api/sessions/:id/workspace`.
## v1.11.1-consolidation — 2026-05-21
Rollup of v1.11.0v1.11.10 work that was shipped piecemeal. Covers anchored rolling compaction (single `summary=true` row per chat that supersedes itself), doom-loop guard via `detectDoomLoop`, `path_guard` secret-filename deny list, web tools (`web_search` against SearXNG + `web_fetch` with SSRF/private-IP block), and the 5MB stream-cap on response bodies with abort-on-overflow.
## v1.11.0-context-bar — 2026-05-20
Persistent context-window tracker in `ChatPane` + `ctx_max` capture via `${LLAMA_SWAP_URL}/upstream/<model>/props`. First inferences after a boocode boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model yet — 60s negative cache TTL recovers on next turn. Replaced an earlier dead read of `parsed.timings.n_ctx` which never carried n_ctx.
## v1.10.1-booterm-user — 2026-05-19
Per-user shell privilege drop in the booterm container via `gosu` in `tmux.conf` default-command. Shells launched in browser terminal panes drop privs to `samkintop` rather than running as root inside the container.
## v1.10.0-booterm — 2026-05-18
Second container (`apps/booterm`, port 9501, bookworm-slim+glibc). Fastify + node-pty + tmux. Browser terminal panes connect via WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-<sid>`, per-pane window `term-<pid>`. xterm-addon-webgl with `document.fonts.load(...)`-gated init (Canvas2D doesn't honor `font-display: block`) and iOS-friendly visibility-change context recreation.
## v1.9.2-ask-user-input — 2026-05-18
`ask_user_input` elicitation tool. Pauses the inference loop and surfaces a prompt to the user; their response routes back as the tool result. Correlation initially via `messages.tool_calls` / `tool_results` JSON columns (later ported to `message_parts` in `v1.13.0-ai-sdk-v6`).
## v1.9.1-skills — 2026-05-18
Skills runtime + `/skill` slash command with autocomplete. Server-side parser, tools, `/api/skills`, and mount. Hardens `.dockerignore` to exclude `secrets/` and `data/`. Drops the type-to-confirm gate on chat delete (plain Cancel/Confirm only — per workspace convention).
## v1.9.0-themes-settings — 2026-05-17
Settings pane + per-project defaults + bulk archive + themes lift. `themes-v1` (18 preset palettes) ships in the same batch with a Settings picker for live theme switching.
## v1.8.2-cap-hit — 2026-05-17
Tool-loop cap-hit summary — when an assistant exceeds the per-turn tool budget, a sentinel `role='system'` row with `metadata.kind='cap_hit'` is inserted and a summary turn runs to give the user a coherent endpoint. Also compacts the tool-call UI rendering.
## v1.8.1-agents-global — 2026-05-16
Global agents (`data/AGENTS.md` bind-mounted at `/data/AGENTS.md`) + parser robustness + WS reconnect toast. Per-project `AGENTS.md` mechanism (`getAgentsForProject`) remains for *other* projects; the BooCode repo itself uses global-only to eliminate two-files-must-stay-in-sync drift.
## v1.8.0-agents — 2026-05-16
Tier 2 agents — `AGENTS.md` registry + per-session agent picker. Also lands mobile tab switcher, branch indicator, and the `git_status` tool.
## v1.7.0-drag-drop — 2026-05-16
Drag-drop + paste-as-attachment for long text in the chat input.
## v1.6.0-mobile — 2026-05-16
Full mobile suite. Adds `useViewport` (matchMedia breakpoints mobile <768 / tablet 7681023 / desktop ≥1024), `useSidebarDrawer` / `useRightRailDrawer` (Context + auto-close on `useLocation().pathname` change), `useLongPress` (500ms timer, synthetic `contextmenu`), `usePullToRefresh` (80px threshold, 600ms hold), `SwipeablePaneTab` (60px close, 30px vertical bail). Mobile headers with safe-area padding, hamburger left, FolderTree right. Tap targets at `max-md:min-h-[44px] max-md:min-w-[44px]`. Raises `MAX_TOOL_LOOP_DEPTH` 5 → 15. Right-rail becomes a drawer on mobile.
## v1.5.1-bootstrap — 2026-05-16
Bootstrap fixes — git + ssh installed in the boocode container, Tailscale host rewrite, `/opt/projects` label correction for the create-new-project bootstrap flow.
## v1.5.0-refactor-tests — 2026-05-16
Refactor split (FileBrowserPane / Workspace / `runAssistantTurn`) + vitest harness + unit tests for security-critical pure functions. Scopes the `/opt` mount to `/opt/projects` (writable) plus `PROJECT_ROOT_WHITELIST=/opt` (read-only resolution for add-existing). Surfaces swallowed errors and removes dead `session_renamed` paths.
## v1.4.0-fork-header — 2026-05-16
Fork from message + delete message + header polish + general housekeeping.
## v1.3.0-chats-projects — 2026-05-16
Chats-in-sessions era. Adds force-send, `/compact`, right-rail file browser, archive/rename/Open-in-Gitea sidebar context menu, archived projects landing page, create-project bootstrap with Gitea remote setup, landing-card buttons, 1000px content cap. Dedup audit and chat archive/delete from the sidebar.
## v1.2.0-multi-pane — 2026-05-15
Multi-pane workspace (batch 3, T1T8). `session_panes` schema (later replaced by `sessions.workspace_panes jsonb` in v1.12.0), `Pane` discriminated union, broker user channel + `/api/ws/user`, `file_ops` + `file_index` services, `PaneShell` / `ChatPane` / `FileBrowserPane` / `PaneTab` / `Workspace` components, `usePanes` hook, Shiki integration in `CodeBlock`. Up to 5 panes per session; default chat pane created on `POST /api/sessions`.
## v1.1.0-markdown-sidebar — 2026-05-15
Markdown rendering, message actions, tok/s + ctx display, AI session naming. Sidebar restructure — chats nested under projects (max 5 + view-all), live updates via WS.
## v1.0.0-initial — 2026-05-14
Initial commit. Skeleton of the monorepo: `apps/server` (Fastify + postgres), `apps/web` (React + Vite), basic chat loop against llama-swap.

View File

@@ -33,7 +33,7 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
docker compose build --no-cache boocode && docker compose up -d
```
Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured.
Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured. Vitest include glob is `src/**/__tests__/**/*.test.ts` (see `apps/server/vitest.config.ts`) — tests outside `src/**/__tests__/` silently won't run; match the per-domain convention (`apps/server/src/services/__tests__/foo.test.ts`).
## Architecture
@@ -46,14 +46,37 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps
- **Zod** for request validation and config parsing.
Key services:
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
- **`services/tools.ts`** — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through `path_guard.ts` which resolves against project root.
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`, `MAX_STEPS`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase → returns `ToolPhaseResult`; no longer recurses into runAssistantTurn — v1.14.0 converted the recursion to an explicit while loop in turn.ts), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + runStepCapSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (parts-table write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts` — v1.13.20 made parts the sole source of truth), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope populated from loop locals each iteration; reset in `runInference` at user-message boundary. The outer loop in `runAssistantTurn` (v1.14.0) runs `while (stepNumber < effectiveCap)` where `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200)`. Per-agent `steps:` field in AGENTS.md frontmatter. `steps: 0` means text-only (no tool execution). Step-cap hit writes a `cap_hit` sentinel so `CapHitSentinel.tsx` renders it.
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop. Only `description` + `inputSchema: jsonSchema(parameters)` — surfacing tool-call parts via `fullStream` and stopping is what we want.
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `services/inference/provider.ts`. The adapter defaults it false, omitting `stream_options.include_usage` from the request body; llama-swap then never emits the usage block and `result.usage.inputTokens/outputTokens` resolve to `undefined`. Latent regression from v1.13.1-A through v1.13.7 — every assistant row in that window has `tokens_used`/`ctx_used` NULL. Don't remove this flag during refactor.
- **Tool-call-only turns may emit a leading `\n` text-delta** as the assistant content. `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check — otherwise whitespace-only content renders an empty bubble + ActionRow between every tool call (v1.13.7 fix). `payload.ts:buildMessagesPayload` also skips `status='failed'` AND complete-but-empty (no content, no tool_calls) assistant rows to avoid "Cannot have 2 or more assistant messages at the end of the list" upstream rejections after cap-hit + Continue.
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart` — BooCode's OpenAI-shape history doesn't carry 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 }` matching the v6 `ToolResultOutput` union. Assistant messages with reasoning emit a `ReasoningPart` first in the content array (v1.13.1-C).
- **`experimental_repairToolCall`** (v1.13.3) wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through implementation — logs the bad call and returns it unmodified; `executeToolPhase`'s existing zod-reject error path routes it to the model on the next turn.
- **`chat_status` frame shape** (published via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'` (widened from `working|idle|error` in v1.12.1). Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders inline beside `StatusDot` only when streaming or tool_running, fed by 500ms-throttled `'usage'` WS frames (`completion_tokens` + `ctx_used` + `ctx_max`). The `POST /api/chats/:id/discard_stale` endpoint exists to mark a stuck-streaming row as `failed` when the frontend's 60s no-token-activity timer (`ChatPane` content-length watcher) gives up.
- **Boot-time stale-streaming sweep** in `apps/server/src/index.ts` after `applySchema()`: any `messages.status='streaming'` older than 5 minutes flips to `'failed'`. Logs only on non-zero count. Recovers from container restart while inference was mid-stream (v1.12.1).
- **Periodic 60s sweeper** in `apps/server/src/index.ts` (v1.13.3 + v1.13.5). Same `setInterval` runs `sweepStaleStreaming` (marks `messages.status='streaming'` older than 5 min as `failed`, publishes `chat_status='idle'` so the UI dot drops) and `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `app.addHook('onClose')` clears the timer. No-op when nothing to reap.
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart. v1.13.11: every WS publish goes through `broker.publishFrame(sessionId, frame)` or `broker.publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). `ctx.publish` / `ctx.publishUser` in inference + auto_name route through the index.ts adapter that calls publishFrame internally. The schema is duplicated byte-identical at `apps/web/src/api/ws-frames.ts`; a `ws-frames.test.ts` case enforces parity. Don't add new raw `broker.publish()` / `publishUser()` calls.
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false. v1.13.5 truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs at `BOOCODE_TRUNCATION_DIR` (default `/tmp/boocode-truncations`, 0o700) keyed by an opaque `tr_<12 base32 chars>` id, and the `view_truncated_output(id)` tool retrieves it. 5MB cap (matches `view_file`'s `MAX_FILE_BYTES`), 7-day TTL, reaped by the periodic sweeper. Tmpfs path means container restart loses retrieval — acceptable, the model usually has moved on.
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)` (v1.13.9 opencode-pattern early trigger; was `ctx_max - 20k` pre-v1.13.9, which gave only 7.6% headroom at 262k and 0 budget for ≤20k contexts). **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). First inferences after a boocode boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model yet; negative cache TTL is 60s, recovers on next turn. v1.13.6: `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on the assistant `content` (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). `buildHeadPayload` + `OpenAiMessage` exported for test access — keep them exported.
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string-returning shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. v1.13.8 instrumentation: SHA-256 of the assembled prefix is logged per `buildMessagesPayload` call (msg `prefix-fingerprint`, level=info); a `Map<sessionId, lastHash>` observer fires `prefix-drift` (level=warn) on hash change with a field-level `changed_inputs` diff. Smoke proved the prefix is byte-stable across turns in steady-state — the originally-planned `system_prompt_cache` DB table was dropped as redundant against the v1.12.0 input-layer mtime caches (BOOCHAT.md here + AGENTS.md global+per-project in `agents.ts:safeStat`).
- **`services/inference/budget.ts`** — tool-call budgets: `BUDGET_READ_ONLY = 30`, `BUDGET_NON_READ_ONLY = 10` (forward-looking; no write tools yet), `BUDGET_NO_AGENT = 30` (v1.13.7; was 15 — every tool in `ALL_TOOLS` is read-only today, so no-agent mode shares the read-only-agent cap). Per-agent `max_tool_calls` from AGENTS.md frontmatter overrides.
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
### BooCoder (`apps/coder/src/`)
- Write-capable coding agent. Separate Fastify server at port 9502, same docker network (`boocode_net`).
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST (Dockerfile builds server → coder).
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to `http://boocoder:3000/api/*`. WS connects directly to `:9502`.
### Frontend (`apps/web/src/`)
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
@@ -86,26 +109,32 @@ Font / CSS pipeline (apps/web):
### Multi-pane workspace
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage key `boocode.workspace.panes.<sessionId>`); the legacy `session_panes` table and its REST endpoints are deprecated — no `/api/panes/*` routes exist. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). v1.12.1 moved pane state from per-device localStorage to `sessions.workspace_panes jsonb` for cross-device sync. `PATCH /api/sessions/:id/workspace` persists; `session_workspace_updated` user-channel frame broadcasts to every device watching the session. `useWorkspacePanes` debounces saves 300ms and dedups echoes by JSON string. Legacy localStorage key `boocode.workspace.panes.<sessionId>` is read once on first hydrate (one-time seed-and-delete migration when server is empty but localStorage has data); no longer written. The deprecated `session_panes` table was dropped. `validatePanes(validChatIds)` prunes panes referencing chat IDs that no longer exist (called by `useSessionChats` after the chat list fetch lands). Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Tab reorder via native HTML5 drag events.
## Database
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `session_panes` (deprecated). Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`.
PostgreSQL 16. Database name: `boochat` (renamed from `boocode` in v2.0.0-alpha; Docker service name stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0), `pending_changes` (v2.0.0), `tasks` (v2.0.0), `available_agents` (v2.0.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window), `human_inbox` (v2.0.0 — tasks WHERE state IN blocked/failed). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
Position-shift pattern for panes (legacy `session_panes` table): negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`.
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailscale IP binding as BooChat. Health reports tool count: `{"ok":true,"db":true,"tools":30}`.
## Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention.
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 36 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
@@ -115,6 +144,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
## Conventions
@@ -124,9 +155,18 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- TypeScript strict mode. Both apps share `tsconfig.base.json`.
- Server uses NodeNext module resolution (`.js` extensions in imports).
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **Docker build order for workspace deps**: the Dockerfile must `COPY` + `RUN pnpm build` the provider app BEFORE the consumer app. `apps/coder/Dockerfile` builds `apps/server` first, then `apps/coder`.

35
apps/coder/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS builder
RUN corepack enable
WORKDIR /build
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY apps/server/package.json ./apps/server/
COPY apps/coder/package.json ./apps/coder/
COPY apps/coder/web/package.json ./apps/coder/web/
RUN pnpm install --frozen-lockfile
# Build server first (coder depends on it via workspace dep for types + inference)
COPY apps/server ./apps/server
RUN pnpm -C apps/server build
COPY apps/coder ./apps/coder
RUN pnpm -C apps/coder/web build
RUN pnpm -C apps/coder build
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
FROM node:20-bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /out/coder ./
COPY --from=builder /build/apps/coder/web/dist ./web
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

28
apps/coder/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@boocode/coder",
"version": "2.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@boocode/server": "workspace:*",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1",
"fastify": "^4.28.1",
"postgres": "^3.4.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.14.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0",
"vitest": "^3.0.0"
}
}

42
apps/coder/src/config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
// BooCoder's config is a superset of the server's Config type so it can be
// passed directly into the inference runner's InferenceContext. Fields the
// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest
// default to values that satisfy the server's Zod schema without BooCoder
// needing to supply them in its environment.
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
LLAMA_SWAP_URL: z.string().url(),
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
LOG_LEVEL: z.string().default('info'),
CONTAINER_GUIDANCE_FILE: z.string().optional(),
// Fields needed to satisfy the server's Config type but unused by BooCoder:
SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'),
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
GITEA_USER: z.string().default('indifferentketchup'),
GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
MCP_CONFIG_PATH: z.string().optional(),
});
export type Config = z.infer<typeof ConfigSchema>;
let cached: Config | null = null;
export function loadConfig(): Config {
if (cached) return cached;
const parsed = ConfigSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
cached = parsed.data;
return cached;
}

45
apps/coder/src/db.ts Normal file
View File

@@ -0,0 +1,45 @@
import postgres from 'postgres';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import type { Config } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export type Sql = ReturnType<typeof postgres>;
let sqlInstance: Sql | null = null;
export function getSql(config: Config): Sql {
if (sqlInstance) return sqlInstance;
sqlInstance = postgres(config.DATABASE_URL, {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
onnotice: () => {},
});
return sqlInstance;
}
export async function applySchema(sql: Sql): Promise<void> {
const schemaPath = resolve(__dirname, 'schema.sql');
const ddl = await readFile(schemaPath, 'utf8');
await sql.unsafe(ddl);
}
export async function pingDb(sql: Sql): Promise<boolean> {
try {
await sql`SELECT 1`;
return true;
} catch {
return false;
}
}
export async function closeDb(): Promise<void> {
if (sqlInstance) {
await sqlInstance.end({ timeout: 5 });
sqlInstance = null;
}
}

173
apps/coder/src/index.ts Normal file
View File

@@ -0,0 +1,173 @@
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.js';
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
// inference loop, broker, and tool registry without duplication.
import { createInferenceRunner } from '@boocode/server/inference';
import { createBroker } from '@boocode/server/broker';
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
import type { Config as ServerConfig } from '@boocode/server/config';
import type { WsFrame } from '@boocode/server/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';
// Routes
import { registerMessageRoutes } from './routes/messages.js';
import { registerPendingRoutes } from './routes/pending.js';
import { registerTaskRoutes } from './routes/tasks.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
import { probeAgents } from './services/agent-probe.js';
async function main() {
const config = loadConfig();
const app = Fastify({
logger: { level: config.LOG_LEVEL },
});
// Allow empty JSON bodies (same pattern as apps/server).
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) {
done(null, {});
return;
}
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
const sql = getSql(config);
await applySchema(sql);
app.log.info('database schema applied');
// Broker: in-memory pub/sub for session + user channel streaming.
const broker = createBroker(app.log);
// --- Tool registry extension ---
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
appendMcpTools(adaptedWriteTools);
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
// the appended write tools) for tool dispatch.
const inference = createInferenceRunner(
{
sql,
config: config as unknown as ServerConfig,
log: app.log,
publish: (sessionId, frame) => {
broker.publishFrame(sessionId, frame as unknown as WsFrame);
},
broker,
},
(user, frame) => {
broker.publishUserFrame(user, frame as unknown as WsFrame);
}
);
// 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.
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);
},
cancel: async (sessionId: string, chatId: string) => {
const result = await inference.cancel(sessionId, chatId);
clearInferenceContext();
return result;
},
hasActive: (chatId: string) => inference.hasActive(chatId),
};
// Register WebSocket support
await app.register(fastifyWebsocket);
// Health endpoint
app.get('/api/health', async (_req, reply) => {
const dbOk = await pingDb(sql);
const status = dbOk ? 200 : 503;
return reply.status(status).send({
ok: dbOk,
db: dbOk,
tools: ALL_TOOLS.length,
});
});
// Phase 4: probe available agents on startup
await probeAgents(sql, app.log);
// Phase 4: dispatcher — polls tasks table and runs inference
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
dispatcher.start();
app.addHook('onClose', () => dispatcher.stop());
// Register routes
registerMessageRoutes(app, sql, broker, inferenceApi);
registerPendingRoutes(app, sql);
registerTaskRoutes(app, sql, inferenceApi);
registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is
// copied to ../web relative to the dist/ directory at /app/web. In dev,
// check adjacent to the source.
const webRoot = resolve(__dirname, '../web');
if (existsSync(webRoot)) {
await app.register(fastifyStatic, {
root: webRoot,
prefix: '/',
// Don't intercept /api routes — static only serves files that exist.
wildcard: false,
});
// SPA fallback: serve index.html for non-API routes that don't match a file.
app.setNotFoundHandler(async (req, reply) => {
if (req.url.startsWith('/api')) {
reply.code(404);
return { error: 'not found' };
}
return reply.sendFile('index.html');
});
app.log.info(`serving frontend from ${webRoot}`);
}
// Graceful shutdown
const shutdown = async () => {
app.log.info('shutting down');
await app.close();
await closeDb();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
}
main().catch((err) => {
console.error('fatal:', err);
process.exit(1);
});

View File

@@ -0,0 +1,126 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
chat_id: z.string().uuid(),
});
interface InferenceApi {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean;
}
export function registerMessageRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker,
inference: InferenceApi,
): void {
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/messages',
async (req, reply) => {
const parsed = SendBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sessionId = req.params.sessionId;
const { content, chat_id: chatId } = parsed.data;
// Validate session exists
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
// Validate chat belongs to session and is open
const chatRows = await sql<{ id: string; session_id: string }[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
// Reject if inference is already running on this chat
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
// Create user message + streaming assistant row in a transaction
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
// Publish user message frames so WS subscribers see it immediately
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: result.user_message_id,
chat_id: chatId,
role: 'user',
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: result.user_message_id,
chat_id: chatId,
content,
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: result.user_message_id,
chat_id: chatId,
} as unknown as WsFrame);
// Enqueue inference — the runner will stream assistant deltas via broker
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
// POST /api/sessions/:sessionId/stop — cancel active inference
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/stop',
async (req, reply) => {
const sessionId = req.params.sessionId;
// Find active chats in this session
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open'
`;
let cancelled = false;
for (const chat of chats) {
if (inference.hasActive(chat.id)) {
cancelled = await inference.cancel(sessionId, chat.id);
break;
}
}
return { cancelled };
},
);
}

View File

@@ -0,0 +1,121 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import {
listPending,
applyOne,
applyAll,
rejectOne,
rewindOne,
} from '../services/pending_changes.js';
/**
* Resolve project root from a session's project path.
*/
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
const rows = await sql<{ path: string }[]>`
SELECT p.path FROM sessions s
JOIN projects p ON s.project_id = p.id
WHERE s.id = ${sessionId}
`;
return rows.length > 0 ? rows[0]!.path : null;
}
/**
* Resolve project root from a pending change's session.
*/
async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise<string | null> {
const rows = await sql<{ path: string }[]>`
SELECT p.path FROM pending_changes pc
JOIN sessions s ON pc.session_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE pc.id = ${changeId}
`;
return rows.length > 0 ? rows[0]!.path : null;
}
export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/sessions/:sessionId/pending — list pending changes for a session
app.get<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending',
async (req, reply) => {
const sessionId = req.params.sessionId;
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const pending = await listPending(sql, sessionId);
return pending;
},
);
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/apply',
async (req, reply) => {
const sessionId = req.params.sessionId;
const projectRoot = await resolveProjectRoot(sql, sessionId);
if (!projectRoot) {
reply.code(404);
return { error: 'session or project not found' };
}
const results = await applyAll(sql, sessionId, projectRoot);
return { results };
},
);
// POST /api/pending/:id/apply — apply a single pending change
app.post<{ Params: { id: string } }>(
'/api/pending/:id/apply',
async (req, reply) => {
const changeId = req.params.id;
const projectRoot = await resolveProjectRootForChange(sql, changeId);
if (!projectRoot) {
reply.code(404);
return { error: 'pending change or project not found' };
}
const result = await applyOne(sql, changeId, projectRoot);
if (!result.success) {
reply.code(422);
}
return result;
},
);
// POST /api/pending/:id/reject — reject a single pending change
app.post<{ Params: { id: string } }>(
'/api/pending/:id/reject',
async (req, reply) => {
const changeId = req.params.id;
await rejectOne(sql, changeId);
return { ok: true };
},
);
// POST /api/pending/:id/rewind — rewind (undo) an applied change
app.post<{ Params: { id: string } }>(
'/api/pending/:id/rewind',
async (req, reply) => {
const changeId = req.params.id;
const projectRoot = await resolveProjectRootForChange(sql, changeId);
if (!projectRoot) {
reply.code(404);
return { error: 'pending change or project not found' };
}
const result = await rewindOne(sql, changeId, projectRoot);
if (!result.success) {
reply.code(422);
}
return result;
},
);
}

View File

@@ -0,0 +1,138 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
interface InferenceApi {
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
}
const CreateBody = z.object({
project_id: z.string().uuid(),
input: z.string().min(1).max(64_000),
agent: z.string().max(100).optional(),
model: z.string().max(200).optional(),
});
const ListQuery = z.object({
state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(),
project_id: z.string().uuid().optional(),
});
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
// POST /api/tasks — create a new task
app.post('/api/tasks', async (req, reply) => {
const parsed = CreateBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { project_id, input, agent, model } = parsed.data;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model)
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null})
RETURNING id, state
`;
reply.code(201);
return { id: task!.id, state: task!.state };
});
// GET /api/tasks — list tasks with optional filters
app.get('/api/tasks', async (req, _reply) => {
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) {
return { error: 'invalid query', details: parsed.error.flatten() };
}
const { state, project_id } = parsed.data;
// Build query with optional filters
if (state && project_id) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE state = ${state} AND project_id = ${project_id}
ORDER BY created_at DESC
LIMIT 100
`;
} else if (state) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE state = ${state}
ORDER BY created_at DESC
LIMIT 100
`;
} else if (project_id) {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
WHERE project_id = ${project_id}
ORDER BY created_at DESC
LIMIT 100
`;
} else {
return sql`
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
FROM tasks
ORDER BY created_at DESC
LIMIT 100
`;
}
});
// GET /api/tasks/:id — single task detail
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
const rows = await sql`
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at
FROM tasks
WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
return rows[0];
});
// POST /api/tasks/:id/cancel — cancel a pending or running task
app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => {
const taskId = req.params.id;
// Get current task state + session info
const rows = await sql<{ id: string; state: string; session_id: string | null }[]>`
SELECT id, state, session_id FROM tasks WHERE id = ${taskId}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'task not found' };
}
const task = rows[0]!;
if (task.state !== 'pending' && task.state !== 'running') {
reply.code(409);
return { error: `cannot cancel task in state '${task.state}'` };
}
// If running, try to cancel inference
if (task.state === 'running' && task.session_id) {
// Find active chat in the task's session
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
`;
for (const chat of chats) {
await inference.cancel(task.session_id, chat.id);
}
}
await sql`
UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId} AND state IN ('pending', 'running')
`;
return { cancelled: true };
});
}

View File

@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
export function registerWebSocket(
app: FastifyInstance,
sql: Sql,
broker: Broker,
): void {
// Per-session streaming WebSocket. Clients connect here to receive live
// inference frames (deltas, tool_calls, tool_results, message_complete).
app.get<{ Params: { sessionId: string } }>(
'/api/ws/sessions/:sessionId',
{ websocket: true },
async (socket, req) => {
const sessionId = req.params.sessionId;
// Validate session exists
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
if (session.length === 0) {
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
socket.close(1008, 'session not found');
return;
}
// Send snapshot of existing messages so client can hydrate
const messages = await sql<Record<string, unknown>[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages_with_parts
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
`;
socket.send(JSON.stringify({ type: 'snapshot', messages }));
// Subscribe to broker for live frames
const unsubscribe = broker.subscribe(sessionId, (frame) => {
if (socket.readyState !== socket.OPEN) return;
try {
socket.send(JSON.stringify(frame));
} catch (err) {
app.log.warn({ err, sessionId }, 'ws send failed');
}
});
socket.on('close', () => unsubscribe());
socket.on('error', () => unsubscribe());
},
);
}

51
apps/coder/src/schema.sql Normal file
View File

@@ -0,0 +1,51 @@
-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
-- Applied on startup by apps/coder/src/db.ts:applySchema().
-- Lives in the same 'boochat' database as BooChat's tables.
CREATE TABLE IF NOT EXISTS pending_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL,
task_id UUID,
file_path TEXT NOT NULL,
operation TEXT NOT NULL,
diff TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
);
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL,
parent_task_id UUID REFERENCES tasks(id),
state TEXT NOT NULL DEFAULT 'pending',
input TEXT NOT NULL,
output_summary TEXT,
agent TEXT,
model TEXT,
execution_path TEXT,
worktree_path TEXT,
cost_tokens INTEGER,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty'))
);
CREATE TABLE IF NOT EXISTS available_agents (
name TEXT PRIMARY KEY,
install_path TEXT,
version TEXT,
supports_acp BOOLEAN NOT NULL DEFAULT false,
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
last_probed_at TIMESTAMPTZ
);
-- v2.0.0 Phase 4: link tasks to their inference sessions.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
-- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js';
const PROJECT_ROOT = '/opt/projects/my-app';
describe('resolveWritePath', () => {
it('resolves a relative path correctly', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts');
expect(result).toBe('/opt/projects/my-app/src/index.ts');
});
it('resolves nested relative path', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts');
expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts');
});
it('throws on ../ escape', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root');
});
it('throws on absolute path outside project root', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root');
});
it('allows absolute path inside project root', () => {
const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts');
expect(result).toBe('/opt/projects/my-app/src/new.ts');
});
it('denies .env files', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file');
});
it('denies .env.local', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError);
});
it('denies .env.production', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError);
});
it('denies *.pem files', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file');
});
it('denies *.key files', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError);
});
it('denies id_rsa', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError);
});
it('denies id_ed25519', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError);
});
it('denies credentials.json', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError);
});
it('passes a normal file inside project', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx');
expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx');
});
it('passes a non-existent nested file (no realpath)', () => {
// This is the key difference from BooChat's pathGuard: no realpath means
// files that don't exist yet still pass validation
const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts');
expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts');
});
it('throws on null/empty path', () => {
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError);
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required');
});
it('normalizes ../ within project root and still allows', () => {
const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts');
expect(result).toBe('/opt/projects/my-app/lib/utils.ts');
});
it('rejects path that looks inside root but normalizes outside', () => {
expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError);
});
});
describe('isSecretPath', () => {
it('detects .env', () => {
expect(isSecretPath('.env')).toBe(true);
});
it('detects nested .env', () => {
expect(isSecretPath('config/.env')).toBe(true);
});
it('detects *.pfx', () => {
expect(isSecretPath('certs/client.pfx')).toBe(true);
});
it('does not flag normal source files', () => {
expect(isSecretPath('src/index.ts')).toBe(false);
expect(isSecretPath('README.md')).toBe(false);
expect(isSecretPath('package.json')).toBe(false);
});
it('returns false for empty string', () => {
expect(isSecretPath('')).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
const execFileAsync = promisify(execFile);
const KNOWN_AGENTS: Array<{ name: string; supportsAcp: boolean }> = [
{ name: 'opencode', supportsAcp: true },
{ name: 'goose', supportsAcp: true },
{ name: 'claude', supportsAcp: false },
{ name: 'pi', supportsAcp: false },
];
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
log.info('agent-probe: scanning PATH for known agents');
for (const agent of KNOWN_AGENTS) {
try {
// Check if the agent binary is on PATH
const { stdout: whichOut } = await execFileAsync('which', [agent.name], { timeout: 5_000 });
const installPath = whichOut.trim();
if (!installPath) continue;
// Get version
let version: string | null = null;
try {
const { stdout: verOut } = await execFileAsync(agent.name, ['--version'], { timeout: 10_000 });
version = verOut.trim().slice(0, 100);
} catch {
// Some agents may not support --version — that's fine
}
// UPSERT into available_agents
await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at)
VALUES (${agent.name}, ${installPath}, ${version}, ${agent.supportsAcp}, clock_timestamp())
ON CONFLICT (name) DO UPDATE SET
install_path = EXCLUDED.install_path,
version = EXCLUDED.version,
supports_acp = EXCLUDED.supports_acp,
last_probed_at = EXCLUDED.last_probed_at
`;
log.info({ agent: agent.name, version, installPath }, 'agent-probe: found');
} catch {
// Agent not found on PATH — skip silently
}
}
log.info('agent-probe: scan complete');
}

View File

@@ -0,0 +1,191 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker';
import type { Config } from '../config.js';
interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean;
}
interface Deps {
sql: Sql;
inference: InferenceRunner;
broker: Broker;
log: FastifyBaseLogger;
config: Config;
}
const POLL_INTERVAL_MS = 5_000;
const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
const { sql, inference, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null;
let running = false;
let stopping = false;
let inflightPromise: Promise<void> | null = null;
async function poll(): Promise<void> {
if (running || stopping) return;
// Grab one pending task
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>`
SELECT id, project_id, input, agent, model
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
LIMIT 1
`;
if (rows.length === 0) return;
const task = rows[0]!;
running = true;
inflightPromise = runTask(task).finally(() => {
running = false;
inflightPromise = null;
});
}
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task');
try {
// Mark running
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = 'native'
WHERE id = ${taskId}
`;
// Create session + chat for this task
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;
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}`;
// Create user message + streaming assistant
await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
// Enqueue inference
inference.enqueue(sessionId, chatId, assistantId, 'default');
// Wait for inference to complete (poll message status)
const finalStatus = await waitForCompletion(assistantId);
if (stopping) {
// Graceful shutdown — mark cancelled
await sql`
UPDATE tasks
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId}
`;
return;
}
if (finalStatus === 'complete') {
// Grab assistant content for output_summary
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
`;
const summary = (msg?.content ?? '').slice(0, 500);
await sql`
UPDATE tasks
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}
WHERE id = ${taskId}
`;
log.info({ taskId }, 'dispatcher: task completed');
} else {
// failed or cancelled
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
`;
const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}
WHERE id = ${taskId}
`;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed');
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, err: errMsg }, 'dispatcher: task error');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {}); // best-effort
}
}
async function waitForCompletion(assistantId: string): Promise<string> {
// Poll until the assistant message is no longer streaming
for (;;) {
if (stopping) return 'cancelled';
const [row] = await sql<{ status: string }[]>`
SELECT status FROM messages WHERE id = ${assistantId}
`;
const status = row?.status ?? 'failed';
if (status !== 'streaming') return status;
await sleep(COMPLETION_POLL_MS);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return {
start() {
log.info('dispatcher: starting poll loop');
timer = setInterval(() => {
poll().catch((err) => {
log.error({ err }, 'dispatcher: poll error');
});
}, POLL_INTERVAL_MS);
},
async stop() {
stopping = true;
if (timer) {
clearInterval(timer);
timer = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;
}
log.info('dispatcher: stopped');
},
};
}

View File

@@ -0,0 +1,224 @@
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.js';
// --- Types -------------------------------------------------------------------
export interface PendingChange {
id: string;
session_id: string;
task_id: string | null;
file_path: string;
operation: 'create' | 'edit' | 'delete';
diff: string;
status: 'pending' | 'applied' | 'rejected' | 'reverted';
created_at: string;
}
export interface ApplyResult {
id: string;
file_path: string;
operation: string;
success: boolean;
error?: string;
}
// --- Queue functions ---------------------------------------------------------
export async function queueEdit(
sql: Sql,
sessionId: string,
taskId: string | null,
filePath: string,
oldString: string,
newString: string,
projectRoot: string,
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const diff = JSON.stringify({ old: oldString, new: newString });
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff})
RETURNING *
`;
return row!;
}
export async function queueCreate(
sql: Sql,
sessionId: string,
taskId: string | null,
filePath: string,
content: string,
projectRoot: string,
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content})
RETURNING *
`;
return row!;
}
export async function queueDelete(
sql: Sql,
sessionId: string,
taskId: string | null,
filePath: string,
projectRoot: string,
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '')
RETURNING *
`;
return row!;
}
// --- Apply functions ---------------------------------------------------------
export async function applyOne(
sql: Sql,
changeId: string,
projectRoot: string,
): Promise<ApplyResult> {
const [change] = await sql<PendingChange[]>`
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
`;
if (!change) {
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
}
try {
// 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');
if (!content.includes(oldStr)) {
throw new Error('old_string not found in file — file may have changed since the edit was queued');
}
const updated = content.replace(oldStr, newStr);
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
}
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 };
} 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 };
}
}
export async function applyAll(
sql: Sql,
sessionId: string,
projectRoot: string,
): Promise<ApplyResult[]> {
const pending = await sql<PendingChange[]>`
SELECT * FROM pending_changes
WHERE session_id = ${sessionId} AND status = 'pending'
ORDER BY created_at ASC
`;
const results: ApplyResult[] = [];
for (const change of pending) {
results.push(await applyOne(sql, change.id, projectRoot));
}
return results;
}
// --- Reject functions --------------------------------------------------------
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
}
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
}
// --- Rewind functions --------------------------------------------------------
export async function rewindOne(
sql: Sql,
changeId: string,
projectRoot: string,
): Promise<ApplyResult> {
const [change] = await sql<PendingChange[]>`
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
`;
if (!change) {
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
}
try {
resolveWritePath(projectRoot, change.file_path);
switch (change.operation) {
case 'create': {
// Reverse a create: delete the file
await unlink(change.file_path);
break;
}
case 'edit': {
// Reverse an edit: swap old and new
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const content = await readFile(change.file_path, 'utf8');
if (!content.includes(newStr)) {
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
}
const reverted = content.replace(newStr, oldStr);
await writeFile(change.file_path, reverted, 'utf8');
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');
break;
}
}
await sql`UPDATE pending_changes SET status = 'reverted' 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 };
}
}
// --- Query functions ---------------------------------------------------------
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
return sql<PendingChange[]>`
SELECT * FROM pending_changes
WHERE session_id = ${sessionId} AND status = 'pending'
ORDER BY created_at ASC
`;
}

View File

@@ -0,0 +1,30 @@
/**
* Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef
* interface (which takes `projectRoot, extraRoots?`).
*
* The adapter reads the module-level inference context at execute time, so the
* wrapping happens at boot (static) — no per-inference re-wrap needed.
*/
import type { ToolDef as ServerToolDef } from '@boocode/server/tools';
import type { ToolDef, ToolContext } from './types.js';
import { getInferenceContext } from './inference_context.js';
/**
* Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat
* ToolDef (execute takes projectRoot + optional extraRoots). The adapter
* builds the ToolContext from the module-level inference context at call time.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function adaptWriteTool(tool: ToolDef<any>): ServerToolDef<any> {
return {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
jsonSchema: tool.jsonSchema,
async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise<unknown> {
const ctx: ToolContext = getInferenceContext();
return tool.execute(input, projectRoot, ctx);
},
};
}

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { applyAll } from '../pending_changes.js';
const ApplyPendingInput = z.object({});
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
name: 'apply_pending',
description:
'Apply all pending changes for the current session to disk. ' +
'Each queued create/edit/delete is executed in order.',
inputSchema: ApplyPendingInput,
jsonSchema: {
type: 'function',
function: {
name: 'apply_pending',
description:
'Apply all pending changes for the current session to disk. ' +
'Each queued create/edit/delete is executed in order.',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
},
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
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;
return {
total: results.length,
succeeded,
failed,
results,
message:
results.length === 0
? 'No pending changes to apply.'
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
};
},
};

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueCreate } from '../pending_changes.js';
const CreateFileInput = z.object({
file_path: z.string().min(1),
content: z.string(),
});
type CreateFileInputT = z.infer<typeof CreateFileInput>;
export const createFileTool: ToolDef<CreateFileInputT> = {
name: 'create_file',
description:
'Queue creation of a new file with the given content. ' +
'The change is staged in pending_changes and must be applied explicitly.',
inputSchema: CreateFileInput,
jsonSchema: {
type: 'function',
function: {
name: 'create_file',
description:
'Queue creation of a new file with the given content. ' +
'The change is staged in pending_changes and must be applied explicitly.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' },
content: { type: 'string', description: 'Full content of the file to create' },
},
required: ['file_path', 'content'],
},
},
},
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
const change = await queueCreate(
context.sql,
context.sessionId,
context.taskId,
input.file_path,
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.`,
};
},
};

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueDelete } from '../pending_changes.js';
const DeleteFileInput = z.object({
file_path: z.string().min(1),
});
type DeleteFileInputT = z.infer<typeof DeleteFileInput>;
export const deleteFileTool: ToolDef<DeleteFileInputT> = {
name: 'delete_file',
description:
'Queue deletion of a file. ' +
'The change is staged in pending_changes and must be applied explicitly.',
inputSchema: DeleteFileInput,
jsonSchema: {
type: 'function',
function: {
name: 'delete_file',
description:
'Queue deletion of a file. ' +
'The change is staged in pending_changes and must be applied explicitly.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' },
},
required: ['file_path'],
},
},
},
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
const change = await queueDelete(
context.sql,
context.sessionId,
context.taskId,
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.`,
};
},
};

View File

@@ -0,0 +1,54 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueEdit } from '../pending_changes.js';
const EditFileInput = z.object({
file_path: z.string().min(1),
old_string: z.string().min(1),
new_string: z.string(),
});
type EditFileInputT = z.infer<typeof EditFileInput>;
export const editFileTool: ToolDef<EditFileInputT> = {
name: 'edit_file',
description:
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
'The change is staged in pending_changes and must be applied explicitly.',
inputSchema: EditFileInput,
jsonSchema: {
type: 'function',
function: {
name: 'edit_file',
description:
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
'The change is staged in pending_changes and must be applied explicitly.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' },
old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' },
new_string: { type: 'string', description: 'The replacement string' },
},
required: ['file_path', 'old_string', 'new_string'],
},
},
},
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
const change = await queueEdit(
context.sql,
context.sessionId,
context.taskId,
input.file_path,
input.old_string,
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.`,
};
},
};

View File

@@ -0,0 +1,26 @@
import type { ToolDef } from './types.js';
import { editFileTool } from './edit_file.js';
import { createFileTool } from './create_file.js';
import { deleteFileTool } from './delete_file.js';
import { applyPendingTool } from './apply_pending.js';
import { rewindTool } from './rewind.js';
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
// All BooCoder write tools. The inference loop (Phase 2B) will combine these
// with BooChat's read-only tools to form the full tool set available to agents.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const WRITE_TOOLS: readonly ToolDef<any>[] = [
applyPendingTool,
createFileTool,
deleteFileTool,
editFileTool,
rewindTool,
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
WRITE_TOOLS.map((t) => [t.name, t]),
);
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool };

View File

@@ -0,0 +1,36 @@
import type { Sql } from '../../db.js';
/**
* Module-level 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?)`).
*/
export interface InferenceContext {
sql: Sql;
sessionId: string;
taskId: string | null;
}
let current: InferenceContext | null = null;
export function setInferenceContext(ctx: InferenceContext): void {
current = ctx;
}
export function clearInferenceContext(): void {
current = null;
}
export function getInferenceContext(): InferenceContext {
if (!current) {
throw new Error(
'Write tool called outside inference context — setInferenceContext() was not called before this run',
);
}
return current;
}

View File

@@ -0,0 +1,71 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { rewindOne } from '../pending_changes.js';
const RewindInput = z.object({
change_id: z.string().uuid().optional(),
all: z.boolean().optional(),
});
type RewindInputT = z.infer<typeof RewindInput>;
export const rewindTool: ToolDef<RewindInputT> = {
name: 'rewind',
description:
'Revert applied changes. Provide change_id to revert a specific change, ' +
'or set all=true to revert all applied changes for the session (in reverse order).',
inputSchema: RewindInput,
jsonSchema: {
type: 'function',
function: {
name: 'rewind',
description:
'Revert applied changes. Provide change_id to revert a specific change, ' +
'or set all=true to revert all applied changes for the session (in reverse order).',
parameters: {
type: 'object',
properties: {
change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' },
all: { type: 'boolean', description: 'If true, revert all applied changes for this session' },
},
required: [],
},
},
},
async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (input.change_id) {
const result = await rewindOne(context.sql, input.change_id, projectRoot);
return {
results: [result],
message: result.success
? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).`
: `Failed to revert: ${result.error}`,
};
}
if (input.all) {
// Rewind all applied changes for this session in reverse order
const applied = await context.sql<{ id: string }[]>`
SELECT id FROM pending_changes
WHERE session_id = ${context.sessionId} AND status = 'applied'
ORDER BY created_at DESC
`;
const results = [];
for (const row of applied) {
results.push(await rewindOne(context.sql, row.id, projectRoot));
}
const succeeded = results.filter((r) => r.success).length;
return {
total: results.length,
succeeded,
failed: results.length - succeeded,
results,
message:
results.length === 0
? 'No applied changes to revert.'
: `Reverted ${succeeded}/${results.length} changes.`,
};
}
return { error: 'Provide either change_id or all=true.' };
},
};

View File

@@ -0,0 +1,32 @@
import type { z } from 'zod';
import type { Sql } from '../../db.js';
export interface ToolJsonSchema {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/**
* Context passed to BooCoder tool execute functions.
*
* Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools
* interact with the database (pending_changes table) and need session/task
* context for proper attribution.
*/
export interface ToolContext {
sql: Sql;
sessionId: string;
taskId: string | null;
}
export interface ToolDef<TInput> {
name: string;
description: string;
inputSchema: z.ZodType<TInput>;
jsonSchema: ToolJsonSchema;
execute(input: TInput, projectRoot: string, context: ToolContext): Promise<unknown>;
}

View File

@@ -0,0 +1,73 @@
import { resolve, sep } from 'node:path';
export class WriteGuardError extends Error {
constructor(message: string) {
super(message);
this.name = 'WriteGuardError';
}
}
// Deny list: files that should never be written regardless of path-guard.
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
// Full parity with BooChat's deny list is not needed for write-guard because
// the write tools are intentional (model chose to create/edit); we block only
// files that are unambiguously secrets.
const SECRET_PATTERNS: readonly string[] = [
'.env',
'.env.local',
'.env.production',
'.env.development',
'.env.staging',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'*.pem',
'*.key',
'*.p12',
'*.pfx',
'*.crt',
'credentials.json',
'*.kdbx',
'.netrc',
];
export function isSecretPath(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/');
const segments = normalized.split('/').filter((s) => s.length > 0);
if (segments.length === 0) return false;
const basename = segments[segments.length - 1]!;
return SECRET_PATTERNS.some((pattern) => {
if (pattern.startsWith('*')) {
return basename.endsWith(pattern.slice(1));
}
return basename === pattern;
});
}
/**
* Resolve and validate a write target path.
*
* Key difference from BooChat's pathGuard: no realpath() — the file may not
* exist yet (creates). Uses resolve() to normalize ../ segments and then
* checks the result stays within projectRoot.
*/
export function resolveWritePath(projectRoot: string, filePath: string): string {
if (!filePath || filePath.length === 0) {
throw new WriteGuardError('file path is required');
}
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
const normalized = resolve(candidate); // normalizes ../ segments
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
throw new WriteGuardError(`path escapes project root: ${filePath}`);
}
if (isSecretPath(normalized)) {
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
}
return normalized;
}

15
apps/coder/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"],
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
include: ['src/**/__tests__/**/*.test.ts'],
},
});

12
apps/coder/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BooCoder</title>
</head>
<body class="bg-zinc-900 text-zinc-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{
"name": "@boocode/coder-web",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --noEmit",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.16.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.3.4"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View File

@@ -0,0 +1,13 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { Home } from './pages/Home';
import { Session } from './pages/Session';
export function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/sessions/:sessionId" element={<Session />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -0,0 +1,93 @@
import type { Project, Session, Chat, Message, PendingChange } from './types';
export class ApiError extends Error {
constructor(
public status: number,
public body: unknown,
) {
super(
typeof body === 'object' && body && 'error' in body
? String((body as { error: unknown }).error)
: `HTTP ${status}`,
);
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? JSON.parse(text) : undefined;
if (!res.ok) throw new ApiError(res.status, data);
return data as T;
}
export const api = {
health: () => request<{ ok: boolean; db: boolean; tools: number }>('/api/health'),
projects: {
list: (params?: { status?: 'open' | 'archived' }) =>
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
},
sessions: {
listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(
`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`,
),
get: (id: string) => request<Session>(`/api/sessions/${id}`),
},
chats: {
listForSession: (sessionId: string) =>
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/api/sessions/${sessionId}/chats`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
},
messages: {
send: (sessionId: string, chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages`,
{
method: 'POST',
body: JSON.stringify({ content, chat_id: chatId }),
},
),
stop: (sessionId: string) =>
request<{ cancelled: boolean }>(`/api/sessions/${sessionId}/stop`, {
method: 'POST',
}),
},
pending: {
list: (sessionId: string) =>
request<PendingChange[]>(`/api/sessions/${sessionId}/pending`),
applyAll: (sessionId: string) =>
request<{ results: Array<{ id: string; success: boolean; error?: string }> }>(
`/api/sessions/${sessionId}/pending/apply`,
{ method: 'POST' },
),
applyOne: (changeId: string) =>
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/apply`, {
method: 'POST',
}),
rejectOne: (changeId: string) =>
request<{ ok: boolean }>(`/api/pending/${changeId}/reject`, {
method: 'POST',
}),
rewindOne: (changeId: string) =>
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/rewind`, {
method: 'POST',
}),
},
};

View File

@@ -0,0 +1,89 @@
// Minimal types for the BooCoder frontend.
// Shared DB entities (same schema as BooChat).
export interface Project {
id: string;
name: string;
path: string;
status: 'open' | 'archived';
created_at: string;
updated_at: string;
}
export interface Session {
id: string;
project_id: string;
name: string | null;
model: string | null;
status: 'open' | 'archived';
created_at: string;
updated_at: string;
}
export interface Chat {
id: string;
session_id: string;
name: string | null;
status: 'open' | 'archived';
created_at: string;
updated_at: string;
}
export interface ToolCall {
id: string;
name: string;
arguments: string;
}
export interface ToolResult {
tool_call_id: string;
output: string;
truncated?: boolean;
error?: boolean;
}
export interface Message {
id: string;
session_id: string;
chat_id: string;
role: 'user' | 'assistant' | 'tool' | 'system';
content: string;
kind: string;
tool_calls: ToolCall[] | null;
tool_results: ToolResult | null;
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
metadata: unknown;
}
export interface PendingChange {
id: string;
session_id: string;
task_id: string | null;
file_path: string;
operation: 'create' | 'edit' | 'delete';
old_string: string | null;
new_string: string | null;
content: string | null;
diff: string | null;
status: 'pending' | 'applied' | 'rejected' | 'reverted';
created_at: string;
applied_at: string | null;
}
// WebSocket frame types (subset of what the coder backend publishes)
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
| { type: 'delta'; message_id: string; chat_id: string; content: string }
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
| { type: 'error'; message_id?: string; error: string; reason?: string }
| { type: 'pending_change_added'; change: PendingChange }
| { type: 'pending_change_updated'; change: PendingChange };

View File

@@ -0,0 +1,131 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Square } from 'lucide-react';
import type { Message } from '@/api/types';
import { api } from '@/api/client';
import { MessageBubble } from './MessageBubble';
interface Props {
sessionId: string;
chatId: string;
messages: Message[];
isStreaming: boolean;
connected: boolean;
}
export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }: Props) {
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}, [input]);
const handleSend = async () => {
const content = input.trim();
if (!content || sending || isStreaming) return;
setInput('');
setSending(true);
try {
await api.messages.send(sessionId, chatId, content);
} catch (err) {
console.error('send failed:', err);
// Restore input on failure
setInput(content);
} finally {
setSending(false);
}
};
const handleStop = async () => {
try {
await api.messages.stop(sessionId);
} catch (err) {
console.error('stop failed:', err);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Filter out system messages for display (sentinels)
const visibleMessages = messages.filter((m) => m.role !== 'system');
return (
<div className="flex flex-col h-full">
{/* Connection indicator */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800 text-xs text-zinc-500">
<div
className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span>{connected ? 'Connected' : 'Disconnected'}</span>
{isStreaming && (
<span className="text-blue-400 ml-auto">Generating...</span>
)}
</div>
{/* Messages list */}
<div className="flex-1 overflow-y-auto px-4 py-4">
{visibleMessages.length === 0 && (
<div className="text-center text-zinc-500 mt-8">
<p className="text-lg font-medium">BooCoder</p>
<p className="text-sm mt-1">Send a message to start coding.</p>
</div>
)}
{visibleMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="border-t border-zinc-800 px-4 py-3">
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message BooCoder..."
rows={1}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
disabled={sending}
/>
{isStreaming ? (
<button
onClick={handleStop}
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 text-white transition-colors"
title="Stop generation"
>
<Square size={18} />
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim() || sending}
className="p-2 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
title="Send message"
>
<Send size={18} />
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,352 @@
import { useState, useEffect, useCallback } from 'react';
import { Check, X, RotateCcw, FileText, FilePlus, Trash2, RefreshCw } from 'lucide-react';
import type { PendingChange } from '@/api/types';
import { api } from '@/api/client';
interface Props {
sessionId: string;
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
}
export function DiffPane({ sessionId, onPendingChange }: Props) {
const [changes, setChanges] = useState<PendingChange[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const fetchPending = useCallback(async () => {
try {
const result = await api.pending.list(sessionId);
setChanges(result);
} catch (err) {
console.error('fetch pending failed:', err);
} finally {
setLoading(false);
}
}, [sessionId]);
// Initial load
useEffect(() => {
fetchPending();
}, [fetchPending]);
// Listen for WS pending change events
useEffect(() => {
const unsub = onPendingChange((change) => {
setChanges((prev) => {
const idx = prev.findIndex((c) => c.id === change.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = change;
return next;
}
return [...prev, change];
});
});
return unsub;
}, [onPendingChange]);
const pendingChanges = changes.filter((c) => c.status === 'pending');
const resolvedChanges = changes.filter((c) => c.status !== 'pending');
const handleApplyOne = async (id: string) => {
try {
await api.pending.applyOne(id);
setChanges((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'applied' as const } : c)),
);
} catch (err) {
console.error('apply failed:', err);
}
};
const handleRejectOne = async (id: string) => {
try {
await api.pending.rejectOne(id);
setChanges((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'rejected' as const } : c)),
);
} catch (err) {
console.error('reject failed:', err);
}
};
const handleRewindOne = async (id: string) => {
try {
await api.pending.rewindOne(id);
setChanges((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'reverted' as const } : c)),
);
} catch (err) {
console.error('rewind failed:', err);
}
};
const handleApplyAll = async () => {
try {
const result = await api.pending.applyAll(sessionId);
const appliedIds = new Set(
result.results.filter((r) => r.success).map((r) => r.id),
);
setChanges((prev) =>
prev.map((c) =>
appliedIds.has(c.id) ? { ...c, status: 'applied' as const } : c,
),
);
} catch (err) {
console.error('apply all failed:', err);
}
};
const handleRejectAll = async () => {
// Reject each pending change individually (no batch reject endpoint)
for (const c of pendingChanges) {
await handleRejectOne(c.id);
}
};
const OpIcon = ({ op }: { op: PendingChange['operation'] }) => {
switch (op) {
case 'create':
return <FilePlus size={14} className="text-green-400" />;
case 'edit':
return <FileText size={14} className="text-blue-400" />;
case 'delete':
return <Trash2 size={14} className="text-red-400" />;
}
};
const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
const colors: Record<PendingChange['status'], string> = {
pending: 'bg-yellow-500/20 text-yellow-400',
applied: 'bg-green-500/20 text-green-400',
rejected: 'bg-zinc-500/20 text-zinc-400',
reverted: 'bg-orange-500/20 text-orange-400',
};
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colors[status]}`}>
{status}
</span>
);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
<h2 className="text-sm font-medium text-zinc-300">
Pending Changes
{pendingChanges.length > 0 && (
<span className="ml-1.5 text-xs text-zinc-500">
({pendingChanges.length})
</span>
)}
</h2>
<div className="flex items-center gap-1">
<button
onClick={fetchPending}
className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200"
title="Refresh"
>
<RefreshCw size={14} />
</button>
{pendingChanges.length > 0 && (
<>
<button
onClick={handleApplyAll}
className="text-xs px-2 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white"
>
Apply All
</button>
<button
onClick={handleRejectAll}
className="text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300"
>
Reject All
</button>
</>
)}
</div>
</div>
{/* Changes list */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="text-center text-zinc-500 text-sm py-8">Loading...</div>
)}
{!loading && changes.length === 0 && (
<div className="text-center text-zinc-500 text-sm py-8">
No pending changes yet.
</div>
)}
{/* Pending changes first */}
{pendingChanges.map((change) => (
<ChangeItem
key={change.id}
change={change}
expanded={expandedId === change.id}
onToggle={() =>
setExpandedId((prev) => (prev === change.id ? null : change.id))
}
onApply={() => handleApplyOne(change.id)}
onReject={() => handleRejectOne(change.id)}
OpIcon={OpIcon}
StatusBadge={StatusBadge}
/>
))}
{/* Resolved changes */}
{resolvedChanges.length > 0 && pendingChanges.length > 0 && (
<div className="border-t border-zinc-800 my-1" />
)}
{resolvedChanges.map((change) => (
<ChangeItem
key={change.id}
change={change}
expanded={expandedId === change.id}
onToggle={() =>
setExpandedId((prev) => (prev === change.id ? null : change.id))
}
onRewind={
change.status === 'applied'
? () => handleRewindOne(change.id)
: undefined
}
OpIcon={OpIcon}
StatusBadge={StatusBadge}
/>
))}
</div>
</div>
);
}
interface ChangeItemProps {
change: PendingChange;
expanded: boolean;
onToggle: () => void;
onApply?: () => void;
onReject?: () => void;
onRewind?: () => void;
OpIcon: React.ComponentType<{ op: PendingChange['operation'] }>;
StatusBadge: React.ComponentType<{ status: PendingChange['status'] }>;
}
function ChangeItem({
change,
expanded,
onToggle,
onApply,
onReject,
onRewind,
OpIcon,
StatusBadge,
}: ChangeItemProps) {
const fileName = change.file_path.split('/').pop() || change.file_path;
const dirPath = change.file_path.split('/').slice(0, -1).join('/');
return (
<div className="border-b border-zinc-800/50">
<div
className="flex items-center gap-2 px-4 py-2 hover:bg-zinc-800/50 cursor-pointer"
onClick={onToggle}
>
<OpIcon op={change.operation} />
<div className="flex-1 min-w-0">
<span className="text-sm font-mono text-zinc-200 truncate block">
{fileName}
</span>
{dirPath && (
<span className="text-[11px] text-zinc-500 truncate block">
{dirPath}
</span>
)}
</div>
<StatusBadge status={change.status} />
{change.status === 'pending' && (
<div className="flex items-center gap-1 ml-1">
<button
onClick={(e) => {
e.stopPropagation();
onApply?.();
}}
className="p-1 rounded hover:bg-green-600/30 text-green-400"
title="Apply"
>
<Check size={14} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onReject?.();
}}
className="p-1 rounded hover:bg-red-600/30 text-red-400"
title="Reject"
>
<X size={14} />
</button>
</div>
)}
{change.status === 'applied' && onRewind && (
<button
onClick={(e) => {
e.stopPropagation();
onRewind();
}}
className="p-1 rounded hover:bg-orange-600/30 text-orange-400"
title="Rewind"
>
<RotateCcw size={14} />
</button>
)}
</div>
{expanded && (
<div className="px-4 pb-3">
{change.operation === 'edit' && (
<div className="space-y-2">
{change.old_string && (
<div className="rounded bg-red-950/30 border border-red-900/30 p-2">
<div className="text-[10px] text-red-400 mb-1 font-medium">
Remove
</div>
<pre className="text-xs text-red-200 whitespace-pre-wrap break-all font-mono">
{change.old_string}
</pre>
</div>
)}
{change.new_string && (
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
<div className="text-[10px] text-green-400 mb-1 font-medium">
Add
</div>
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono">
{change.new_string}
</pre>
</div>
)}
</div>
)}
{change.operation === 'create' && change.content && (
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
<div className="text-[10px] text-green-400 mb-1 font-medium">
New file
</div>
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono max-h-60 overflow-y-auto">
{change.content.length > 2000
? change.content.slice(0, 2000) + '\n... (truncated)'
: change.content}
</pre>
</div>
)}
{change.operation === 'delete' && (
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
This file will be deleted.
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
import { Code2, MessageSquare, GitPullRequest } from 'lucide-react';
interface Props {
chatPane: React.ReactNode;
diffPane: React.ReactNode;
}
export function Layout({ chatPane, diffPane }: Props) {
const [activeTab, setActiveTab] = useState<'chat' | 'diff'>('chat');
return (
<div className="flex flex-col h-screen bg-zinc-900">
{/* Top bar */}
<header className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/95">
<Code2 size={20} className="text-blue-400" />
<h1 className="text-sm font-semibold text-zinc-200">BooCoder</h1>
</header>
{/* Mobile tab bar (visible below lg breakpoint) */}
<div className="lg:hidden flex border-b border-zinc-800">
<button
onClick={() => setActiveTab('chat')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
activeTab === 'chat'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-zinc-500'
}`}
>
<MessageSquare size={14} />
Chat
</button>
<button
onClick={() => setActiveTab('diff')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
activeTab === 'diff'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-zinc-500'
}`}
>
<GitPullRequest size={14} />
Changes
</button>
</div>
{/* Desktop split layout */}
<div className="flex-1 hidden lg:flex overflow-hidden">
<div className="w-[60%] border-r border-zinc-800 overflow-hidden">
{chatPane}
</div>
<div className="w-[40%] overflow-hidden">
{diffPane}
</div>
</div>
{/* Mobile: show only the active tab */}
<div className="flex-1 lg:hidden overflow-hidden">
{activeTab === 'chat' ? chatPane : diffPane}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Message } from '@/api/types';
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
interface Props {
message: Message;
}
export function MessageBubble({ message }: Props) {
if (message.role === 'tool') {
return <ToolResultBubble message={message} />;
}
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const isFailed = message.status === 'failed';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div
className={`max-w-[85%] rounded-lg px-4 py-2.5 ${
isUser
? 'bg-blue-600 text-white'
: 'bg-zinc-800 text-zinc-100 border border-zinc-700'
}`}
>
{isFailed && (
<div className="flex items-center gap-1.5 text-red-400 text-xs mb-1">
<AlertCircle size={12} />
<span>Failed</span>
</div>
)}
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="mb-2 space-y-1">
{message.tool_calls.map((tc) => (
<div
key={tc.id}
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
>
<Wrench size={11} />
<span className="font-mono">{tc.name}</span>
<span className="text-zinc-500 truncate max-w-[200px]">
{truncateArgs(tc.arguments)}
</span>
</div>
))}
</div>
)}
{message.content.trim() && (
<div className="prose prose-invert prose-sm max-w-none [&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto [&_code]:text-zinc-300 [&_p]:my-1.5">
<Markdown remarkPlugins={[remarkGfm]}>{message.content}</Markdown>
</div>
)}
{isStreaming && !message.content.trim() && (
<div className="flex items-center gap-1.5 text-zinc-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-xs">Thinking...</span>
</div>
)}
{isStreaming && message.content.trim() && (
<span className="inline-block w-1.5 h-4 bg-zinc-400 animate-pulse ml-0.5 align-middle" />
)}
</div>
</div>
);
}
function ToolResultBubble({ message }: Props) {
const result = message.tool_results;
if (!result) return null;
const isError = result.error;
const output = result.output || '';
const displayOutput =
output.length > 300 ? output.slice(0, 300) + '...' : output;
return (
<div className="flex justify-start mb-2 ml-6">
<div
className={`max-w-[80%] rounded px-3 py-2 text-xs font-mono border ${
isError
? 'bg-red-950/30 border-red-800/50 text-red-300'
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400'
}`}
>
{result.truncated && (
<span className="text-yellow-500 text-[10px] block mb-1">
[truncated]
</span>
)}
<pre className="whitespace-pre-wrap break-all">{displayOutput}</pre>
</div>
</div>
);
}
function truncateArgs(args: string): string {
if (!args) return '';
try {
const parsed = JSON.parse(args);
const keys = Object.keys(parsed);
if (keys.length === 0) return '';
const first = keys[0]!;
const val = String(parsed[first]);
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
return `${first}: ${display}`;
} catch {
return args.length > 50 ? args.slice(0, 50) + '...' : args;
}
}

View File

@@ -0,0 +1,22 @@
@import "tailwindcss";
body {
margin: 0;
min-height: 100vh;
}
/* Scrollbar styling for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}

View File

@@ -0,0 +1,230 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type { Message, WsFrame, PendingChange } from '@/api/types';
interface State {
messages: Message[];
connected: boolean;
error: string | null;
}
function applyFrame(state: State, frame: WsFrame): State {
switch (frame.type) {
case 'snapshot': {
return { ...state, messages: frame.messages };
}
case 'message_started': {
const exists = state.messages.some((m) => m.id === frame.message_id);
if (exists) return state;
const newMsg: Message = {
id: frame.message_id,
session_id: '',
chat_id: frame.chat_id,
role: frame.role,
content: '',
kind: 'message',
tool_calls: null,
tool_results: null,
status: frame.role === 'system' ? 'complete' : 'streaming',
tokens_used: null,
ctx_used: null,
ctx_max: null,
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
metadata: null,
};
return { ...state, messages: [...state.messages, newMsg] };
}
case 'delta': {
const next = state.messages.map((m) =>
m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m,
);
return { ...state, messages: next };
}
case 'tool_call': {
const next = state.messages.map((m) =>
m.id === frame.message_id
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
: m,
);
return { ...state, messages: next };
}
case 'tool_result': {
const exists = state.messages.some((m) => m.id === frame.tool_message_id);
if (exists) {
const next = state.messages.map((m) =>
m.id === frame.tool_message_id
? {
...m,
role: 'tool' as const,
tool_results: {
tool_call_id: frame.tool_call_id,
output: frame.output,
truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
},
status: 'complete' as const,
}
: m,
);
return { ...state, messages: next };
}
const newMsg: Message = {
id: frame.tool_message_id,
session_id: '',
chat_id: frame.chat_id,
role: 'tool',
content: '',
kind: 'message',
tool_calls: null,
tool_results: {
tool_call_id: frame.tool_call_id,
output: frame.output,
truncated: frame.truncated,
...(frame.error ? { error: frame.error } : {}),
},
status: 'complete',
tokens_used: null,
ctx_used: null,
ctx_max: null,
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
metadata: null,
};
return { ...state, messages: [...state.messages, newMsg] };
}
case 'message_complete': {
const next = state.messages.map((m) =>
m.id === frame.message_id
? {
...m,
status: 'complete' as const,
...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
}
: m,
);
return { ...state, messages: next };
}
case 'error': {
const next = frame.message_id
? state.messages.map((m) =>
m.id === frame.message_id ? { ...m, status: 'failed' as const } : m,
)
: state.messages;
return { ...state, messages: next, error: frame.error };
}
case 'pending_change_added':
case 'pending_change_updated':
// These are handled by the pending changes listener, not the message state
return state;
}
}
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
interface SessionStreamResult {
messages: Message[];
connected: boolean;
error: string | null;
isStreaming: boolean;
/** Listeners for pending change frames */
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
}
export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
useEffect(() => {
if (!sessionId) return;
setState({ messages: [], connected: false, error: null });
let unmounted = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
const connect = () => {
if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
setState((s) => ({ ...s, connected: true, error: null }));
};
ws.onmessage = (ev) => {
let frame: WsFrame;
try {
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
} catch {
return;
}
// Notify pending change listeners
if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
for (const cb of pendingListenersRef.current) {
cb(frame.change);
}
}
setState((s) => applyFrame(s, frame));
};
ws.onerror = () => {
try {
ws.close();
} catch {}
};
ws.onclose = () => {
if (unmounted) return;
setState((s) => ({ ...s, connected: false }));
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
};
};
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
const ws = wsRef.current;
wsRef.current = null;
if (ws)
try {
ws.close();
} catch {}
};
}, [sessionId]);
const isStreaming = state.messages.some((m) => m.status === 'streaming');
const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
pendingListenersRef.current.add(cb);
return () => {
pendingListenersRef.current.delete(cb);
};
}, []);
return {
messages: state.messages,
connected: state.connected,
error: state.error,
isStreaming,
onPendingChange,
};
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './globals.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Code2, Folder, ArrowRight } from 'lucide-react';
import type { Project, Session } from '@/api/types';
import { api } from '@/api/client';
export function Home() {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [sessions, setSessions] = useState<Session[]>([]);
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Fetch projects on mount
useEffect(() => {
api.projects
.list({ status: 'open' })
.then(setProjects)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// Fetch sessions when a project is selected
useEffect(() => {
if (!selectedProject) {
setSessions([]);
return;
}
api.sessions
.listForProject(selectedProject, 'open')
.then(setSessions)
.catch(console.error);
}, [selectedProject]);
const handleSessionClick = (session: Session) => {
navigate(`/sessions/${session.id}`);
};
if (loading) {
return (
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
<div className="text-zinc-500">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-zinc-900 p-6">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<Code2 size={28} className="text-blue-400" />
<h1 className="text-2xl font-bold text-zinc-100">BooCoder</h1>
</div>
{/* Projects list */}
<div className="mb-8">
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
Projects
</h2>
{projects.length === 0 ? (
<p className="text-zinc-500 text-sm">
No projects found. Create one in BooChat first.
</p>
) : (
<div className="space-y-1">
{projects.map((project) => (
<button
key={project.id}
onClick={() => setSelectedProject(project.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${
selectedProject === project.id
? 'bg-blue-600/20 border border-blue-500/40'
: 'bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800'
}`}
>
<Folder
size={16}
className={
selectedProject === project.id
? 'text-blue-400'
: 'text-zinc-500'
}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-200 truncate">
{project.name}
</div>
<div className="text-xs text-zinc-500 truncate">
{project.path}
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Sessions list */}
{selectedProject && (
<div>
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
Sessions
</h2>
{sessions.length === 0 ? (
<p className="text-zinc-500 text-sm">
No open sessions. Create one in BooChat first.
</p>
) : (
<div className="space-y-1">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => handleSessionClick(session)}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800 text-left transition-colors group"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-200 truncate">
{session.name || 'Untitled session'}
</div>
<div className="text-xs text-zinc-500">
{new Date(session.updated_at).toLocaleDateString()}
</div>
</div>
<ArrowRight
size={16}
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
/>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import type { Chat } from '@/api/types';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { ChatPane } from '@/components/ChatPane';
import { DiffPane } from '@/components/DiffPane';
import { Layout } from '@/components/Layout';
export function Session() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const [chat, setChat] = useState<Chat | null>(null);
const [loading, setLoading] = useState(true);
const { messages, connected, isStreaming, onPendingChange } =
useSessionStream(sessionId);
// Get or create a chat for this session
useEffect(() => {
if (!sessionId) return;
api.chats
.listForSession(sessionId)
.then((chats) => {
// Use the first open chat, or create one
const openChat = chats.find((c) => c.status === 'open');
if (openChat) {
setChat(openChat);
} else {
// Create a new chat
return api.chats.create(sessionId).then((newChat) => {
setChat(newChat);
});
}
})
.catch(console.error)
.finally(() => setLoading(false));
}, [sessionId]);
if (!sessionId) {
navigate('/');
return null;
}
if (loading) {
return (
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
<div className="text-zinc-500">Loading session...</div>
</div>
);
}
if (!chat) {
return (
<div className="min-h-screen bg-zinc-900 flex flex-col items-center justify-center gap-4">
<div className="text-zinc-500">Could not load chat for this session.</div>
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300"
>
<ArrowLeft size={14} />
Back to projects
</button>
</div>
);
}
return (
<Layout
chatPane={
<ChatPane
sessionId={sessionId}
chatId={chat.id}
messages={messages}
isStreaming={isStreaming}
connected={connected}
/>
}
diffPane={
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
}
/>
);
}

1
apps/coder/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"useDefineForClassFields": true,
"allowImportingTsExtensions": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

2
apps/coder/web/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

View File

@@ -4,6 +4,23 @@
"private": true,
"type": "module",
"main": "dist/index.js",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
@@ -11,8 +28,11 @@
"test": "vitest run"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.47",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"ai": "^6.0.190",
"fastify": "^4.28.1",
"postgres": "^3.4.4",
"ws": "^8.18.0",

View File

@@ -19,6 +19,9 @@ const ConfigSchema = z.object({
GITEA_USER: z.string().default('indifferentketchup'),
GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
MCP_CONFIG_PATH: z.string().optional(),
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -10,17 +10,24 @@ import { registerProjectRoutes } from './routes/projects.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerSettingsRoutes } from './routes/settings.js';
import { registerMessageRoutes } from './routes/messages.js';
import { registerArtifactRoutes } from './routes/artifacts.js';
import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
import { createInferenceRunner } from './services/inference.js';
import { registerToolsRoutes } from './routes/tools.js';
import { createInferenceRunner } from './services/inference/index.js';
import { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js';
import * as compaction from './services/compaction.js';
import { configureModelContext } from './services/model-context.js';
import { cleanupTruncations } from './services/truncate.js';
import { loadMcpConfig } from './services/mcp-config.js';
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
import { appendMcpTools } from './services/tools.js';
import { refreshToolNames } from './services/agents.js';
async function main() {
const config = loadConfig();
@@ -49,11 +56,40 @@ async function main() {
await applySchema(sql);
app.log.info('database schema applied');
const swept = await sql<{ count: string }[]>`
WITH swept AS (
UPDATE messages SET status = 'failed'
WHERE status = 'streaming' AND created_at < NOW() - INTERVAL '5 minutes'
RETURNING id
) SELECT count(*)::text AS count FROM swept
`;
const sweptCount = Number(swept[0]?.count ?? 0);
if (sweptCount > 0) {
app.log.info({ sweptCount }, 'swept stale streaming messages to failed');
}
// v1.11.3: tell the model-context cache where llama-swap lives. Cache
// lookups go to ${LLAMA_SWAP_URL}/upstream/<model>/props to read
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
// v1.15.0-mcp-multi: read MCP config file and connect to all enabled servers.
// Runs before route registration so the tool list is complete when the first
// inference request arrives. Per-server graceful degradation: one failing
// server doesn't block others.
const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json';
const mcpServers = loadMcpConfig(mcpConfigPath, app.log);
if (mcpServers.length > 0) {
await initMcp(mcpServers, app.log);
const mcpTools = getMcpTools();
if (mcpTools.length > 0) {
appendMcpTools(mcpTools);
refreshToolNames();
app.log.info({ servers: mcpServers.length, tools: mcpTools.length }, 'mcp: registered');
}
}
app.addHook('onClose', async () => { await shutdownMcp(); });
await app.register(fastifyWebsocket);
app.get('/api/health', async () => {
@@ -61,7 +97,7 @@ async function main() {
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
});
const broker = createBroker();
const broker = createBroker(app.log);
registerProjectRoutes(app, sql, config, broker);
registerSessionRoutes(app, sql, config, broker);
@@ -70,6 +106,7 @@ async function main() {
registerAgentRoutes(app, sql);
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
registerToolsRoutes(app, sql);
// 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.
@@ -86,7 +123,9 @@ async function main() {
config,
log: app.log,
publish: (sessionId, frame) => {
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
// v1.13.11-b: route through the typed publishFrame so the broker's
// Zod gate validates every inference frame before delivery.
broker.publishFrame(sessionId, frame as unknown as import('./types/ws-frames.js').WsFrame);
},
// v1.11: broker handle for compaction.process to publish 'compacted'
// frames on the per-session channel. Inference's regular publish path
@@ -95,10 +134,10 @@ async function main() {
broker,
},
(user, frame) => {
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
}
);
registerMessageRoutes(app, sql, {
registerMessageRoutes(app, sql, config, broker, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
@@ -114,64 +153,96 @@ async function main() {
},
hasActiveInference: (chatId) => inference.hasActive(chatId),
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: userMessageId,
chat_id: chatId,
role: 'user',
});
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'delta',
message_id: userMessageId,
chat_id: chatId,
content,
});
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: userMessageId,
chat_id: chatId,
});
},
publishMessagesDeleted: (sessionId, chatId, messageIds) => {
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'messages_deleted',
message_ids: messageIds,
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
},
});
registerArtifactRoutes(app, sql);
registerSkillsRoutes(app, sql, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: userMessageId,
chat_id: chatId,
role: 'user',
});
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'delta',
message_id: userMessageId,
chat_id: chatId,
content,
});
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: userMessageId,
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
},
});
registerWebSocket(app, sql, broker);
// v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the
// SPA's HTTP requests going through a single origin (avoids CORS). WS for
// the coder pane connects directly to boocoder:9502 from the browser (same
// Tailscale network — no CORS issue for WebSocket upgrade requests).
const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
app.all('/api/coder/*', async (req, reply) => {
const targetPath = req.url.replace('/api/coder', '/api');
const targetUrl = `${BOOCODER_ORIGIN}${targetPath}`;
const headers: Record<string, string> = {};
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
try {
const res = await fetch(targetUrl, {
method: req.method as string,
headers,
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
});
reply.code(res.status);
for (const [key, value] of res.headers) {
if (key === 'transfer-encoding') continue;
reply.header(key, value);
}
const body = await res.text();
return reply.send(body);
} catch (err) {
app.log.error({ err, targetUrl }, 'coder proxy error');
reply.code(502).send({ error: 'boocoder backend unavailable' });
}
});
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
if (existsSync(webDist)) {
await app.register(fastifyStatic, {
@@ -189,6 +260,52 @@ async function main() {
app.log.info(`serving static frontend from ${webDist}`);
}
// v1.13.3: periodic in-process sweeper for streaming rows orphaned by a
// mid-session crash. The boot sweep (above) only fires once at startup;
// this loop catches the in-flight case. 60s cadence + 5-min threshold
// matches the boot sweep so behavior is consistent. Publishes
// chat_status='idle' on the user channel so the UI dot drops without a
// refresh — same pattern as handleAbortOrError.
const SWEEP_INTERVAL_MS = 60_000;
const sweepStaleStreaming = async (): Promise<void> => {
try {
const rows = await sql<{ id: string; chat_id: string }[]>`
UPDATE messages
SET status = 'failed', finished_at = clock_timestamp()
WHERE status = 'streaming'
AND created_at < NOW() - INTERVAL '5 minutes'
RETURNING id, chat_id
`;
if (rows.length === 0) return;
app.log.warn(
{ swept: rows.length, ids: rows.map((r) => r.id) },
'swept stale streaming rows',
);
const seenChats = new Set<string>();
const now = new Date().toISOString();
for (const row of rows) {
if (seenChats.has(row.chat_id)) continue;
seenChats.add(row.chat_id);
broker.publishUserFrame('default', {
type: 'chat_status',
chat_id: row.chat_id,
status: 'idle',
at: now,
});
}
} catch (err) {
app.log.error({ err }, 'stuck-row sweeper failed');
}
};
// v1.13.5: truncation cleanup rides the same cadence — 60s tick reaps
// tmpfs files past the 7-day TTL plus any orphans whose owning part has
// been pruned (v1.13.4) or deleted. No-op when the dir is empty.
const sweepTimer = setInterval(() => {
void sweepStaleStreaming();
void cleanupTruncations({ sql, log: app.log });
}, SWEEP_INTERVAL_MS);
app.addHook('onClose', async () => { clearInterval(sweepTimer); });
const shutdown = async (signal: string) => {
app.log.info(`received ${signal}, shutting down`);
try {

View File

@@ -0,0 +1,70 @@
// v1.13.17-cross-repo-reads: PATCH /api/sessions/:id allowed_read_paths
// subset enforcement. Sam flagged in the compliance review that without a
// runtime subset check, a malicious client could POST
// {"allowed_read_paths":["/etc"]}
// and bypass the user-consent grant flow entirely. The findUnauthorizedAdditions
// helper is the guard; tests pin its behavior so a regression in the helper
// or its callsite (PATCH handler in sessions.ts) trips CI before prod.
import { describe, it, expect } from 'vitest';
import { findUnauthorizedAdditions } from '../sessions.js';
describe('findUnauthorizedAdditions — PATCH allowed_read_paths subset guard', () => {
it('returns no extras when requested is empty (full revoke)', () => {
expect(findUnauthorizedAdditions(['/opt/forks/foo'], [])).toEqual([]);
});
it('returns no extras when requested is a strict subset (single revoke)', () => {
expect(
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], ['/opt/forks/foo']),
).toEqual([]);
});
it('returns no extras when requested equals prior (no-op PATCH)', () => {
expect(
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
'/opt/forks/foo',
'/opt/forks/bar',
]),
).toEqual([]);
});
it('flags an unauthorized addition when prior is empty', () => {
// The /etc bypass attempt — Sam's specific concern from the compliance
// review. Without this guard, the PATCH would have written /etc directly.
expect(findUnauthorizedAdditions([], ['/etc'])).toEqual(['/etc']);
});
it('flags a single unauthorized addition mixed in with valid revokes', () => {
// The attacker still tries to be sneaky: keep one legit entry, drop
// another, slip in a new one. The guard catches the addition regardless
// of how the rest of the array shrinks.
expect(
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
'/opt/forks/foo',
'/var/secrets',
]),
).toEqual(['/var/secrets']);
});
it('flags every unauthorized addition when there are multiple', () => {
expect(
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/etc', '/root']),
).toEqual(['/etc', '/root']);
});
it('treats requested duplicates correctly (each occurrence checked)', () => {
// If the requested array has duplicates of an unauthorized entry, the
// guard surfaces each one. (A frontend would never send duplicates, but
// the guard's contract shouldn't assume that.)
expect(findUnauthorizedAdditions([], ['/etc', '/etc'])).toEqual(['/etc', '/etc']);
});
it('does not flag entries present in prior even if requested has duplicates', () => {
// Duplicate of an authorized entry passes — the membership check is by
// value, not by index. Settled by Set.has semantics.
expect(
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/opt/forks/foo']),
).toEqual([]);
});
});

View File

@@ -0,0 +1,231 @@
// v1.14.x-html-artifact-panes: artifact download routes.
//
// Two endpoints:
// POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html
// Materialises a file under <projectRoot>/.boocode/artifacts/ and
// returns {path, url}. fmt=html requires an existing html_artifact part
// on the message (404 otherwise). fmt=md works on any assistant
// message with non-empty content.
//
// GET /api/projects/:project_id/artifacts/:filename
// Streams a previously-written artifact back with
// Content-Disposition: attachment. Path-guarded to the project's
// artifacts dir; rejects traversal attempts.
import { createReadStream } from 'node:fs';
import { realpath, stat } from 'node:fs/promises';
import { resolve, sep, basename } from 'node:path';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
writeHtmlArtifact,
writeMarkdownArtifact,
type HtmlArtifactPayload,
} from '../services/artifacts.js';
const DownloadQuery = z.object({
fmt: z.enum(['md', 'html']),
});
// Filename safety: alnum, dash, dot, underscore only. Blocks `..`, slashes,
// nul bytes, etc. before we even touch the filesystem.
const FilenameRe = /^[A-Za-z0-9._-]+$/;
interface ChatRow {
id: string;
session_id: string;
project_id: string;
project_path: string;
}
interface MessageRow {
id: string;
chat_id: string;
role: string;
content: string;
}
export function registerArtifactRoutes(app: FastifyInstance, sql: Sql): void {
app.post<{
Params: { id: string; msg_id: string };
Querystring: { fmt?: string };
}>(
'/api/chats/:id/messages/:msg_id/artifacts/download',
async (req, reply) => {
const parsed = DownloadQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const { fmt } = parsed.data;
const { id: chatId, msg_id: messageId } = req.params;
const chatRows = await sql<ChatRow[]>`
SELECT c.id, c.session_id, s.project_id, p.path AS project_path
FROM chats c
JOIN sessions s ON s.id = c.session_id
JOIN projects p ON p.id = s.project_id
WHERE c.id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const msgRows = await sql<MessageRow[]>`
SELECT id, chat_id, role, content
FROM messages
WHERE id = ${messageId} AND chat_id = ${chatId}
`;
if (msgRows.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
const msg = msgRows[0]!;
if (msg.role !== 'assistant') {
reply.code(400);
return { error: 'only assistant messages produce artifacts' };
}
const ctx = { projectId: chat.project_id, projectRoot: chat.project_path };
try {
if (fmt === 'md') {
if (!msg.content || msg.content.trim().length === 0) {
reply.code(400);
return { error: 'message has no content to export' };
}
const result = await writeMarkdownArtifact(
{ content: msg.content },
ctx,
);
return result;
}
// fmt === 'html': require an html_artifact part on the message.
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
SELECT payload
FROM message_parts
WHERE message_id = ${messageId} AND kind = 'html_artifact'
ORDER BY sequence ASC
LIMIT 1
`;
if (partRows.length === 0) {
reply.code(404);
return { error: 'no html_artifact part on this message' };
}
const result = await writeHtmlArtifact(partRows[0]!.payload, ctx);
return result;
} catch (err) {
req.log.error({ err, messageId, fmt }, 'artifact write failed');
reply.code(500);
return {
error: err instanceof Error ? err.message : 'artifact write failed',
};
}
},
);
// v1.14.x-html-artifact-panes: HtmlArtifactPane needs the payload on click
// to render its iframe. Returns 404 when the message has no html_artifact
// sibling part — frontend uses that signal to open the markdown_artifact
// pane variant instead. Payload shape matches HtmlArtifactPayload in
// services/artifacts.ts.
app.get<{ Params: { id: string; msg_id: string } }>(
'/api/chats/:id/messages/:msg_id/html_artifact',
async (req, reply) => {
const { id: chatId, msg_id: messageId } = req.params;
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
SELECT payload
FROM message_parts mp
JOIN messages m ON m.id = mp.message_id
WHERE mp.message_id = ${messageId}
AND m.chat_id = ${chatId}
AND mp.kind = 'html_artifact'
ORDER BY mp.sequence ASC
LIMIT 1
`;
if (partRows.length === 0) {
reply.code(404);
return { error: 'no html_artifact part on this message' };
}
return partRows[0]!.payload;
},
);
app.get<{ Params: { project_id: string; filename: string } }>(
'/api/projects/:project_id/artifacts/:filename',
async (req, reply) => {
const { project_id: projectId, filename } = req.params;
// Strip directory components defensively; only the basename is allowed.
const base = basename(filename);
if (base !== filename || !FilenameRe.test(base)) {
reply.code(400);
return { error: 'invalid filename' };
}
const projectRows = await sql<{ id: string; path: string }[]>`
SELECT id, path FROM projects WHERE id = ${projectId}
`;
if (projectRows.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const project = projectRows[0]!;
let resolvedRoot: string;
try {
resolvedRoot = await realpath(project.path);
} catch {
reply.code(404);
return { error: 'project path missing' };
}
const artifactsDir = resolve(resolvedRoot, '.boocode/artifacts');
const absPath = resolve(artifactsDir, base);
if (!absPath.startsWith(artifactsDir + sep)) {
reply.code(400);
return { error: 'path traversal rejected' };
}
// Close the symlink-escape gap: if `.boocode/artifacts` (or an
// ancestor) is a symlink pointing outside resolvedRoot, the lexical
// prefix check above passes but the actual read lands outside the
// sandbox. Realpath the artifacts dir and re-verify.
try {
const realArtifactsDir = await realpath(artifactsDir);
if (
realArtifactsDir !== resolvedRoot &&
!realArtifactsDir.startsWith(resolvedRoot + sep)
) {
reply.code(400);
return { error: 'path traversal rejected' };
}
} catch {
reply.code(404);
return { error: 'artifact not found' };
}
try {
await stat(absPath);
} catch {
reply.code(404);
return { error: 'artifact not found' };
}
const ext = base.toLowerCase().endsWith('.html')
? 'text/html; charset=utf-8'
: base.toLowerCase().endsWith('.md')
? 'text/markdown; charset=utf-8'
: 'application/octet-stream';
reply.header('Content-Type', ext);
// Defense-in-depth on LLM-generated HTML served through this route.
// Authelia gates the proxy; these headers limit blast radius if a
// payload tries to escape that boundary in-browser.
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Content-Security-Policy', 'sandbox');
reply.header(
'Content-Disposition',
`attachment; filename="${base.replace(/"/g, '')}"`,
);
return reply.send(createReadStream(absPath));
},
);
}

View File

@@ -18,6 +18,12 @@ const ForkBody = z.object({
name: z.string().min(1).max(200).optional(),
});
const DiscardStaleBody = z.object({
message_id: z.string().uuid(),
});
const STALE_MIN_AGE_SECONDS = 60;
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
@@ -96,7 +102,7 @@ export function registerChatRoutes(
VALUES (${req.params.id}, ${parsed.data.name ?? null}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'chat_created',
chat: chat!,
session_id: req.params.id,
@@ -126,7 +132,7 @@ export function registerChatRoutes(
return { error: 'chat not found' };
}
const chat = rows[0]!;
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'chat_updated',
chat_id: chat.id,
session_id: chat.session_id,
@@ -156,7 +162,7 @@ export function registerChatRoutes(
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'chat_archived',
chat_id: id,
session_id: req.params.id,
@@ -197,7 +203,7 @@ export function registerChatRoutes(
return { error: 'chat not found or already archived' };
}
const row = rows[0]!;
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'chat_archived',
chat_id: row.id,
session_id: row.session_id,
@@ -220,7 +226,7 @@ export function registerChatRoutes(
return { error: 'chat not found or not archived' };
}
const chat = rows[0]!;
broker.publishUser('default', { type: 'chat_unarchived', chat });
broker.publishUserFrame('default', { type: 'chat_unarchived', chat });
return chat;
}
);
@@ -237,7 +243,7 @@ export function registerChatRoutes(
return { error: 'chat not found' };
}
const row = result[0]!;
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'chat_deleted',
chat_id: row.id,
session_id: row.session_id,
@@ -290,13 +296,13 @@ export function registerChatRoutes(
`;
await tx`
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
session_id, chat_id, role, content, kind,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at, metadata
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
tool_calls, tool_results, status,
status,
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
@@ -307,10 +313,32 @@ export function registerChatRoutes(
AND created_at <= ${target.created_at}::timestamptz
AND status = 'complete'
`;
// v1.13.0: clone message_parts for the forked messages. Source and
// destination preserve ordering (the INSERT above orders by created_at,
// id) so a ROW_NUMBER pairing maps source.id → dest.id deterministically.
await tx`
WITH src AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS rn
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
AND status = 'complete'
),
dst AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS rn
FROM messages
WHERE chat_id = ${chat!.id}
)
INSERT INTO message_parts (message_id, sequence, kind, payload)
SELECT dst.id, p.sequence, p.kind, p.payload
FROM message_parts p
JOIN src ON p.message_id = src.id
JOIN dst ON dst.rn = src.rn
`;
return chat!;
});
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'chat_created',
chat: newChat,
session_id: source.session_id,
@@ -320,6 +348,77 @@ export function registerChatRoutes(
}
);
// v1.12.3: explicit recovery from a stuck-streaming assistant row. The
// frontend gates this behind a 60s no-token-activity timer; the server
// re-checks the age and current status for safety. Non-streaming rows
// return 409 (frontend race; idempotent retry is fine).
app.post<{ Params: { id: string } }>(
'/api/chats/:id/discard_stale',
async (req, reply) => {
const parsed = DiscardStaleBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const rows = await sql<{
id: string;
session_id: string;
chat_id: string;
status: string;
age_seconds: number;
}[]>`
SELECT id, session_id, chat_id, status,
EXTRACT(EPOCH FROM (clock_timestamp() - created_at))::int AS age_seconds
FROM messages
WHERE id = ${parsed.data.message_id} AND chat_id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'message not found in chat' };
}
const msg = rows[0]!;
if (msg.status !== 'streaming') {
reply.code(409);
return { error: 'message is no longer streaming', current_status: msg.status };
}
if (msg.age_seconds < STALE_MIN_AGE_SECONDS) {
reply.code(409);
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
}
const updated = await sql<{ id: string }[]>`
UPDATE messages
SET status = 'failed',
content = COALESCE(content, ''),
finished_at = clock_timestamp()
WHERE id = ${msg.id} AND status = 'streaming'
RETURNING id
`;
if (updated.length === 0) {
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
reply.code(409);
return { error: 'message status changed mid-request' };
}
// v1.13.20: re-fetch via messages_with_parts so the returned shape
// carries parts-synthesized tool_calls / tool_results. The dropped
// legacy columns can no longer be selected directly.
const refreshed = await sql<Message[]>`
SELECT * FROM messages_with_parts WHERE id = ${msg.id}
`;
broker.publishUserFrame('default', {
type: 'chat_status',
chat_id: msg.chat_id,
status: 'idle',
at: new Date().toISOString(),
});
broker.publishFrame(msg.session_id, {
type: 'message_complete',
message_id: msg.id,
chat_id: msg.chat_id,
});
return refreshed[0];
}
);
app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages',
async (req, reply) => {
@@ -328,11 +427,12 @@ export function registerChatRoutes(
reply.code(404);
return { error: 'chat not found' };
}
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages
FROM messages_with_parts
WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
`;

View File

@@ -1,7 +1,13 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
// decision time (not at request time) so concurrent project changes don't
// stale-bind the resolution.
import { resolveGrantRoot } from '../services/grant_resolver.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
@@ -47,6 +53,21 @@ const AskUserInputArgs = z.object({
.max(3),
});
// v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the
// model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary.
const GrantReadAccessBody = z.object({
tool_call_id: z.string().min(1),
decision: z.enum(['allow', 'deny']),
});
// Same shape as services/request_read_access.ts RequestReadAccessInput.
// Re-derived to avoid the services/tools.ts import (matches the
// AskUserInputArgs pattern above).
const RequestReadAccessArgs = z.object({
path: z.string().min(1),
reason: z.string().min(1).max(500),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
// v1.11: returns a promise that resolves after compaction.process finishes
@@ -76,6 +97,8 @@ interface MessageHandlers {
export function registerMessageRoutes(
app: FastifyInstance,
sql: Sql,
config: Config,
broker: Broker,
handlers: MessageHandlers
): void {
app.get<{ Params: { id: string } }>(
@@ -91,11 +114,12 @@ export function registerMessageRoutes(
// SummaryCard) and shows compacted_at-stamped rows inline for context.
// Internal inference assembly filters compacted_at IS NULL separately —
// see services/inference.ts loadContext + services/compaction.ts.
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages
FROM messages_with_parts
WHERE session_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
`;
@@ -469,30 +493,36 @@ export function registerMessageRoutes(
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Find the assistant message that emitted this tool_call. Scoped by
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
// because the most recent issuance wins when an LLM reuses call IDs
// across turns (the older, already-answered one is a different row with
// populated tool_results downstream).
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
SELECT id, tool_calls FROM messages
WHERE chat_id = ${chat.id}
AND role = 'assistant'
AND tool_calls IS NOT NULL
ORDER BY created_at DESC
// v1.13.1-C: find the assistant's tool_call by indexing message_parts
// directly on payload->>'id'. Scoped by chat_id + role via the JOIN.
// Pre-v1.13.0 history has no parts rows — those tool_calls become
// unreachable here (404). Acceptable per the dispatch decision: any
// pending elicitation from before v1.13.0 is long timed out by now;
// promote to a hotfix with a JSON-column fallback if it ever surfaces.
const callerRows = await sql<{
message_id: string;
payload: { id: string; name: string; args: Record<string, unknown> };
}[]>`
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'assistant'
AND p.kind = 'tool_call'
AND p.payload->>'id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
let foundCall: ToolCall | null = null;
for (const row of callerRows) {
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
if (match) {
foundCall = match;
break;
}
}
if (!foundCall) {
const callerRow = callerRows[0];
if (!callerRow) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
const foundCall: ToolCall = {
id: callerRow.payload.id,
name: callerRow.payload.name,
args: callerRow.payload.args,
};
if (foundCall.name !== 'ask_user_input') {
reply.code(400);
return { error: 'tool_call_not_ask_user_input' };
@@ -539,18 +569,21 @@ export function registerMessageRoutes(
}
}
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
// the most recent row with this tool_call_id; the already-answered
// check below guards against UPDATE-ing a stale answer.
// v1.13.1-C: find the pending tool row via message_parts on
// payload->>'tool_call_id'. Same fallback caveat as the caller lookup
// above — pre-v1.13.0 rows are unreachable here.
const toolRows = await sql<{
id: string;
tool_results: { tool_call_id: string; output: unknown } | null;
message_id: string;
payload: { tool_call_id: string; output: unknown };
}[]>`
SELECT id, tool_results FROM messages
WHERE chat_id = ${chat.id}
AND role = 'tool'
AND tool_results->>'tool_call_id' = ${tool_call_id}
ORDER BY created_at DESC
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'tool'
AND p.kind = 'tool_result'
AND p.payload->>'tool_call_id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
const toolRow = toolRows[0];
@@ -558,7 +591,7 @@ export function registerMessageRoutes(
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
if (toolRow.payload && toolRow.payload.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
@@ -570,11 +603,17 @@ export function registerMessageRoutes(
truncated: false,
};
const toolMessageId = toolRow.message_id;
const result = await sql.begin(async (tx) => {
// v1.13.20: parts-only. Replace the pending tool_result part inserted
// at message creation (tool-phase.ts) with the answered one. Delete-
// then-insert is simpler than UPDATE because parts are append-style
// elsewhere; the UNIQUE (message_id, sequence) constraint blocks
// plain insert.
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolRow.id}
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
@@ -584,7 +623,7 @@ export function registerMessageRoutes(
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
tool_message_id: toolRow.id,
tool_message_id: toolMessageId,
assistant_message_id: assistantMsg!.id,
};
});
@@ -606,4 +645,230 @@ export function registerMessageRoutes(
return result;
},
);
// v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape
// of /answer_user_input (validate, look up via message_parts, UPDATE,
// publish, enqueue). Differences vs /answer_user_input:
// - On 'allow', re-resolves the grant root via grant_resolver (state
// may have changed since the prompt fired — concurrent project add,
// etc.). Resolution failure auto-falls to a denial with reason text
// rather than 500ing.
// - On 'allow' with a valid root, appends to sessions.allowed_read_paths
// (deduplicated) inside the same transaction.
// - On success, also publishes session_updated so an open SettingsPane
// refetches the new grant list.
// Error codes match /answer:
// 400 invalid_body / mismatched_answer_shape (bad args on the tool_call)
// 404 chat_not_found / unknown_tool_call_id
// 409 tool_call_already_answered
app.post<{ Params: { id: string } }>(
'/api/chats/:id/grant_read_access',
async (req, reply) => {
const parsed = GrantReadAccessBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid_body', details: parsed.error.flatten() };
}
const { tool_call_id, decision } = parsed.data;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat_not_found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Mirror the /answer lookup: assistant tool_call by id via message_parts.
const callerRows = await sql<{
message_id: string;
payload: { id: string; name: string; args: Record<string, unknown> };
}[]>`
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'assistant'
AND p.kind = 'tool_call'
AND p.payload->>'id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
const callerRow = callerRows[0];
if (!callerRow) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
const foundCall: ToolCall = {
id: callerRow.payload.id,
name: callerRow.payload.name,
args: callerRow.payload.args,
};
if (foundCall.name !== 'request_read_access') {
reply.code(400);
return { error: 'tool_call_not_request_read_access' };
}
const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
if (!argsParsed.success) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
}
const requestedPath = argsParsed.data.path;
// Find the pending tool row.
const toolRows = await sql<{
message_id: string;
payload: { tool_call_id: string; output: unknown };
}[]>`
SELECT p.message_id, p.payload
FROM message_parts p
JOIN messages m ON m.id = p.message_id
WHERE m.chat_id = ${chat.id}
AND m.role = 'tool'
AND p.kind = 'tool_result'
AND p.payload->>'tool_call_id' = ${tool_call_id}
ORDER BY m.created_at DESC
LIMIT 1
`;
const toolRow = toolRows[0];
if (!toolRow) {
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRow.payload && toolRow.payload.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
// Look up session + project so we can re-resolve the grant root and
// append to allowed_read_paths atomically. We don't need agent or
// history here — just the project path for the resolver.
const sessionRows = await sql<{
id: string;
project_id: string;
allowed_read_paths: string[];
project_path: string;
}[]>`
SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path
FROM sessions s
JOIN projects p ON p.id = s.project_id
WHERE s.id = ${sessionId}
`;
const sessionRow = sessionRows[0];
if (!sessionRow) {
reply.code(404);
return { error: 'session_not_found' };
}
// Decision branch. 'deny' is the easy path: nothing to resolve or
// persist. 'allow' resolves the grant root; if resolution fails (e.g.
// path was deleted, project removed since prompt) the tool gets a
// denial with the resolver's reason text instead of a 500.
let resultOutput: string;
let grantRoot: string | null = null;
if (decision === 'allow') {
const resolution = await resolveGrantRoot(
sql,
requestedPath,
sessionRow.project_path,
config.PROJECT_ROOT_WHITELIST,
);
if (!resolution.ok) {
resultOutput = `denied: ${resolution.reason}`;
} else {
grantRoot = resolution.root;
resultOutput = `granted: ${grantRoot}`;
}
} else {
resultOutput = 'denied';
}
const newToolResults = {
tool_call_id,
output: resultOutput,
truncated: false,
};
const toolMessageId = toolRow.message_id;
const dbResult = await sql.begin(async (tx) => {
// v1.13.20: parts-only. Same delete+insert dance as /answer —
// UNIQUE (message_id, sequence) blocks plain UPDATE on append-style
// parts.
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
`;
// Persist the grant if we have one. ARRAY-level dedup — append only
// when the root isn't already present. The session row gets
// touched (updated_at) so the post-update publish below has a
// fresh timestamp.
let allowedRootsAfter = sessionRow.allowed_read_paths;
if (grantRoot !== null) {
if (!sessionRow.allowed_read_paths.includes(grantRoot)) {
const updated = await tx<{ allowed_read_paths: string[] }[]>`
UPDATE sessions
SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}),
updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING allowed_read_paths
`;
allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths;
} else {
// Already present — touch updated_at so any open settings
// panel still picks up the no-op via session_updated.
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
}
}
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
tool_message_id: toolMessageId,
assistant_message_id: assistantMsg!.id,
allowed_roots_after: allowedRootsAfter,
};
});
// Publish the deferred tool_result frame so the pending card flips to
// its answered view without a refetch.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: dbResult.tool_message_id,
tool_call_id,
chat_id: chat.id,
output: resultOutput,
truncated: false,
});
// session_updated nudge so any open SettingsPane refetches and sees
// the new allowed_read_paths. We publish on the user channel to match
// the existing PATCH /api/sessions/:id behavior — frontend refetches
// via api.sessions.get on receipt.
const nowIso = new Date().toISOString();
broker.publishUserFrame('default', {
type: 'session_updated',
session_id: sessionId,
project_id: sessionRow.project_id,
// session name doesn't change on grant; we look it up fresh to
// avoid carrying stale state if a rename raced us.
name:
(
await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}`
)[0]?.name ?? '',
updated_at: nowIso,
});
handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default');
reply.code(202);
return {
tool_message_id: dbResult.tool_message_id,
assistant_message_id: dbResult.assistant_message_id,
allowed_read_paths: dbResult.allowed_roots_after,
};
},
);
}

View File

@@ -129,7 +129,7 @@ export function registerProjectRoutes(
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
broker.publishUserFrame('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201);
return {
project: row,
@@ -186,11 +186,11 @@ export function registerProjectRoutes(
`;
if (existing.length === 0) {
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
broker.publishUserFrame('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201);
} else {
// existing.status was 'archived' — row has been restored.
broker.publishUser('default', { type: 'project_unarchived', project: row as unknown as Project });
broker.publishUserFrame('default', { type: 'project_unarchived', project: row as unknown as Project });
reply.code(200);
}
return row;
@@ -243,7 +243,7 @@ export function registerProjectRoutes(
// v1.9: the project_updated frame still only carries id + name. Clients
// that need the new fields refetch via api.projects.list() — keeps the
// frame payload lean, per the locked recon decision (d).
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'project_updated',
project_id: project.id,
name: project.name,
@@ -260,7 +260,7 @@ export function registerProjectRoutes(
reply.code(404);
return { error: 'not found or already archived' };
}
broker.publishUser('default', { type: 'project_archived', project_id: req.params.id });
broker.publishUserFrame('default', { type: 'project_archived', project_id: req.params.id });
reply.code(204);
return null;
});
@@ -277,7 +277,7 @@ export function registerProjectRoutes(
return { error: 'not found or not archived' };
}
const project = rows[0]!;
broker.publishUser('default', { type: 'project_unarchived', project });
broker.publishUserFrame('default', { type: 'project_unarchived', project });
return project;
});
@@ -288,7 +288,7 @@ export function registerProjectRoutes(
reply.code(404);
return { error: 'not found' };
}
broker.publishUser('default', { type: 'project_deleted', project_id: id });
broker.publishUserFrame('default', { type: 'project_deleted', project_id: id });
reply.code(204);
return null;
});

View File

@@ -13,6 +13,43 @@ const CreateBody = z.object({
agent_id: z.string().min(1).max(200).nullable().optional(),
});
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added
// as pane kinds. Pane state is a reference only (chat_id + message_id +
// title) — the actual artifact body is fetched from the message row or
// message_parts.payload by the pane component on mount.
const MarkdownArtifactStateZ = z.object({
chat_id: z.string().min(1).max(200),
message_id: z.string().min(1).max(200),
title: z.string().max(500),
});
const HtmlArtifactStateZ = z.object({
chat_id: z.string().min(1).max(200),
message_id: z.string().min(1).max(200),
title: z.string().max(500),
});
const WorkspacePaneZ = z.object({
id: z.string().min(1).max(200),
kind: z.enum([
'chat',
'terminal',
'agent',
'empty',
'settings',
'markdown_artifact',
'html_artifact',
]),
chatId: z.string().min(1).max(200).optional(),
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
html_artifact_state: HtmlArtifactStateZ.optional(),
});
const WorkspacePanesBody = z.object({
workspace_panes: z.array(WorkspacePaneZ).max(10),
});
const PatchBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
@@ -20,6 +57,29 @@ const PatchBody = z.object({
agent_id: z.string().min(1).max(200).nullable().optional(),
// v1.9: null = inherit from project default; true/false = explicit override.
web_search_enabled: z.boolean().nullable().optional(),
// v1.13.17-cross-repo-reads: revocation pathway. PATCH with a shortened
// list deletes entries; the grant flow itself APPENDS via the separate
// grant_read_access endpoint, never via this PATCH. Frontend treats this
// as "send the new whole array". Per-entry shape validation: must be
// absolute, no NUL, no `/..` traversal segment. Server doesn't re-validate
// whitelist membership on PATCH — entries already in the array were
// placed there by the grant endpoint after a full whitelist+repo-shape
// check. THE SUBSET CHECK (every entry must already be in the current
// array) is enforced at runtime in the PATCH handler below, NOT in this
// zod refinement, because the refinement has no access to the existing
// session row.
allowed_read_paths: z
.array(
z
.string()
.min(1)
.max(1024)
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('/..'), {
message: 'must be an absolute path without traversal markers',
}),
)
.max(64)
.optional(),
});
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
@@ -28,6 +88,19 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
return config.DEFAULT_MODEL;
}
// v1.13.17-cross-repo-reads: subset enforcement for PATCH allowed_read_paths.
// The PATCH route can only SHRINK the array; growth happens exclusively via
// POST /api/chats/:id/grant_read_access (which requires user consent).
// Returns the list of disallowed-additions; an empty list means the request
// is a valid shrink-or-no-op. Exported for the unit test.
export function findUnauthorizedAdditions(
prior: readonly string[],
requested: readonly string[],
): string[] {
const priorSet = new Set(prior);
return requested.filter((p) => !priorSet.has(p));
}
export function registerSessionRoutes(
app: FastifyInstance,
sql: Sql,
@@ -44,7 +117,7 @@ export function registerSessionRoutes(
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
@@ -92,7 +165,7 @@ export function registerSessionRoutes(
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
`;
await tx`
INSERT INTO chats (session_id, name, status)
@@ -100,7 +173,7 @@ export function registerSessionRoutes(
`;
return session!;
});
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_created',
session: row,
project_id: row.project_id,
@@ -112,7 +185,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -138,15 +211,53 @@ export function registerSessionRoutes(
const newAgentId = parsed.data.agent_id ?? null;
const wseProvided = parsed.data.web_search_enabled !== undefined;
const newWse = parsed.data.web_search_enabled ?? null;
// Read the prior name so the post-update publish can skip no-op renames
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
// between SELECT and UPDATE is sub-millisecond in the same request handler;
// a concurrent rename in that gap would just mean one stale publish, which
// existing clients dedup by id.
const before = await sql<{ name: string }[]>`
SELECT name FROM sessions WHERE id = ${req.params.id}
// v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
// change, [] = clear). Frontend currently uses this PATCH only for
// revocation (delete a single entry from the existing array, send
// shortened result). Append-style grants go through the dedicated
// grant_read_access endpoint inside the inference loop.
const arpProvided = parsed.data.allowed_read_paths !== undefined;
const newArp = parsed.data.allowed_read_paths ?? [];
// Read the prior name + grants so the post-update publish can skip no-op
// renames (PATCH { name: "Foo" } where the session is already "Foo") AND
// so the subset check below has the current grant list to compare against.
// The window between SELECT and UPDATE is sub-millisecond in the same
// request handler; a concurrent rename in that gap would just mean one
// stale publish, which existing clients dedup by id.
const before = await sql<{ name: string; allowed_read_paths: string[] }[]>`
SELECT name, allowed_read_paths FROM sessions WHERE id = ${req.params.id}
`;
const priorName = before[0]?.name;
const priorArp = before[0]?.allowed_read_paths ?? [];
// v1.13.17-cross-repo-reads: subset enforcement. The grant flow is the
// ONLY path that can add entries to allowed_read_paths — PATCH can only
// shrink the array, never grow it. Without this guard, a malicious
// client could POST {"allowed_read_paths":["/etc"]} and bypass the
// user-consent prompt entirely. Sam flagged this in the v1.13.17
// compliance review (2026-05-22).
// Race note: a concurrent grant landing between this SELECT and the
// UPDATE below would briefly make a "shouldn't-have-been-valid" PATCH
// succeed (the newly-granted root sneaks in). Inverse race — a
// legitimate revoke happening alongside a concurrent grant — could
// briefly reject the revoke; the user retries. Both are acceptable
// given the single-user threat model + sub-millisecond window.
if (arpProvided) {
const extras = findUnauthorizedAdditions(priorArp, newArp);
if (extras.length > 0) {
reply.code(400);
return {
error: 'invalid body',
details: {
fieldErrors: {
allowed_read_paths: [
`entries must already be granted; cannot add via PATCH: ${extras.join(', ')}`,
],
},
},
};
}
}
const rows = await sql<Session[]>`
UPDATE sessions
SET
@@ -155,10 +266,11 @@ export function registerSessionRoutes(
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
allowed_read_paths = CASE WHEN ${arpProvided} THEN ${sql.array(newArp, 25)} ELSE allowed_read_paths END,
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
`;
if (rows.length === 0) {
reply.code(404);
@@ -166,7 +278,7 @@ export function registerSessionRoutes(
}
const session = rows[0]!;
if (name !== undefined && session.name !== priorName) {
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
@@ -176,7 +288,7 @@ export function registerSessionRoutes(
// (notably the SettingsPane open in another tab) can refetch and pick
// up the new fields. Frame stays lean (decision d) — payload is just
// ids + name + updated_at, the client refetches via api.sessions.get.
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_updated',
session_id: session.id,
project_id: session.project_id,
@@ -187,6 +299,36 @@ export function registerSessionRoutes(
}
);
app.patch<{ Params: { id: string } }>(
'/api/sessions/:id/workspace',
async (req, reply) => {
const parsed = WorkspacePanesBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const rows = await sql<Session[]>`
UPDATE sessions
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const session = rows[0]!;
broker.publishUserFrame('default', {
type: 'session_workspace_updated',
session_id: session.id,
workspace_panes: session.workspace_panes,
});
return session;
}
);
// v1.9: bulk-archive every open session in a project. Mirrors the
// single-archive shape (same broker frame type) so the existing useSidebar
// reducer cases handle it without changes — just N frames instead of 1.
@@ -206,7 +348,7 @@ export function registerSessionRoutes(
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_archived',
session_id: id,
project_id: req.params.id,
@@ -247,7 +389,7 @@ export function registerSessionRoutes(
reply.code(404);
return { error: 'session not found or already archived' };
}
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_archived',
session_id: rows[0]!.id,
project_id: rows[0]!.project_id,
@@ -263,14 +405,14 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found or not archived' };
}
const session = rows[0]!;
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_created',
session: session,
project_id: session.project_id,
@@ -292,7 +434,7 @@ export function registerSessionRoutes(
return { error: 'not found' };
}
const project_id = deleted[0]!.project_id;
broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id });
broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id });
reply.code(204);
return null;
}

View File

@@ -86,15 +86,30 @@ export function registerSkillsRoutes(
const result = await sql.begin(async (tx) => {
const [synthAssistant] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
RETURNING id
`;
// v1.13.20: parts-only write. Single skill_use tool_call, no text
// content, so one part at seq 0.
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
id: toolCallId,
name: 'skill_use',
args: { name: skill_name },
} as never)})
`;
const [toolMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
// v1.13.20: parts-only write of the synthetic tool result (skill body).
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
`;
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())

View File

@@ -0,0 +1,40 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
export interface ToolCostStat {
tool_name: string;
mean_prompt_tokens: number;
mean_completion_tokens: number;
n_calls: number;
updated_at: string;
}
// v1.13.10: per-tool token cost rolling window read endpoint. Backed by the
// tool_cost_stats view in schema.sql (last 100 calls per tool, equal-split
// attribution across multi-tool turns, sentinel/failed-turn excluded).
// Consumed by AgentPicker for at-a-glance per-agent cost hints.
export function registerToolsRoutes(app: FastifyInstance, sql: Sql): void {
app.get('/api/tools/cost_stats', async () => {
const rows = await sql<
{
tool_name: string;
prompt_tokens_sum: number;
completion_tokens_sum: number;
n_calls: number;
updated_at: string;
}[]
>`
SELECT tool_name, prompt_tokens_sum, completion_tokens_sum, n_calls, updated_at
FROM tool_cost_stats
ORDER BY tool_name ASC
`;
const stats: ToolCostStat[] = rows.map((r) => ({
tool_name: r.tool_name,
mean_prompt_tokens: Math.round(r.prompt_tokens_sum / r.n_calls),
mean_completion_tokens: Math.round(r.completion_tokens_sum / r.n_calls),
n_calls: r.n_calls,
updated_at: r.updated_at,
}));
return { stats };
});
}

View File

@@ -23,11 +23,12 @@ export function registerWebSocket(
// v1.11: snapshot includes compaction fields so MessageBubble can
// render the SummaryCard for summary=true rows on first connect.
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
const messages = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages
FROM messages_with_parts
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
`;

View File

@@ -1,3 +1,10 @@
-- v1.13.3: statement_timeout is set at database level via:
-- ALTER DATABASE boocode SET statement_timeout = '30s';
-- ALTER DATABASE can't run inside a DO block, so this is an operational
-- step rather than schema. Re-apply after a volume reset (the setting
-- lives in pg_db which survives `docker compose up --build` but NOT a
-- `docker volume rm boocode_pgdata`).
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
@@ -32,6 +39,162 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
-- v1.13.0: granular message parts table for AI SDK migration. Old
-- messages.content / tool_calls / tool_results columns stay authoritative
-- for reads in v1.13.0; this table is dual-written so the swap can happen
-- in a later dispatch without a backfill window. ON DELETE CASCADE means
-- removing a message removes its parts in one go.
CREATE TABLE IF NOT EXISTS message_parts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
sequence int NOT NULL,
kind text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact')),
CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
);
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
-- v1.13.4: prune support. hidden_at marks parts that have been pruned out
-- of the model payload by the two-tier compaction prune (services/inference/
-- prune.ts). Rows stay in the DB so frontend can still display them with a
-- "hidden" indicator (out of scope this dispatch). messages_with_parts
-- view filters these out — see below. Partial index speeds the common
-- "visible parts only" filter.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'message_parts' AND column_name = 'hidden_at'
) THEN
ALTER TABLE message_parts ADD COLUMN hidden_at timestamptz NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
ON message_parts (message_id) WHERE hidden_at IS NULL;
-- v1.13.13: extend message_parts.kind to allow 'synthesis'. Existing DBs were
-- created with the pre-v1.13.13 CHECK constraint that did NOT include
-- 'synthesis'; drop + re-add the constraint with the extended enum. Fresh
-- installs hit the inline constraint above (already updated) and skip this
-- block via the pg_constraint guard.
-- v1.14.x-html-artifact-panes: extend the same constraint with 'html_artifact'.
-- DROP IF EXISTS + DO $$ pg_constraint $$ guard remains idempotent across
-- both v1.13.13 and v1.14.x boots; the IN list below is the union of every
-- kind ever shipped.
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'message_parts_kind_chk'
) THEN
ALTER TABLE message_parts
ADD CONSTRAINT message_parts_kind_chk
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact'));
END IF;
END $$;
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
-- instead of messages so tool_calls / tool_results / reasoning_parts come
-- from the granular message_parts table.
-- v1.13.20: post column-drop. The legacy COALESCE fallback over
-- messages.tool_calls / messages.tool_results was removed because those
-- columns no longer exist on the table (see the ALTER TABLE DROP COLUMN
-- statements below). Writes continue to target `messages` directly — the
-- view is read-only. Shapes match the in-memory ToolCall / ToolResult
-- types: tool_calls is a jsonb array of {id, name, args}, tool_results is
-- a single jsonb object {tool_call_id, output, truncated, error?}.
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
CREATE OR REPLACE VIEW messages_with_parts AS
SELECT
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
m.started_at, m.finished_at, m.created_at, m.metadata,
m.summary, m.tail_start_id, m.compacted_at,
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
(SELECT p.payload
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
ORDER BY p.sequence LIMIT 1) AS tool_results,
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
FROM messages m;
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
-- through messages_with_parts since v1.13.1-B; dual-writes removed in this
-- batch. The view above was simplified to remove COALESCE fallbacks before
-- this drop (Postgres rejects column-drop on view-referenced columns).
-- Idempotent via IF EXISTS.
ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;
ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;
-- v1.13.10: per-tool token cost rolling window. Derives from
-- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
-- the legacy JSON column) so this works whether the chat predates v1.13.0
-- or postdates v1.13.2 (column drop). No new write site — all source data
-- already lands via the existing tool-phase.ts:94-95 UPDATE.
--
-- Attribution model: equal split. A turn emitting N tool calls divides its
-- prompt/completion tokens by N before attribution. See v1.13.10 dispatch
-- brief for rationale + rejected alternatives.
--
-- Column mapping: messages.ctx_used = prompt (input), messages.tokens_used
-- = completion (output). Non-obvious naming; pinned via canonical writes at
-- tool-phase.ts:94-95 et al.
--
-- Filtering rationale:
-- status='complete' — exclude failed/cancelled (defense in
-- depth; failed-path doesn't write
-- tokens_used so they're filtered
-- indirectly too).
-- metadata->>'kind' exclusions — exclude cap_hit / doom_loop sentinels
-- (defense in depth; sentinels are
-- role='system' with tool_calls=NULL
-- so they're filtered indirectly too).
-- experimental_repairToolCall — no special handling; retries flow
-- as normal next-turn tool_result
-- errors and count naturally.
--
-- Rolling window: last 100 calls per tool_name, ordered by created_at DESC.
-- Aggregate-on-read is microseconds at BooCode scale (single user, ~30
-- tools, < 100 calls each). DROP VIEW + recreate to change window size.
CREATE OR REPLACE VIEW tool_cost_stats AS
WITH per_call AS (
SELECT
(tc->>'name')::text AS tool_name,
(m.ctx_used::float / NULLIF(jsonb_array_length(m.tool_calls), 0)) AS prompt_tokens,
(m.tokens_used::float / NULLIF(jsonb_array_length(m.tool_calls), 0)) AS completion_tokens,
m.created_at,
ROW_NUMBER() OVER (
PARTITION BY (tc->>'name')::text
ORDER BY m.created_at DESC
) AS rn
FROM messages_with_parts m,
LATERAL jsonb_array_elements(m.tool_calls) AS tc
WHERE m.tool_calls IS NOT NULL
AND jsonb_array_length(m.tool_calls) > 0
AND m.tokens_used IS NOT NULL
AND m.ctx_used IS NOT NULL
AND m.status = 'complete'
AND (m.metadata IS NULL
OR m.metadata->>'kind' IS NULL
OR m.metadata->>'kind' NOT IN ('cap_hit', 'doom_loop'))
)
SELECT
tool_name,
ROUND(SUM(prompt_tokens))::int AS prompt_tokens_sum,
ROUND(SUM(completion_tokens))::int AS completion_tokens_sum,
COUNT(*)::int AS n_calls,
MAX(created_at) AS updated_at
FROM per_call
WHERE rn <= 100
GROUP BY tool_name;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_used INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER;
@@ -47,22 +210,14 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;
-- DEPRECATED: client-side pane state as of v1.2-batch4. Table retained per
-- additive schema rule; no writes. Drop in a future destructive migration.
CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')),
state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (session_id, position)
);
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
-- v1.12.1: deprecated session_panes table removed. Workspace pane state now
-- lives in sessions.workspace_panes (jsonb), see below.
DROP TABLE IF EXISTS session_panes;
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
-- future destructive migration.
-- v1.12.1: server-side workspace pane layout, replaces localStorage so every
-- device sees the same panes for a given session. Shape matches
-- WorkspacePane[] from apps/server/src/types/api.ts.
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS workspace_panes JSONB NOT NULL DEFAULT '[]'::jsonb;
-- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
@@ -159,6 +314,16 @@ END $$;
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
-- v1.13.17-cross-repo-reads: session-scoped read grants for paths outside the
-- session's primary project root. Populated only by the request_read_access
-- tool's approve branch; revoked via PATCH /api/sessions/:id. Values are
-- absolute paths to project roots OR repo-shaped dirs under
-- PROJECT_ROOT_WHITELIST (default /opt). No CHECK constraint — validation
-- happens at write time in services/grant_resolver.ts. Cleared automatically
-- when the session row is deleted (no cascade needed; the column goes with it).
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
-- reasons. JSONB so future kinds can extend without further schema churn.
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,

View File

@@ -0,0 +1,261 @@
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
decideHtmlArtifactWrite,
deriveHtmlSlug,
deriveHtmlTitle,
deriveMarkdownSlug,
detectHtmlArtifact,
HTML_ARTIFACT_MAX_BYTES,
writeHtmlArtifact,
writeMarkdownArtifact,
} from '../artifacts.js';
import { PathScopeError } from '../path_guard.js';
describe('deriveMarkdownSlug', () => {
it('uses the first # heading when present', () => {
expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
});
it('falls back to first 6 words', () => {
const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
expect(s).toBe('the-quick-brown-fox-jumps-over');
});
it('returns "artifact" for empty input', () => {
expect(deriveMarkdownSlug('')).toBe('artifact');
});
it('caps at 60 chars and lowercases', () => {
const long = '# ' + 'A'.repeat(200);
const s = deriveMarkdownSlug(long);
expect(s.length).toBeLessThanOrEqual(60);
expect(s).toMatch(/^[a-z0-9-]+$/);
});
it('strips trailing punctuation', () => {
expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
});
});
describe('deriveHtmlSlug', () => {
it('prefers payload.title when set', () => {
expect(
deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
).toBe('my-title');
});
it('falls back to <title> tag', () => {
expect(
deriveHtmlSlug({
html_content: '<html><head><title>Page Title</title></head></html>',
title: null,
}),
).toBe('page-title');
});
it('falls back to first <h1> when no <title>', () => {
expect(
deriveHtmlSlug({
html_content: '<html><body><h1>Heading One</h1></body></html>',
title: null,
}),
).toBe('heading-one');
});
it('falls back to inner text words', () => {
expect(
deriveHtmlSlug({
html_content: '<div>one two three four five six seven</div>',
title: null,
}),
).toBe('one-two-three-four-five-six');
});
});
describe('deriveHtmlTitle', () => {
it('returns <title> content', () => {
expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
});
it('falls back to <h1>', () => {
expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
});
it('falls back to first 80 chars of inner text', () => {
const html = '<div>' + 'x '.repeat(100) + '</div>';
const t = deriveHtmlTitle(html);
expect(t).not.toBeNull();
expect(t!.length).toBeLessThanOrEqual(80);
});
it('returns null for empty html', () => {
expect(deriveHtmlTitle('')).toBeNull();
});
});
describe('detectHtmlArtifact', () => {
it('detects <!DOCTYPE html> prefix case-insensitively', () => {
const html = '<!doctype HTML><html><body>x</body></html>';
expect(detectHtmlArtifact(html)).toBe(html);
});
it('strips leading/trailing whitespace before matching', () => {
const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
expect(detectHtmlArtifact(html)).toBe(html.trim());
});
it('detects fenced ```html block wrapping entire message', () => {
const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
});
it('rejects plain markdown', () => {
expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
});
it('rejects message with prose before the doctype', () => {
expect(
detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
).toBeNull();
});
it('rejects empty input', () => {
expect(detectHtmlArtifact('')).toBeNull();
expect(detectHtmlArtifact(' \n ')).toBeNull();
});
it('rejects fenced block without doctype/<html>', () => {
expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
});
it('accepts fenced block containing <html> tag (no doctype)', () => {
const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
expect(r).toContain('<html>');
});
});
describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
let projectRoot: string;
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
});
afterEach(async () => {
await rm(projectRoot, { recursive: true, force: true });
});
it('writes a markdown artifact under .boocode/artifacts/', async () => {
const result = await writeMarkdownArtifact(
{ content: '# Hello\n\nbody' },
{ projectId: 'pid', projectRoot },
);
expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
const written = await readFile(result.path, 'utf8');
expect(written).toBe('# Hello\n\nbody');
});
it('writes an html artifact', async () => {
const result = await writeHtmlArtifact(
{
html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
char_count: 56,
title: 'X',
},
{ projectId: 'pid', projectRoot },
);
expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
const written = await readFile(result.path, 'utf8');
expect(written).toContain('<!DOCTYPE html>');
});
it('creates the artifacts directory if absent', async () => {
// Confirm the writer mkdir-recursive's the artifacts dir on first call.
const result = await writeMarkdownArtifact(
{ content: '# T' },
{ projectId: 'pid', projectRoot },
);
expect(result.path).toContain('.boocode/artifacts');
});
});
describe('1MB cap behavior', () => {
it('reports the correct byte threshold', () => {
expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
});
it('exceeds threshold for oversize payload', () => {
const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
HTML_ARTIFACT_MAX_BYTES,
);
});
it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
// Detection is content-shape; the cap check lives in finalizeCompletion
// (error-handler.ts). This test pins that contract: the helper does not
// silently drop oversize payloads on the floor.
const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
expect(detectHtmlArtifact(big)).not.toBeNull();
});
});
describe('decideHtmlArtifactWrite', () => {
// Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
// the warn-and-skip decision without mocking the full InferenceContext.
it('returns write=true for payloads under the cap', () => {
const html = '<!DOCTYPE html><html></html>';
const decision = decideHtmlArtifactWrite(html);
expect(decision.write).toBe(true);
expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
});
it('returns write=false with cap_exceeded reason for oversize payloads', () => {
const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
const decision = decideHtmlArtifactWrite(big);
expect(decision.write).toBe(false);
if (!decision.write) {
expect(decision.reason).toBe('cap_exceeded');
expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
}
});
it('accepts payload exactly at the cap (boundary)', () => {
// byteLen === cap should write; only strictly greater skips.
const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
const decision = decideHtmlArtifactWrite(exact);
expect(decision.write).toBe(true);
expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
});
});
describe('symlink escape protection', () => {
// Closes the gap where `.boocode/artifacts` is a symlink pointing
// outside the project root. The lexical prefix check on the resolved
// candidate path passes (it's under projectRoot textually), but the
// post-mkdir realpath verification must catch the escape.
let projectRoot: string;
let outside: string;
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
});
afterEach(async () => {
await rm(projectRoot, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
});
it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
// Create .boocode dir, then make `artifacts` a symlink pointing outside.
await mkdir(join(projectRoot, '.boocode'), { recursive: true });
await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
await expect(
writeMarkdownArtifact(
{ content: '# Hello' },
{ projectId: 'pid', projectRoot },
),
).rejects.toBeInstanceOf(PathScopeError);
});
});

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { callCodecontext } from '../codecontext_client.js';
@@ -203,3 +203,197 @@ describe('callCodecontext — error paths', () => {
).rejects.toThrow(/timed out after 30000ms/);
});
});
// ---- v1.13.18: file_path resolution tests -----------------------------------
describe('callCodecontext — file_path resolution', () => {
// Case 1: relative path resolves to absolute under project root
it('resolves a relative file_path to an absolute path inside project root', async () => {
// Create a real file so realpath can canonicalise it
const fileName = 'src_module.ts';
await writeFile(join(projectDir, fileName), '// hello');
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'file analysis', error: null }),
);
await callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: fileName },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
expect(fetcher).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
// Should be the resolved absolute path
expect(body.file_path).toBe(join(projectDir, fileName));
});
// Case 2: absolute path inside project root → realpathed → forwarded
it('passes through an absolute file_path inside project root', async () => {
const fileName = 'absolute_target.ts';
const absPath = join(projectDir, fileName);
await writeFile(absPath, '// absolute');
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'analysis', error: null }),
);
await callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: absPath },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
expect(body.file_path).toBe(absPath);
});
// Case 3: relative escape path → rejected with same error shape as target_dir escape
it('rejects a relative file_path that escapes the project root', async () => {
const fetcher = vi.fn();
await expect(
callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: '../../etc/passwd' },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/escapes project root/);
expect(fetcher).not.toHaveBeenCalled();
});
// Case 4: absolute path outside project root → rejected
it('rejects an absolute file_path outside the project root', async () => {
const fetcher = vi.fn();
await expect(
callCodecontext(
{
toolName: 'get_file_analysis',
// /etc/passwd is outside any tmpdir project root
args: { file_path: '/etc/passwd' },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/escapes project root/);
expect(fetcher).not.toHaveBeenCalled();
});
// Case 5: nonexistent file (ENOENT) → forwarded as un-realpath'd absolute
it('forwards a nonexistent file_path as absolute without throwing', async () => {
const missingPath = join(projectDir, 'does_not_exist.ts');
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: null, error: 'File not found in graph: ' + missingPath }),
);
// The resolver should NOT throw; the error comes back from the sidecar
await expect(
callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: 'does_not_exist.ts' },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/File not found in graph/);
// Wire was still called — resolver forwarded the path
expect(fetcher).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
// Should receive the absolute (non-realpathed) path
expect(body.file_path).toBe(missingPath);
});
// Case 6: empty string → skipped by guard, reaches wire unmodified
// Note: Zod .trim().min(1) in get_file_analysis rejects empty before the
// shim is reached in production. At the shim layer, the guard
// `file_path.trim() !== ''` skips the resolver for empty strings so that
// optional-file_path wrappers treat '' as "not provided". This is a
// deliberate design; callers that require file_path validate at the Zod layer.
it('skips resolver for empty string file_path (treated as not provided)', async () => {
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'analysis', error: null }),
);
// Should succeed — empty string is treated as "no file_path"
await callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: '' },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
expect(fetcher).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
// Empty string passes through unchanged (resolver not invoked)
expect(body.file_path).toBe('');
});
// Case 7: wrapper without file_path (e.g. get_codebase_overview) → resolver not invoked
it('does not invoke file_path resolver when file_path is absent from args', async () => {
const fetcher = vi.fn().mockResolvedValue(
mockJSONResponse({ result: 'overview', error: null }),
);
await callCodecontext(
{
toolName: 'get_codebase_overview',
args: { include_stats: true },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
);
expect(fetcher).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
// No file_path in the wire body
expect('file_path' in body).toBe(false);
});
// Case 8: absolute path with `..` that resolves outside project root, even
// when the literal path is ENOENT. Without resolve() in the absolute branch
// the prefix check false-positives because the raw `<projectDir>/../etc/x`
// literal starts with `<projectDir>/`.
it('rejects absolute file_path with `..` resolving outside project root (ENOENT branch)', async () => {
const fetcher = vi.fn();
const escapingAbsolute = `${projectDir}/../etc/non_existent_passwd`;
await expect(
callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: escapingAbsolute },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/escapes project root/);
expect(fetcher).not.toHaveBeenCalled();
});
// Case 9: in-project symlink targeting outside the project root. This is the
// canonical realpath defense — realpath must canonicalise the symlink and
// the escape check must reject. Without this test, a symlink-out hole could
// regress silently.
it('rejects file_path that resolves through a symlink leaving project root', async () => {
const outsideDir = await mkdtemp(join(tmpdir(), 'codecontext-outside-'));
try {
const evilTarget = join(outsideDir, 'secrets.txt');
await writeFile(evilTarget, 'top secret');
await symlink(evilTarget, join(projectDir, 'evil-link'));
const fetcher = vi.fn();
await expect(
callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: 'evil-link' },
projectPath: projectDir,
},
fetcher as unknown as typeof fetch,
),
).rejects.toThrow(/escapes project root/);
expect(fetcher).not.toHaveBeenCalled();
} finally {
await rm(outsideDir, { recursive: true, force: true });
}
});
});

View File

@@ -70,7 +70,7 @@ describe('codecontext wrappers — toolName + args forwarding', () => {
const { url, body } = parsePOST(fetcher);
expect(url).toMatch(/\/v1\/get_file_analysis$/);
expect(body).toMatchObject({
file_path: 'apps/server/src/index.ts',
file_path: join(projectDir, 'apps/server/src/index.ts'),
target_dir: projectDir,
});
});

View File

@@ -6,6 +6,7 @@ import {
turns,
select,
buildPrompt,
buildHeadPayload,
type CompactionMessage,
} from '../compaction.js';
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
@@ -31,6 +32,7 @@ function mkMsg(
status: 'complete',
tool_calls: null,
tool_results: null,
reasoning_parts: null,
metadata: null,
created_at: new Date(counter * 1000).toISOString(),
...overrides,
@@ -39,49 +41,58 @@ function mkMsg(
// ---- usable -----------------------------------------------------------------
describe('usable', () => {
it('returns 0 when contextLimit is 0', () => {
// v1.13.9: ratio-only early trigger at 0.85 × contextLimit. Replaces the
// v1.11.0-era `contextLimit - 20_000` math, which degenerated to 0 for
// contexts ≤20k and gave only 7-8% headroom at 262k.
describe('usable() — ratio-only early trigger (v1.13.9)', () => {
it('returns floor(0.85 * limit) for the qwen3.6 daily-driver context', () => {
// floor(0.85 * 262144) = floor(222822.4) = 222822 — 15% headroom for
// the summarizer to do its turn without itself overflowing.
expect(usable(262144)).toBe(222822);
});
it('returns 0.85× for a mid-sized context', () => {
expect(usable(100_000)).toBe(85_000);
});
it('returns 0.85× for a small context (no degenerate 0)', () => {
// floor(0.85 * 8192) = 6963. Under the old formula this returned 0
// (8192 - 20_000 clamped to 0), effectively disabling compaction for
// small-context models. The ratio keeps the trigger active.
expect(usable(8192)).toBe(6963);
});
it('returns 0 for zero or negative contextLimit', () => {
expect(usable(0)).toBe(0);
});
it('returns 0 when contextLimit is below the 20k buffer', () => {
// Math.max(0, x - 20000) clamps the subtraction so we never report
// negative headroom. A 10k-context model reports 0 usable, which makes
// isOverflow short-circuit to false (correct — we can't size the
// compaction with no headroom).
expect(usable(10_000)).toBe(0);
expect(usable(19_999)).toBe(0);
expect(usable(20_000)).toBe(0);
});
it('subtracts the 20k buffer from a normal-sized context window', () => {
expect(usable(100_000)).toBe(80_000);
expect(usable(32_768)).toBe(12_768);
expect(usable(-1)).toBe(0);
});
});
// ---- isOverflow -------------------------------------------------------------
describe('isOverflow', () => {
it('returns false when usable is 0 (unknown / sub-buffer context)', () => {
it('returns false when usable is 0 (unknown contextLimit)', () => {
expect(isOverflow({ prompt_tokens: 999_999, completion_tokens: 0 }, 0)).toBe(false);
expect(isOverflow({ prompt_tokens: 0, completion_tokens: 999_999 }, 10_000)).toBe(false);
expect(isOverflow({ prompt_tokens: 0, completion_tokens: 999_999 }, -1)).toBe(false);
});
it('returns false at 50% of usable', () => {
// usable(100k) = 80k → 50% = 40k.
// v1.13.9: usable(100k) = 85k → 50% 42.5k.
expect(isOverflow({ prompt_tokens: 30_000, completion_tokens: 10_000 }, 100_000)).toBe(false);
});
it('returns false just under usable', () => {
expect(isOverflow({ prompt_tokens: 79_000, completion_tokens: 999 }, 100_000)).toBe(false);
// v1.13.9: 84_000 + 999 = 84_999 < 85_000 budget.
expect(isOverflow({ prompt_tokens: 84_000, completion_tokens: 999 }, 100_000)).toBe(false);
});
it('returns true exactly at usable (>=, not strict >)', () => {
expect(isOverflow({ prompt_tokens: 80_000, completion_tokens: 0 }, 100_000)).toBe(true);
// v1.13.9: 85_000 == usable(100_000).
expect(isOverflow({ prompt_tokens: 85_000, completion_tokens: 0 }, 100_000)).toBe(true);
});
it('returns true above usable', () => {
// 50_000 + 40_000 = 90_000 > 85_000.
expect(isOverflow({ prompt_tokens: 50_000, completion_tokens: 40_000 }, 100_000)).toBe(true);
});
});
@@ -224,8 +235,9 @@ describe('select', () => {
const u = mkMsg('user', 'oversized');
const a = mkMsg('assistant', 'Y'.repeat(40_000));
const result = select([u, a], 30_000, 1);
// usable(30k) = 10k → budget = min(8k, max(2k, floor(10k*0.25))) =
// min(8k, max(2k, 2500)) = 2500. 40k chars ≈ 10k tokens. Can't fit.
// v1.13.9: usable(30k) = floor(0.85*30k) = 25500 → budget =
// min(8k, max(2k, floor(25500*0.25))) = min(8k, max(2k, 6375)) = 6375.
// 40k chars ≈ 10k tokens. Still can't fit (10k > 6375).
expect(result.tail_start_id).toBeUndefined();
expect(result.head).toEqual([u, a]);
});
@@ -256,3 +268,56 @@ describe('buildPrompt', () => {
expect(out.endsWith('extra-context-line')).toBe(true);
});
});
// ---- buildHeadPayload (v1.13.6) -----------------------------------------------
describe('buildHeadPayload reasoning render', () => {
it('emits reasoning as a <reasoning> tag prefixed onto the assistant content', () => {
const out = buildHeadPayload([
mkMsg('user', 'show me the file'),
mkMsg('assistant', 'reading it now', {
reasoning_parts: [{ text: 'user wants src/index.ts; I should view it' }],
}),
]);
expect(out).toHaveLength(2);
expect(out[1]!.role).toBe('assistant');
expect(out[1]!.content).toBe(
'<reasoning>user wants src/index.ts; I should view it</reasoning>\n\nreading it now',
);
});
it('emits a standalone <reasoning> tag when reasoning is present but content is empty (tool-call-only turn)', () => {
const out = buildHeadPayload([
mkMsg('assistant', '', {
reasoning_parts: [{ text: 'jumping straight to grep' }],
tool_calls: [{ id: 'c1', name: 'grep', args: { pattern: 'foo' } }],
}),
]);
expect(out).toHaveLength(1);
expect(out[0]!.content).toBe('<reasoning>jumping straight to grep</reasoning>');
expect(out[0]!.tool_calls).toHaveLength(1);
expect(out[0]!.tool_calls![0]!.function.name).toBe('grep');
});
it('joins multiple reasoning parts without separators (matches the streaming concat)', () => {
const out = buildHeadPayload([
mkMsg('assistant', 'final answer', {
reasoning_parts: [{ text: 'first thought ' }, { text: 'second thought' }],
}),
]);
expect(out[0]!.content).toBe(
'<reasoning>first thought second thought</reasoning>\n\nfinal answer',
);
});
it('omits the reasoning tag entirely when reasoning_parts is null or empty', () => {
const out = buildHeadPayload([
mkMsg('assistant', 'plain answer', { reasoning_parts: null }),
mkMsg('assistant', 'other answer', { reasoning_parts: [] }),
]);
expect(out[0]!.content).toBe('plain answer');
expect(out[1]!.content).toBe('other answer');
expect(out[0]!.content).not.toContain('<reasoning>');
expect(out[1]!.content).not.toContain('<reasoning>');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { DOOM_LOOP_THRESHOLD, detectDoomLoop } from '../inference.js';
import { DOOM_LOOP_THRESHOLD, detectDoomLoop } from '../inference/index.js';
import type { ToolCall } from '../../types/api.js';
// ---- fixture ----------------------------------------------------------------

View File

@@ -0,0 +1,199 @@
// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree.
//
// Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor
// walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
// filesystem root — check on every iteration, not just final parent.
// Symlinked input must not be able to escape the whitelist during the
// walk." The symlink-escape-mid-walk test below pins that invariant —
// without the per-iteration whitelist check, this case would walk OUTSIDE
// the whitelist root and return a phantom grant.
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { realpath } from 'node:fs/promises';
import { resolveGrantRoot } from '../grant_resolver.js';
import type { Sql } from '../../db.js';
let tmp: string;
let whitelist: string;
let project: string;
let fork: string;
let outside: string;
// Fake sql tag — returns the projects rows we want without touching a real
// database. The resolver only ever does a single SELECT, so a single-shot
// mock that returns the prepared rows on every invocation is enough.
function makeSql(rows: Array<{ path: string }>): Sql {
const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql;
return tag;
}
beforeAll(async () => {
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-')));
whitelist = join(tmp, 'whitelist');
project = join(whitelist, 'boocode');
fork = join(whitelist, 'forks', 'codecontext');
outside = join(tmp, 'outside');
await mkdir(project, { recursive: true });
await mkdir(fork, { recursive: true });
await mkdir(outside, { recursive: true });
// Mark project as a repo (.git directory).
await mkdir(join(project, '.git'));
await writeFile(join(project, 'README.md'), 'project readme');
// Mark fork as a repo via go.mod (matches the proposal's example).
await writeFile(join(fork, 'go.mod'), 'module example.com/foo');
await writeFile(join(fork, 'main.go'), 'package main');
await writeFile(join(outside, 'secret.txt'), 'forbidden');
});
afterAll(async () => {
await rm(tmp, { recursive: true, force: true });
});
describe('resolveGrantRoot — happy paths', () => {
it('refuses when the requested path is already under projectRoot', async () => {
const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/already accessible/);
});
it('returns the project root when the path falls under a registered project', async () => {
// Register `fork` as a known project. Resolver should return the project
// ancestor (LONGEST match wins) rather than the repo-shape fallback.
const result = await resolveGrantRoot(
makeSql([{ path: fork }]),
join(fork, 'main.go'),
project,
whitelist,
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.root).toBe(fork);
expect(result.source).toBe('project');
}
});
it('falls back to the nearest repo-shaped ancestor when no project matches', async () => {
const result = await resolveGrantRoot(
makeSql([]),
join(fork, 'main.go'),
project,
whitelist,
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.root).toBe(fork);
expect(result.source).toBe('whitelist');
}
});
});
describe('resolveGrantRoot — refusals', () => {
it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => {
const result = await resolveGrantRoot(
makeSql([]),
join(outside, 'secret.txt'),
project,
whitelist,
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
});
it('refuses non-absolute paths', async () => {
const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/absolute/);
});
it('refuses missing paths without prompting', async () => {
const result = await resolveGrantRoot(
makeSql([]),
join(whitelist, 'nope'),
project,
whitelist,
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/does not exist/);
});
it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => {
// Build a directory tree under the whitelist that has NO repo markers
// all the way up to the whitelist root.
const plain = join(whitelist, 'plain-dir', 'nested');
await mkdir(plain, { recursive: true });
await writeFile(join(plain, 'just-a-file.txt'), 'x');
const result = await resolveGrantRoot(
makeSql([]),
join(plain, 'just-a-file.txt'),
project,
whitelist,
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
});
it('does not grant the whitelist root itself as a fallback', async () => {
// Even if .git existed at the whitelist root (it doesn't), we'd refuse.
// Easier to assert: a path directly under whitelist with no repo marker.
const direct = join(whitelist, 'lone-file.txt');
await writeFile(direct, 'x');
const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist);
expect(result.ok).toBe(false);
});
});
describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => {
it('refuses a symlinked input whose realpath sits outside the whitelist', async () => {
// The symlink lives nominally inside the whitelist, but its target
// (realpath) is outside. The guard's first realpath() call normalizes
// and the up-front whitelist check refuses immediately.
const link = join(whitelist, 'escape-link');
try {
await symlink(outside, link);
const result = await resolveGrantRoot(
makeSql([]),
join(link, 'secret.txt'),
project,
whitelist,
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
} finally {
await rm(link, { force: true });
}
});
it('walk loop terminates at the whitelist root, not at filesystem /', async () => {
// Construct a deep tree with NO repo markers anywhere. Without a bound,
// the walk would chase parents up to "/". The bound flips the loop into
// a refusal once the cursor equals the realpath'd whitelist root.
const deep = join(whitelist, 'a', 'b', 'c', 'd');
await mkdir(deep, { recursive: true });
await writeFile(join(deep, 'leaf.txt'), 'x');
const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
});
});
describe('resolveGrantRoot — nearest-project disambiguation', () => {
it('prefers the longest matching project path over a shorter ancestor', async () => {
const outer = whitelist;
const inner = fork; // /whitelist/forks/codecontext, deeper than outer
const result = await resolveGrantRoot(
makeSql([{ path: outer }, { path: inner }]),
join(fork, 'main.go'),
project,
whitelist,
);
expect(result.ok).toBe(true);
if (result.ok) expect(result.root).toBe(inner);
});
});
// Belt-and-suspenders: silence a known dynamic-import warning that vitest
// occasionally emits on transient fs operations in CI but never in dev.
vi.spyOn(console, 'warn').mockImplementation(() => {});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildMessagesPayload } from '../inference.js';
import { buildMessagesPayload } from '../inference/index.js';
import type {
Message,
MessageRole,

View File

@@ -0,0 +1,169 @@
/**
* v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
* Pure unit tests — no live MCP server needed. Tests tool-wrapping,
* read-only guard, name prefixing, content extraction, and error handling.
* Multi-server routing tested via wrapMcpTool's server-name prefix.
*/
import { describe, it, expect } from 'vitest';
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
describe('mcp-client', () => {
describe('wrapMcpTool — multi-server prefixing', () => {
it('produces a ToolDef with <serverName>_ prefix', () => {
const mcpTool = {
name: 'resolve-library-id',
description: 'Resolve a library identifier',
inputSchema: {
type: 'object' as const,
properties: { query: { type: 'string' } },
required: ['query'],
},
};
const wrapped = wrapMcpTool('context7', mcpTool);
expect(wrapped.name).toBe('context7_resolve-library-id');
expect(wrapped.description).toBe('Resolve a library identifier');
expect(wrapped.jsonSchema.type).toBe('function');
expect(wrapped.jsonSchema.function.name).toBe('context7_resolve-library-id');
expect(wrapped.jsonSchema.function.parameters).toEqual(mcpTool.inputSchema);
expect(typeof wrapped.execute).toBe('function');
});
it('prefixes tools from different servers correctly', () => {
const toolA = {
name: 'query-docs',
description: 'Query docs',
inputSchema: { type: 'object' as const, properties: {} },
};
const toolB = {
name: 'overview',
description: 'Get overview',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrappedA = wrapMcpTool('context7', toolA);
const wrappedB = wrapMcpTool('codecontext', toolB);
expect(wrappedA.name).toBe('context7_query-docs');
expect(wrappedB.name).toBe('codecontext_overview');
});
it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => {
const serverATools = [
{ name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } },
{ name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } },
];
const serverBTools = [
{ name: 'overview', inputSchema: { type: 'object' as const, properties: {} } },
{ name: 'search', inputSchema: { type: 'object' as const, properties: {} } },
];
const allWrapped = [
...serverATools.map((t) => wrapMcpTool('context7', t)),
...serverBTools.map((t) => wrapMcpTool('codecontext', t)),
];
expect(allWrapped).toHaveLength(4);
expect(allWrapped.map((t) => t.name)).toEqual([
'context7_query-docs',
'context7_resolve-library-id',
'codecontext_overview',
'codecontext_search',
]);
});
it('defaults description to empty string when absent', () => {
const mcpTool = {
name: 'no-desc',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrapped = wrapMcpTool('myserver', mcpTool);
expect(wrapped.description).toBe('');
expect(wrapped.jsonSchema.function.description).toBe('');
});
it('uses passthrough Zod schema (z.record)', () => {
const mcpTool = {
name: 'test',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrapped = wrapMcpTool('s', mcpTool);
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
expect(result.success).toBe(true);
});
});
describe('isToolReadOnly', () => {
it('accepts tools with readOnlyHint: true', () => {
expect(isToolReadOnly({ readOnlyHint: true })).toBe(true);
});
it('accepts tools with no annotations', () => {
expect(isToolReadOnly(undefined)).toBe(true);
});
it('accepts tools with empty annotations', () => {
expect(isToolReadOnly({})).toBe(true);
});
it('rejects tools with readOnlyHint: false', () => {
expect(isToolReadOnly({ readOnlyHint: false })).toBe(false);
});
it('accepts tools with only destructiveHint set', () => {
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
});
});
describe('extractContent', () => {
it('extracts single text block', () => {
const content = [{ type: 'text', text: 'hello world' }];
expect(extractContent(content)).toBe('hello world');
});
it('joins multiple text blocks with newline', () => {
const content = [
{ type: 'text', text: 'line 1' },
{ type: 'text', text: 'line 2' },
];
expect(extractContent(content)).toBe('line 1\nline 2');
});
it('returns "(no output)" for empty content', () => {
expect(extractContent([])).toBe('(no output)');
});
it('returns "(no output)" for undefined content', () => {
expect(extractContent(undefined)).toBe('(no output)');
});
it('serializes non-text blocks as JSON', () => {
const content = [
{ type: 'resource', uri: 'file:///foo', mimeType: 'text/plain' },
];
const result = extractContent(content);
expect(result).toContain('"type":"resource"');
expect(result).toContain('"uri":"file:///foo"');
});
it('returns error shape when isError is true', () => {
const content = [{ type: 'text', text: 'something failed' }];
const result = extractContent(content, true);
expect(result).toEqual({ error: true, output: 'something failed' });
});
it('returns error shape with joined content on isError', () => {
const content = [
{ type: 'text', text: 'error 1' },
{ type: 'text', text: 'error 2' },
];
const result = extractContent(content, true);
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' });
});
});
});

View File

@@ -0,0 +1,82 @@
/**
* v1.15.0-mcp-multi: unit tests for matchToolGlob.
*/
import { describe, it, expect } from 'vitest';
import { matchToolGlob } from '../agents.js';
describe('matchToolGlob', () => {
it('exact match: "grep" matches "grep"', () => {
expect(matchToolGlob('grep', ['grep'])).toBe(true);
});
it('exact match: "grep" does not match "grep2"', () => {
expect(matchToolGlob('grep2', ['grep'])).toBe(false);
});
it('exact match: multiple tools', () => {
expect(matchToolGlob('grep', ['grep', 'view_file'])).toBe(true);
expect(matchToolGlob('view_file', ['grep', 'view_file'])).toBe(true);
expect(matchToolGlob('find_files', ['grep', 'view_file'])).toBe(false);
});
it('wildcard: "context7_*" matches "context7_query-docs"', () => {
expect(matchToolGlob('context7_query-docs', ['context7_*'])).toBe(true);
});
it('wildcard: "context7_*" matches "context7_resolve-library-id"', () => {
expect(matchToolGlob('context7_resolve-library-id', ['context7_*'])).toBe(true);
});
it('wildcard: "context7_*" does not match "codecontext_overview"', () => {
expect(matchToolGlob('codecontext_overview', ['context7_*'])).toBe(false);
});
it('wildcard: "view_*" matches "view_file" and "view_truncated_output"', () => {
expect(matchToolGlob('view_file', ['view_*'])).toBe(true);
expect(matchToolGlob('view_truncated_output', ['view_*'])).toBe(true);
});
it('wildcard: "*" matches everything', () => {
expect(matchToolGlob('anything', ['*'])).toBe(true);
expect(matchToolGlob('context7_query-docs', ['*'])).toBe(true);
});
it('deny: "!web_*" excludes "web_search"', () => {
// With only a deny rule and no prior match, the tool is not matched
expect(matchToolGlob('web_search', ['!web_*'])).toBe(false);
});
it('last-match-wins: ["*", "!web_*"] excludes web tools, includes others', () => {
expect(matchToolGlob('web_search', ['*', '!web_*'])).toBe(false);
expect(matchToolGlob('web_fetch', ['*', '!web_*'])).toBe(false);
expect(matchToolGlob('grep', ['*', '!web_*'])).toBe(true);
expect(matchToolGlob('context7_query-docs', ['*', '!web_*'])).toBe(true);
});
it('last-match-wins: deny then re-allow', () => {
// ["!web_*", "web_search"] — deny all web, then re-allow web_search
expect(matchToolGlob('web_search', ['!web_*', 'web_search'])).toBe(true);
expect(matchToolGlob('web_fetch', ['!web_*', 'web_fetch'])).toBe(true);
});
it('empty patterns: nothing matches', () => {
expect(matchToolGlob('grep', [])).toBe(false);
expect(matchToolGlob('anything', [])).toBe(false);
});
it('no-glob fallback: exact-match only, same as pre-v1.15', () => {
const patterns = ['grep', 'view_file'];
expect(matchToolGlob('grep', patterns)).toBe(true);
expect(matchToolGlob('view_file', patterns)).toBe(true);
expect(matchToolGlob('find_files', patterns)).toBe(false);
expect(matchToolGlob('web_search', patterns)).toBe(false);
});
it('mixed glob and exact patterns', () => {
const patterns = ['grep', 'context7_*', '!context7_dangerous'];
expect(matchToolGlob('grep', patterns)).toBe(true);
expect(matchToolGlob('context7_query-docs', patterns)).toBe(true);
expect(matchToolGlob('context7_dangerous', patterns)).toBe(false);
expect(matchToolGlob('view_file', patterns)).toBe(false);
});
});

View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import { partsFromAssistantMessage, partsFromToolMessage } from '../inference/parts.js';
import type { ToolCall, ToolResult } from '../../types/api.js';
describe('partsFromAssistantMessage', () => {
it('emits one text part for content-only assistant', () => {
const parts = partsFromAssistantMessage({ content: 'hello world', tool_calls: null });
expect(parts).toHaveLength(1);
expect(parts[0]).toEqual({
sequence: 0,
kind: 'text',
payload: { text: 'hello world' },
});
});
it('emits one tool_call part for empty-content + single tool_call', () => {
const tc: ToolCall = { id: 'call_1', name: 'view_file', args: { path: 'src/a.ts' } };
const parts = partsFromAssistantMessage({ content: '', tool_calls: [tc] });
expect(parts).toHaveLength(1);
expect(parts[0]).toEqual({
sequence: 0,
kind: 'tool_call',
payload: { id: 'call_1', name: 'view_file', args: { path: 'src/a.ts' } },
});
});
it('emits text then tool_call parts in order when both present', () => {
const tc: ToolCall = { id: 'call_2', name: 'grep', args: { pattern: 'foo' } };
const parts = partsFromAssistantMessage({ content: 'let me search', tool_calls: [tc] });
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
[0, 'text'],
[1, 'tool_call'],
]);
});
it('preserves tool_call order with multiple calls', () => {
const calls: ToolCall[] = [
{ id: 'a', name: 'list_dir', args: { path: '.' } },
{ id: 'b', name: 'view_file', args: { path: 'x.ts' } },
{ id: 'c', name: 'grep', args: { pattern: 'y' } },
];
const parts = partsFromAssistantMessage({ content: '', tool_calls: calls });
expect(parts).toHaveLength(3);
expect(parts.map((p) => p.payload)).toEqual([
{ id: 'a', name: 'list_dir', args: { path: '.' } },
{ id: 'b', name: 'view_file', args: { path: 'x.ts' } },
{ id: 'c', name: 'grep', args: { pattern: 'y' } },
]);
expect(parts.map((p) => p.sequence)).toEqual([0, 1, 2]);
});
it('returns empty array for empty content + null tool_calls', () => {
expect(partsFromAssistantMessage({ content: '', tool_calls: null })).toEqual([]);
});
it('v1.13.1-C: reasoning lands at sequence 0 before text + tool_calls', () => {
const tc: ToolCall = { id: 'call_r', name: 'view_file', args: { path: 'x.ts' } };
const parts = partsFromAssistantMessage({
content: 'inspecting now',
tool_calls: [tc],
reasoning: 'user asked about x.ts; I should view it',
});
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
[0, 'reasoning'],
[1, 'text'],
[2, 'tool_call'],
]);
expect(parts[0]!.payload).toEqual({
text: 'user asked about x.ts; I should view it',
});
});
it('v1.13.1-C: reasoning + empty content + tool_calls preserves seq 0 reasoning', () => {
const tc: ToolCall = { id: 'call_r2', name: 'grep', args: { pattern: 'foo' } };
const parts = partsFromAssistantMessage({
content: '',
tool_calls: [tc],
reasoning: 'jumping straight to grep',
});
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
[0, 'reasoning'],
[1, 'tool_call'],
]);
});
});
describe('partsFromToolMessage', () => {
it('emits a single tool_result part at sequence 0', () => {
const tr: ToolResult = {
tool_call_id: 'call_1',
output: { contents: 'console.log(1)' },
truncated: false,
};
const parts = partsFromToolMessage({ tool_results: tr });
expect(parts).toHaveLength(1);
expect(parts[0]).toEqual({
sequence: 0,
kind: 'tool_result',
payload: {
tool_call_id: 'call_1',
output: { contents: 'console.log(1)' },
truncated: false,
},
});
});
it('includes error in payload when present', () => {
const tr: ToolResult = {
tool_call_id: 'call_2',
output: null,
truncated: false,
error: 'permission denied',
};
const parts = partsFromToolMessage({ tool_results: tr });
expect(parts[0]!.payload).toMatchObject({ error: 'permission denied' });
});
it('returns empty array when tool_results is null', () => {
expect(partsFromToolMessage({ tool_results: null })).toEqual([]);
});
});

View File

@@ -0,0 +1,93 @@
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
// list. Validates the primary-root path stays the source of truth and that
// extra roots are consulted when (and only when) the primary rejects.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { realpath } from 'node:fs/promises';
import { pathGuard, PathScopeError } from '../path_guard.js';
let tmp: string;
let projectRoot: string;
let altRoot: string;
let outsideDir: string;
beforeAll(async () => {
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-pg-')));
projectRoot = join(tmp, 'project');
altRoot = join(tmp, 'alt');
outsideDir = join(tmp, 'outside');
await mkdir(projectRoot, { recursive: true });
await mkdir(altRoot, { recursive: true });
await mkdir(outsideDir, { recursive: true });
await writeFile(join(projectRoot, 'inside.txt'), 'p');
await writeFile(join(altRoot, 'cross.txt'), 'a');
await writeFile(join(outsideDir, 'forbidden.txt'), 'x');
});
afterAll(async () => {
await rm(tmp, { recursive: true, force: true });
});
describe('pathGuard (v1.13.17 extraRoots)', () => {
it('accepts paths inside the primary projectRoot', async () => {
const real = await pathGuard(projectRoot, 'inside.txt');
expect(real).toBe(join(projectRoot, 'inside.txt'));
});
it('rejects paths outside the primary root when no extra roots given', async () => {
await expect(pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'))).rejects.toBeInstanceOf(
PathScopeError,
);
});
it('accepts cross-root paths when the matching extra root is provided', async () => {
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [altRoot]);
expect(real).toBe(join(altRoot, 'cross.txt'));
});
it('rejects cross-root paths even with extra roots when no root matches', async () => {
await expect(
pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'), [altRoot]),
).rejects.toBeInstanceOf(PathScopeError);
});
it('ignores empty-string extra roots silently', async () => {
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), ['', altRoot]);
expect(real).toBe(join(altRoot, 'cross.txt'));
});
it('error message contains the request_read_access hint when scope rejects', async () => {
try {
await pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'));
throw new Error('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(PathScopeError);
expect((err as Error).message).toContain('request_read_access');
}
});
it('still resolves symlinks before the scope check', async () => {
const linkPath = join(projectRoot, 'link-to-outside');
await symlink(join(outsideDir, 'forbidden.txt'), linkPath);
// Symlink target escapes both primary and the single extra root, so
// even though the surface path "looks" inside projectRoot, the real
// path resolves outside and the guard rejects.
await expect(pathGuard(projectRoot, linkPath, [altRoot])).rejects.toBeInstanceOf(
PathScopeError,
);
// But adding outsideDir as an extra root accepts (realpath inside it).
const real = await pathGuard(projectRoot, linkPath, [altRoot, outsideDir]);
expect(real).toBe(join(outsideDir, 'forbidden.txt'));
});
it('tries extra roots in order until one accepts', async () => {
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [
outsideDir, // rejects
altRoot, // accepts
]);
expect(real).toBe(join(altRoot, 'cross.txt'));
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
selectPruneTargets,
PROTECTED_TOKENS,
PRUNE_TRIGGER_TOKENS,
type PartForPrune,
} from '../inference/prune.js';
// Test fixture: build a tool_result part whose payload size yields a known
// token estimate (chars/4). The decision logic only cares about
// JSON.stringify(payload).length, so a string payload of `4n` chars
// produces exactly `n` tokens.
let seq = 0;
function part(tokens: number, createdAt: Date): PartForPrune {
seq += 1;
// JSON.stringify("xxx...") wraps in quotes (adds 2 chars), so subtract 2
// before multiplying. Math.ceil((len+2)/4) needs len ≈ 4*tokens - 2 so the
// total stringified length is 4*tokens. Approximate by padding 4 chars per
// token; the off-by-one from quotes is small and tests check totals, not
// exact per-part counts.
const text = 'x'.repeat(tokens * 4 - 2);
return { id: `p${seq}`, payload: text, created_at: createdAt };
}
const T_NOW = new Date('2026-05-22T12:00:00Z');
function ago(secondsBack: number): Date {
return new Date(T_NOW.getTime() - secondsBack * 1000);
}
describe('selectPruneTargets', () => {
beforeEach(() => {
seq = 0;
});
it('returns nothing when there are no parts', () => {
expect(selectPruneTargets([], null)).toEqual({ ids: [], freedTokens: 0 });
});
it('returns nothing when total tokens are under the protection window', () => {
const parts: PartForPrune[] = [
part(10_000, ago(10)),
part(10_000, ago(20)),
]; // 20k total, all protected
expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
});
it('returns nothing when candidate total is below the prune trigger', () => {
// Protection fills with ~40k newest, candidates only ~5k. Below 20k trigger.
const parts: PartForPrune[] = [
part(20_000, ago(10)),
part(20_000, ago(20)),
// Past protection; total ~5k won't trigger.
part(5_000, ago(30)),
];
const result = selectPruneTargets(parts, null);
expect(result.ids).toEqual([]);
expect(result.freedTokens).toBe(0);
});
it('hides candidates past protection when their total clears the trigger', () => {
// Newest 40k protected; older 30k cleanly above the 20k trigger.
const parts: PartForPrune[] = [
part(20_000, ago(10)),
part(20_000, ago(20)),
// Past protection, total ~30k freed.
part(15_000, ago(30)),
part(15_000, ago(40)),
];
const result = selectPruneTargets(parts, null);
expect(result.ids).toEqual(['p3', 'p4']);
expect(result.freedTokens).toBeGreaterThanOrEqual(PRUNE_TRIGGER_TOKENS);
});
it('stops at the compaction summary boundary', () => {
// Newest 30k protected (just under PROTECTED_TOKENS=40k); then 30k of
// older parts. Boundary sits at ago(35), so the ago(40) part is
// beyond it and gets skipped.
const parts: PartForPrune[] = [
part(15_000, ago(10)),
part(15_000, ago(20)),
part(15_000, ago(30)), // crosses protection threshold; candidate
part(15_000, ago(40)), // beyond summary boundary; skipped
];
const tailStart = ago(35);
const result = selectPruneTargets(parts, tailStart);
// ago(30) is the only candidate inside the window; 15k is below the
// 20k trigger so we expect no hides.
expect(result.ids).toEqual([]);
});
it('does not prune when only protected parts exist (no candidates)', () => {
// Exactly PROTECTED_TOKENS of newest parts; no older candidates.
const parts: PartForPrune[] = [part(PROTECTED_TOKENS, ago(10))];
expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
});
});

View File

@@ -6,7 +6,9 @@ import {
loadContainerGuidance,
getContainerGuidance,
buildSystemPrompt,
buildSystemPromptWithFingerprint,
_resetContainerGuidanceCacheForTests,
_resetPrefixObserverForTests,
} from '../system-prompt.js';
import type { Agent, Project, Session } from '../../types/api.js';
@@ -17,12 +19,14 @@ let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'system-prompt-test-'));
_resetContainerGuidanceCacheForTests();
_resetPrefixObserverForTests();
delete process.env['CONTAINER_GUIDANCE_FILE'];
});
afterEach(async () => {
delete process.env['CONTAINER_GUIDANCE_FILE'];
_resetContainerGuidanceCacheForTests();
_resetPrefixObserverForTests();
await rm(tmpDir, { recursive: true, force: true });
});
@@ -176,3 +180,75 @@ describe('buildSystemPrompt', () => {
expect(prompt).not.toContain('--- end container guidance ---');
});
});
// v1.13.8: byte-stability instrumentation surface.
describe('buildSystemPromptWithFingerprint (v1.13.8)', () => {
it('returns byte-identical prompts for two consecutive calls with the same inputs', async () => {
const path = join(tmpDir, 'BOOCHAT.md');
await writeFile(path, 'stable guidance', 'utf8');
process.env['CONTAINER_GUIDANCE_FILE'] = path;
const session = makeSession();
const project = makeProject({ path: '/tmp/stable-proj' });
const agent = makeAgent({ system_prompt: 'be terse' });
const first = await buildSystemPromptWithFingerprint(project, session, agent);
const second = await buildSystemPromptWithFingerprint(project, session, agent);
expect(first.prompt).toBe(second.prompt);
expect(first.fingerprint.prefix_hash).toBe(second.fingerprint.prefix_hash);
expect(first.fingerprint.prefix_length).toBe(second.fingerprint.prefix_length);
});
it('emits drift=null on the first call for a fresh session, then null again when nothing changes', async () => {
process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md');
const session = makeSession();
const project = makeProject({ path: '/tmp/stable-proj' });
const first = await buildSystemPromptWithFingerprint(project, session, null);
expect(first.drift).toBeNull();
const second = await buildSystemPromptWithFingerprint(project, session, null);
expect(second.drift).toBeNull();
expect(second.fingerprint.prefix_hash).toBe(first.fingerprint.prefix_hash);
});
it('emits drift with prev/new hashes and a changed_inputs entry when an input mutates', async () => {
// Two BOOCHAT.md contents with different mtimes → guidance cache picks
// up the change → fingerprint hash flips → drift fires.
const path = join(tmpDir, 'BOOCHAT.md');
await writeFile(path, 'first', 'utf8');
process.env['CONTAINER_GUIDANCE_FILE'] = path;
const session = makeSession();
const project = makeProject({ path: '/tmp/stable-proj' });
const first = await buildSystemPromptWithFingerprint(project, session, null);
expect(first.drift).toBeNull();
await writeFile(path, 'second — different content', 'utf8');
const later = new Date(Date.now() + 60_000);
await utimes(path, later, later);
const second = await buildSystemPromptWithFingerprint(project, session, null);
expect(second.drift).not.toBeNull();
expect(second.drift!.prev_hash).toBe(first.fingerprint.prefix_hash);
expect(second.drift!.new_hash).toBe(second.fingerprint.prefix_hash);
expect(second.drift!.prev_hash).not.toBe(second.drift!.new_hash);
expect(second.drift!.changed_inputs).toContain('mtime_boochat');
});
it('does not fire drift across distinct sessions even if their hashes differ', async () => {
process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md');
const sessionA = makeSession({ id: 'sess-A' });
const sessionB = makeSession({ id: 'sess-B', system_prompt: 'B-only override' });
const project = makeProject({ path: '/tmp/stable-proj' });
const a = await buildSystemPromptWithFingerprint(project, sessionA, null);
const b = await buildSystemPromptWithFingerprint(project, sessionB, null);
expect(a.drift).toBeNull();
expect(b.drift).toBeNull();
expect(a.fingerprint.prefix_hash).not.toBe(b.fingerprint.prefix_hash);
});
});

View File

@@ -0,0 +1,236 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import postgres from 'postgres';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// v1.13.10: integration tests for the tool_cost_stats view. Skipped unless
// DATABASE_URL is set so they don't break `pnpm test` on a fresh checkout.
// Run with:
// DATABASE_URL=postgres://boocode:<pw>@localhost:5500/boocode pnpm -C apps/server test
//
// Isolation: each test uses a unique tool_name suffix derived from a per-test
// counter. The view aggregates globally across all chats, so without unique
// tool names parallel test runs would interfere. Cleanup deletes by tool_name
// suffix in afterAll.
const DB_URL = process.env.DATABASE_URL;
const describeFn = DB_URL ? describe : describe.skip;
const TEST_RUN_ID = `v13_10_${Date.now()}`;
const tname = (suffix: string) => `${TEST_RUN_ID}_${suffix}`;
describeFn('tool_cost_stats view (v1.13.10)', () => {
let sql: ReturnType<typeof postgres>;
let projectId: string;
let sessionId: string;
let chatId: string;
beforeAll(async () => {
if (!DB_URL) return;
sql = postgres(DB_URL, { max: 2, idle_timeout: 5, connect_timeout: 5, onnotice: () => {} });
// Apply the schema before fixtures so the view exists. Idempotent via
// CREATE OR REPLACE VIEW + CREATE TABLE IF NOT EXISTS; safe to run on a
// pre-populated DB. Mirrors apps/server/src/db.ts:applySchema.
const here = fileURLToPath(import.meta.url);
const schemaPath = resolve(here, '../../../schema.sql');
const ddl = readFileSync(schemaPath, 'utf8');
await sql.unsafe(ddl);
// Fixture project + session + chat for all inserts in this file.
const proj = await sql<{ id: string }[]>`
INSERT INTO projects (name, path)
VALUES (${`tool_cost_stats_test_${TEST_RUN_ID}`}, ${`/tmp/${TEST_RUN_ID}`})
RETURNING id
`;
projectId = proj[0]!.id;
const sess = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model)
VALUES (${projectId}, ${'test'}, ${'test-model'})
RETURNING id
`;
sessionId = sess[0]!.id;
const chat = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name) VALUES (${sessionId}, ${'test'}) RETURNING id
`;
chatId = chat[0]!.id;
});
afterAll(async () => {
if (!DB_URL) return;
// Project FK CASCADE cleans sessions/chats/messages/parts in one shot.
await sql`DELETE FROM projects WHERE id = ${projectId}`;
await sql.end({ timeout: 5 });
});
async function insertAssistantTurn(opts: {
toolNames: string[];
tokensUsed: number | null;
ctxUsed: number | null;
status?: 'streaming' | 'complete' | 'failed' | 'cancelled';
metadata?: { kind: string } | null;
createdAt?: Date;
}): Promise<string> {
const toolCalls = opts.toolNames.map((name, i) => ({
id: `call_${TEST_RUN_ID}_${name}_${i}`,
name,
args: {},
}));
const created = opts.createdAt ?? new Date();
// v1.13.20: parts-only. messages.tool_calls column was dropped; the
// tool_cost_stats view reads through messages_with_parts which derives
// tool_calls from message_parts rows.
const rows = await sql<{ id: string }[]>`
INSERT INTO messages (
session_id, chat_id, role, content, kind, status,
tokens_used, ctx_used,
metadata, created_at
)
VALUES (
${sessionId}, ${chatId}, 'assistant', '', 'message',
${opts.status ?? 'complete'},
${opts.tokensUsed},
${opts.ctxUsed},
${opts.metadata ? sql.json(opts.metadata as never) : null},
${created}
)
RETURNING id
`;
const messageId = rows[0]!.id;
for (let i = 0; i < toolCalls.length; i++) {
await sql`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${messageId}, ${i}, 'tool_call', ${sql.json(toolCalls[i] as never)})
`;
}
return messageId;
}
it('returns empty when no tool calls exist for a tool name', async () => {
const t = tname('absent');
const stats = await sql<{ tool_name: string }[]>`
SELECT * FROM tool_cost_stats WHERE tool_name = ${t}
`;
expect(stats).toEqual([]);
});
it('attributes single-tool turn fully to that tool', async () => {
const t = tname('single');
await insertAssistantTurn({ toolNames: [t], tokensUsed: 300, ctxUsed: 15000 });
const stats = await sql<{
tool_name: string;
prompt_tokens_sum: number;
completion_tokens_sum: number;
n_calls: number;
}[]>`SELECT * FROM tool_cost_stats WHERE tool_name = ${t}`;
expect(stats[0]).toMatchObject({
tool_name: t,
prompt_tokens_sum: 15000,
completion_tokens_sum: 300,
n_calls: 1,
});
});
it('splits multi-tool turn equally across tools', async () => {
const a = tname('multi_a');
const b = tname('multi_b');
const c = tname('multi_c');
// 3 tools, 300 completion / 15000 prompt → each gets 100 / 5000
await insertAssistantTurn({ toolNames: [a, b, c], tokensUsed: 300, ctxUsed: 15000 });
const stats = await sql<{
tool_name: string;
prompt_tokens_sum: number;
completion_tokens_sum: number;
n_calls: number;
}[]>`
SELECT * FROM tool_cost_stats
WHERE tool_name IN (${a}, ${b}, ${c})
ORDER BY tool_name
`;
expect(stats).toHaveLength(3);
for (const s of stats) {
expect(s.completion_tokens_sum).toBe(100);
expect(s.prompt_tokens_sum).toBe(5000);
expect(s.n_calls).toBe(1);
}
});
it('limits to last 100 calls per tool (FIFO window)', async () => {
const t = tname('window');
// Insert 110 turns with monotonically-increasing created_at and tokensUsed.
// Expect view to keep only the most recent 100.
const base = Date.now() + 1_000_000; // distant future to avoid colliding with other tests
for (let i = 1; i <= 110; i++) {
await insertAssistantTurn({
toolNames: [t],
tokensUsed: i, // 1..110
ctxUsed: i * 10,
createdAt: new Date(base + i),
});
}
const [stat] = await sql<{
n_calls: number;
completion_tokens_sum: number;
}[]>`SELECT n_calls, completion_tokens_sum FROM tool_cost_stats WHERE tool_name = ${t}`;
expect(stat!.n_calls).toBe(100);
// Last 100 are tokensUsed=11..110, sum = (11+110)*100/2 = 6050.
expect(stat!.completion_tokens_sum).toBe(6050);
});
it('excludes turns with NULL tokens_used (pre-v1.13.7 latent regression)', async () => {
const t = tname('null_tokens');
await insertAssistantTurn({ toolNames: [t], tokensUsed: null, ctxUsed: 1000 });
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: null });
const stats = await sql`SELECT * FROM tool_cost_stats WHERE tool_name = ${t}`;
expect(stats).toEqual([]);
});
it('excludes failed/cancelled turns and cap_hit/doom_loop sentinel rows', async () => {
const t = tname('filtered');
// A: status='failed' — excluded
// B: status='cancelled' — excluded
// C: status='complete', metadata={kind:'cap_hit'} — excluded
// D: status='complete', metadata={kind:'doom_loop'} — excluded
// E: status='complete', metadata=null — included
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, status: 'failed' });
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, status: 'cancelled' });
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, metadata: { kind: 'cap_hit' } });
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, metadata: { kind: 'doom_loop' } });
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, metadata: null });
const [stat] = await sql<{ n_calls: number }[]>`
SELECT n_calls FROM tool_cost_stats WHERE tool_name = ${t}
`;
expect(stat!.n_calls).toBe(1);
});
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
const t = tname('parts');
// v1.13.20: post-column-drop the only source for tool_calls is
// message_parts. This test asserts the same path the view always took
// (parts-derived), now that the legacy column COALESCE fallback is gone.
const rows = await sql<{ id: string }[]>`
INSERT INTO messages (
session_id, chat_id, role, content, kind, status,
tokens_used, ctx_used
)
VALUES (
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
200, 5000
)
RETURNING id
`;
const messageId = rows[0]!.id;
await sql`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (
${messageId}, 0, 'tool_call',
${sql.json({ id: `tc_parts_${TEST_RUN_ID}`, name: t, args: {} } as never)}
)
`;
const [stat] = await sql<{ n_calls: number }[]>`
SELECT n_calls FROM tool_cost_stats WHERE tool_name = ${t}
`;
expect(stat!.n_calls).toBe(1);
});
});

View File

@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import {
ALL_TOOLS,
CORE_TOOL_NAMES,
STANDARD_TOOL_NAMES,
TOOLS_BY_NAME,
resolveToolTier,
} from '../tools.js';
describe('ALL_TOOLS registry', () => {
// v1.13.3: tools must be alpha-sorted at module load. llama.cpp's prompt
// cache hits on byte-identical prefixes; the tool list lives near the
// top of the system prompt, so any order drift invalidates every cached
// turn. The registry sort is the single source of truth; downstream
// helpers (toolJsonSchemas, TOOLS_BY_NAME, buildAiTools) inherit it.
it('exports tools in alphabetical order by name', () => {
const names = ALL_TOOLS.map((t) => t.name);
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
});
});
describe('resolveToolTier (v1.13.15-tools)', () => {
it('returns CORE tools for tier=core', () => {
expect(resolveToolTier('core')).toEqual(CORE_TOOL_NAMES);
});
it('returns STANDARD tools for tier=standard', () => {
const result = resolveToolTier('standard');
expect(result.length).toBe(STANDARD_TOOL_NAMES.length);
expect(result.length).toBeGreaterThan(CORE_TOOL_NAMES.length);
// STANDARD is a strict superset of CORE.
expect(result).toEqual(expect.arrayContaining([...CORE_TOOL_NAMES]));
});
it('returns ALL tool names for tier=all', () => {
expect(resolveToolTier('all').length).toBe(ALL_TOOLS.length);
});
it('defaults to all when env var is undefined', () => {
expect(resolveToolTier(undefined).length).toBe(ALL_TOOLS.length);
});
it('is case-insensitive', () => {
expect(resolveToolTier('CORE')).toEqual(CORE_TOOL_NAMES);
expect(resolveToolTier('Standard').length).toBe(STANDARD_TOOL_NAMES.length);
});
it('falls back to all for unknown tier strings', () => {
expect(resolveToolTier('bogus').length).toBe(ALL_TOOLS.length);
});
});
describe('CORE_TOOL_NAMES + STANDARD_TOOL_NAMES validation', () => {
// The module-load validation in tools.ts throws if a tier references a
// tool that doesn't exist in TOOLS_BY_NAME. These tests double-check that
// invariant from the consumer side so a future tier-list edit can't smuggle
// in a typo without a test failure.
it('every CORE name exists in TOOLS_BY_NAME', () => {
for (const name of CORE_TOOL_NAMES) {
expect(TOOLS_BY_NAME[name], `CORE references unknown tool '${name}'`).toBeDefined();
}
});
it('every STANDARD name exists in TOOLS_BY_NAME', () => {
for (const name of STANDARD_TOOL_NAMES) {
expect(TOOLS_BY_NAME[name], `STANDARD references unknown tool '${name}'`).toBeDefined();
}
});
it('CORE is a subset of STANDARD', () => {
const standardSet = new Set<string>(STANDARD_TOOL_NAMES);
for (const name of CORE_TOOL_NAMES) {
expect(standardSet.has(name), `'${name}' is in CORE but not STANDARD`).toBe(true);
}
});
});

View File

@@ -0,0 +1,104 @@
// v1.13.5: truncate.ts unit coverage. Each test isolates TRUNCATION_DIR
// under os.tmpdir() so concurrent vitest runs don't collide and the suite
// stays self-cleaning. cleanupTruncations is covered by file-system half
// only; the orphan-reap branch needs a real Postgres and is tested via the
// smoke flow rather than vitest.
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Set the env var BEFORE importing the module so its module-load constant
// reads the test directory rather than /tmp/boocode-truncations.
const testDir = path.join(os.tmpdir(), `boocode-truncate-test-${process.pid}-${Date.now()}`);
process.env.BOOCODE_TRUNCATION_DIR = testDir;
const mod = await import('../truncate.js');
const { storeTruncation, readTruncation, truncateIfNeeded, MAX_TRUNCATION_BYTES } = mod;
beforeAll(async () => {
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
// Drop every file between tests so id-collision asserts and orphan-style
// counts start from zero.
const entries = await fs.readdir(testDir).catch(() => [] as string[]);
await Promise.all(entries.map((n) => fs.unlink(path.join(testDir, n)).catch(() => {})));
});
describe('storeTruncation / readTruncation roundtrip', () => {
it('writes and reads identical content', async () => {
const original = 'hello\nworld\n' + 'x'.repeat(500);
const id = await storeTruncation(original);
expect(id).toMatch(/^tr_[0-9a-v]{12}$/);
const got = await readTruncation(id);
expect(got).toBe(original);
});
it('readTruncation returns null for unknown ids', async () => {
const got = await readTruncation('tr_000000000000');
expect(got).toBeNull();
});
it('readTruncation rejects malformed ids (returns null, never escapes dir)', async () => {
// Path traversal attempt; readTruncation should not even try to open.
const got = await readTruncation('../../etc/passwd');
expect(got).toBeNull();
});
});
describe('truncateIfNeeded', () => {
it('returns sliced content with no outputPath when wasTruncated=false', async () => {
const out = await truncateIfNeeded({
fullContent: 'irrelevant',
slicedContent: 'visible',
wasTruncated: false,
});
expect(out).toEqual({ content: 'visible', truncated: false });
expect('outputPath' in out).toBe(false);
});
it('stashes full content and returns outputPath when wasTruncated=true', async () => {
const full = 'line1\nline2\nline3\nline4\n';
const sliced = 'line1\nline2\n[truncated]';
const out = await truncateIfNeeded({
fullContent: full,
slicedContent: sliced,
wasTruncated: true,
});
expect(out.content).toBe(sliced);
expect(out.truncated).toBe(true);
expect(out.outputPath).toMatch(/^tr_[0-9a-v]{12}$/);
const stashed = await readTruncation(out.outputPath!);
expect(stashed).toBe(full);
});
it('skips storage but still reports truncated when fullContent exceeds the cap', async () => {
// Build content larger than MAX_TRUNCATION_BYTES. Use a Buffer to size
// it without holding a literal that triggers the gigantic-string lint.
const oversized = Buffer.alloc(MAX_TRUNCATION_BYTES + 1, 'x').toString('utf8');
const sliced = 'preview...';
const out = await truncateIfNeeded({
fullContent: oversized,
slicedContent: sliced,
wasTruncated: true,
});
expect(out).toEqual({ content: sliced, truncated: true });
expect('outputPath' in out).toBe(false);
});
it('storage failure surfaces as truncated without outputPath', async () => {
// Force writeFile to throw. Spy at the fs module level since truncate.ts
// imports { promises as fs } and storeTruncation calls fs.writeFile.
const spy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('disk full'));
const out = await truncateIfNeeded({
fullContent: 'short',
slicedContent: 'sliced',
wasTruncated: true,
});
expect(out).toEqual({ content: 'sliced', truncated: true });
expect('outputPath' in out).toBe(false);
spy.mockRestore();
});
});

View File

@@ -295,9 +295,10 @@ describe('executeWebFetch — size + truncation', () => {
// 1.5M U+1F600 emojis: each is length 2 in UTF-16 (surrogate pair) and
// 4 bytes in UTF-8. body.length = 3,000,000 chars (~2.86 MiB by
// UTF-16 count) but Buffer.byteLength = 6,000,000 bytes (>5 MiB).
// Pre-fix the char-count comparison let this through; the byte-count
// check now rejects. No Content-Length header so the pre-flight
// guard doesn't fire — we're testing the POST-consumption check.
// v1.11.10: streaming reader catches this as body_too_large (was
// response_too_large in the post-consumption check). No
// Content-Length header so the pre-flight pass and the streaming
// path is the one that rejects.
const heavy = '😀'.repeat(1_500_000);
const fakeFetch = vi.fn().mockResolvedValue(
new Response(heavy, { status: 200, headers: { 'content-type': 'text/plain' } }),
@@ -308,9 +309,8 @@ describe('executeWebFetch — size + truncation', () => {
);
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.error).toBe('response_too_large');
// Error reason should reference bytes, not character count.
expect(result.reason).toMatch(/bytes/);
expect(result.error).toBe('body_too_large');
expect(result.reason).toMatch(/exceeded/);
}
});
@@ -453,3 +453,138 @@ describe('executeWebFetch — redirect handling', () => {
expect(fakeFetch.mock.calls[1]![0]).toBe('https://example.com/foo');
});
});
// ============================================================================
// v1.11.10: streaming body cap — abort the response stream at MAX_BYTES
// ============================================================================
// MAX_BYTES is 5 * 1024 * 1024 = 5_242_880. Repeating this here (rather
// than importing) so a change to the cap surfaces as a test failure —
// the limit is part of the public contract.
const MAX_BYTES_TEST = 5 * 1024 * 1024;
// Build a Response whose body is a real ReadableStream. Uses pull() (not
// start()) so chunks are produced lazily — without backpressure, an
// unbounded start() enqueues everything and calls controller.close()
// before the consumer reads, which means a subsequent reader.cancel()
// finds the stream already closed and the cancel callback never fires.
// `cancelFlag` lets the test observe whether reader.cancel() reached the
// underlying source mid-stream.
function streamedResponse(
chunks: Uint8Array[],
init: { contentType?: string; contentLength?: number | null; cancelFlag?: { cancelled: boolean } } = {},
): Response {
let idx = 0;
const stream = new ReadableStream({
pull(controller) {
if (idx >= chunks.length) {
controller.close();
return;
}
controller.enqueue(chunks[idx]!);
idx += 1;
},
cancel() {
if (init.cancelFlag) init.cancelFlag.cancelled = true;
},
});
const headers: Record<string, string> = {};
if (init.contentType) headers['content-type'] = init.contentType;
if (init.contentLength !== undefined && init.contentLength !== null) {
headers['content-length'] = String(init.contentLength);
}
return new Response(stream, { status: 200, headers });
}
describe('executeWebFetch — streaming body cap (v1.11.10)', () => {
it('aborts the stream when a server lies about Content-Length and emits over the cap', async () => {
// Honest header would have failed the pre-flight check. The lie is
// the point: pre-flight passes (100 < 5MB) and the streaming reader
// has to be the thing that catches the oversized body.
//
// Chunk count is deliberately higher than what the reader will
// consume (10 × 1MB available, but the reader will cancel after ~6
// chunks land it over 5MB). That headroom keeps the stream in
// 'readable' state at the moment reader.cancel() runs — otherwise
// a pull-then-close race could make the source close the stream
// before cancel reaches it, and the cancel() callback wouldn't fire.
const oneMB = new Uint8Array(1024 * 1024).fill(65); // 'A'
const tenMBInChunks = Array.from({ length: 10 }, () => oneMB);
const cancelFlag = { cancelled: false };
const fakeFetch = vi.fn().mockResolvedValue(
streamedResponse(tenMBInChunks, {
contentType: 'text/plain',
contentLength: 100,
cancelFlag,
}),
);
const result = await executeWebFetch(
{ url: 'https://example.com/lying-server' },
fakeFetch as unknown as typeof fetch,
);
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.error).toBe('body_too_large');
expect(result.reason).toMatch(/exceeded/);
}
// Critical: reader.cancel() actually fired so the underlying
// connection / stream got released. Otherwise the abort would be
// notional and the server could keep streaming.
expect(cancelFlag.cancelled).toBe(true);
});
it('catches an oversized stream when Content-Length is omitted entirely', async () => {
// Many real servers (chunked transfer-encoding, dynamic responses)
// never send Content-Length. The pre-flight check has nothing to
// gate on; the streaming reader is the only line of defense.
// 10 chunks vs the ~6 the reader will consume — same headroom
// rationale as the lying-Content-Length test above.
const oneMB = new Uint8Array(1024 * 1024).fill(66); // 'B'
const tenMBInChunks = Array.from({ length: 10 }, () => oneMB);
const fakeFetch = vi.fn().mockResolvedValue(
streamedResponse(tenMBInChunks, { contentType: 'text/plain' }),
);
const result = await executeWebFetch(
{ url: 'https://example.com/no-length' },
fakeFetch as unknown as typeof fetch,
);
expect('error' in result && result.error).toBe('body_too_large');
});
it('passes a multi-chunk body that totals just under the cap', async () => {
// Boundary case: MAX_BYTES - 1 bytes split across N chunks. The
// streaming reader's `total > maxBytes` check is strict-greater so
// exactly MAX_BYTES would still succeed; MAX_BYTES + 1 would fail.
// - 1 leaves clear headroom without coinciding with the boundary.
const targetTotal = MAX_BYTES_TEST - 1;
const chunkSize = 256 * 1024; // 256 KiB chunks
const chunks: Uint8Array[] = [];
let remaining = targetTotal;
while (remaining > 0) {
const size = Math.min(chunkSize, remaining);
chunks.push(new Uint8Array(size).fill(67)); // 'C'
remaining -= size;
}
const fakeFetch = vi.fn().mockResolvedValue(
streamedResponse(chunks, { contentType: 'text/plain' }),
);
const result = await executeWebFetch(
{ url: 'https://example.com/right-at-cap' },
fakeFetch as unknown as typeof fetch,
);
// The streaming reader succeeded — we got a content shape, not an
// error. (Downstream truncate() will clamp the final string to
// MAX_CHARS_CAP=32000 and set truncated:true; that's the existing
// truncation logic and is exercised by its own test. The point of
// THIS test is that readBodyCapped didn't trip on a body that
// sits just under its byte limit.)
expect('content' in result).toBe(true);
if ('content' in result) {
expect(result.content.length).toBeGreaterThan(0);
// All ASCII 'C's, so the leading 200 chars before any truncation
// marker should be all C — proves we read real bytes through the
// streaming reader rather than getting an empty buffer.
expect(result.content.slice(0, 200)).toBe('C'.repeat(200));
}
});
});

View File

@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
WsFrameSchema,
KNOWN_FRAME_TYPES,
type WsFrame,
} from '../../types/ws-frames.js';
import { createBroker } from '../broker.js';
const VALID_UUID_A = '00000000-0000-0000-0000-000000000001';
const VALID_UUID_B = '00000000-0000-0000-0000-000000000002';
const VALID_UUID_C = '00000000-0000-0000-0000-000000000003';
const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z';
describe('WsFrameSchema (v1.13.11-a)', () => {
it('accepts a well-formed chat_status frame', () => {
const result = WsFrameSchema.safeParse({
type: 'chat_status',
chat_id: VALID_UUID_A,
status: 'streaming',
at: VALID_TIMESTAMP,
});
expect(result.success).toBe(true);
});
it('rejects an unknown frame type', () => {
const result = WsFrameSchema.safeParse({
type: 'cosmic_ray_strike',
chat_id: VALID_UUID_A,
});
expect(result.success).toBe(false);
});
it('rejects a chat_status frame with invalid status enum', () => {
// v1.12.1 dropped the legacy 'working' status. Any frame still emitting it
// should fail validation — that's a drift catcher.
const result = WsFrameSchema.safeParse({
type: 'chat_status',
chat_id: VALID_UUID_A,
status: 'working',
at: VALID_TIMESTAMP,
});
expect(result.success).toBe(false);
});
it('rejects a UUID field with a non-UUID string', () => {
const result = WsFrameSchema.safeParse({
type: 'chat_status',
chat_id: 'not-a-uuid',
status: 'idle',
at: VALID_TIMESTAMP,
});
expect(result.success).toBe(false);
});
it('rejects negative token counts in usage frame', () => {
const result = WsFrameSchema.safeParse({
type: 'usage',
message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
completion_tokens: -1,
ctx_used: 100,
ctx_max: 1000,
});
expect(result.success).toBe(false);
});
it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => {
const result = WsFrameSchema.safeParse({
type: 'usage',
message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
completion_tokens: null,
ctx_used: null,
ctx_max: null,
});
expect(result.success).toBe(true);
});
it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => {
// Model-emitted tool_call_ids look like "call_abc123", not UUIDs.
const result = WsFrameSchema.safeParse({
type: 'tool_result',
tool_message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
tool_call_id: 'call_abc123',
output: { whatever: true },
truncated: false,
});
expect(result.success).toBe(true);
});
it('accepts a compacted frame', () => {
const result = WsFrameSchema.safeParse({
type: 'compacted',
session_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
summary_message_id: VALID_UUID_C,
});
expect(result.success).toBe(true);
});
it('accepts a session_workspace_updated frame', () => {
const result = WsFrameSchema.safeParse({
type: 'session_workspace_updated',
session_id: VALID_UUID_A,
workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }],
});
expect(result.success).toBe(true);
});
it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => {
// Probe each known type by attempting a minimal valid construction.
// Failure here means the union and the KNOWN_FRAME_TYPES list drifted.
for (const type of KNOWN_FRAME_TYPES) {
const probe = WsFrameSchema.safeParse({ type, __dummy__: true });
// We expect FAILURE on every type because we're missing required fields,
// but the failure must be ABOUT the missing fields, not about an unknown
// type. A "Invalid discriminator value" error means the type isn't in
// the union — that's a drift.
if (probe.success) continue;
const issues = probe.error.issues;
const hasInvalidDiscriminator = issues.some(
(i) => i.code === 'invalid_union_discriminator',
);
expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false);
}
});
});
describe('ws-frames.ts file mirror parity', () => {
it('apps/server and apps/web copies are byte-identical', () => {
const here = fileURLToPath(import.meta.url);
const serverPath = resolve(here, '../../../types/ws-frames.ts');
const webPath = resolve(here, '../../../../../web/src/api/ws-frames.ts');
const serverContent = readFileSync(serverPath, 'utf8');
const webContent = readFileSync(webPath, 'utf8');
expect(webContent, 'apps/web/src/api/ws-frames.ts must be byte-identical to apps/server/src/types/ws-frames.ts').toBe(serverContent);
});
});
describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => {
let logErrors: Array<{ obj: unknown; msg: string }>;
let mockLog: Parameters<typeof createBroker>[0];
beforeEach(() => {
logErrors = [];
mockLog = {
error: (obj: unknown, msg: string) => {
logErrors.push({ obj, msg });
},
info: () => {},
warn: () => {},
debug: () => {},
trace: () => {},
fatal: () => {},
child: () => mockLog as never,
level: 'info',
silent: () => {},
} as unknown as Parameters<typeof createBroker>[0];
});
afterEach(() => {
vi.restoreAllMocks();
});
it('publishFrame delivers a valid frame to subscribers', () => {
const broker = createBroker(mockLog);
const received: WsFrame[] = [];
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
broker.publishFrame('sess-1', {
type: 'delta',
message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
content: 'hello',
});
expect(received).toHaveLength(1);
expect((received[0] as { type: string }).type).toBe('delta');
expect(logErrors).toHaveLength(0);
});
it('publishFrame drops + logs an invalid frame instead of delivering it', () => {
const broker = createBroker(mockLog);
const received: WsFrame[] = [];
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
broker.publishFrame('sess-1', {
type: 'delta',
message_id: 'not-a-uuid',
content: 'hello',
} as never);
expect(received).toHaveLength(0);
expect(logErrors).toHaveLength(1);
expect(logErrors[0]!.msg).toMatch(/ws-frame-validation-failed/);
});
it('publishUserFrame drops + logs an invalid user-channel frame', () => {
const broker = createBroker(mockLog);
const received: WsFrame[] = [];
broker.subscribeUser('default', (f) => received.push(f as WsFrame));
broker.publishUserFrame('default', {
type: 'chat_status',
chat_id: VALID_UUID_A,
status: 'working', // v1.12.1 dropped this enum value
at: VALID_TIMESTAMP,
} as never);
expect(received).toHaveLength(0);
expect(logErrors).toHaveLength(1);
});
it('publishFrame validation failure does not throw (no cascade into stream-phase)', () => {
const broker = createBroker(mockLog);
expect(() =>
broker.publishFrame('sess-1', { type: 'unknown_type' } as never),
).not.toThrow();
});
});

View File

@@ -0,0 +1,357 @@
// v1.13.16: covers the Qwen/Hermes <tool_call> parser, the new Anthropic
// <invoke> parser, the partial-opener detector for both flavors, the unified
// extraction helper, and the unknown-tool error formatter that downstream
// dispatch uses to give the model a recovery hint when it drifts to a
// Claude Code tool name like read_file instead of BooCode's view_file.
import { describe, expect, it } from 'vitest';
import {
parseXmlToolCall,
parseInvokeToolCall,
partialXmlOpenerStart,
extractToolCallBlocks,
XML_TOOL_OPEN,
XML_TOOL_CLOSE,
INVOKE_TOOL_OPEN,
INVOKE_TOOL_CLOSE,
} from '../inference/xml-parser.js';
import {
levenshtein,
suggestToolName,
formatUnknownToolError,
} from '../inference/tool-suggestions.js';
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
it('parses a well-formed single-parameter call', () => {
const block = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
expect(parseXmlToolCall(block)).toEqual({
name: 'view_file',
args: { path: '/tmp/foo' },
});
});
it('parses multi-parameter call', () => {
const block = '<tool_call><function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function></tool_call>';
expect(parseXmlToolCall(block)).toEqual({
name: 'grep',
args: { pattern: 'foo', path: 'src/' },
});
});
it('JSON-parses numeric parameter values', () => {
const block = '<tool_call><function=foo><parameter=count>42</parameter></function></tool_call>';
expect(parseXmlToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
});
it('tolerates whitespace around = in function (v1.13.16 tightening)', () => {
const block = '<tool_call><function = view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
expect(parseXmlToolCall(block)).toEqual({
name: 'view_file',
args: { path: '/tmp/foo' },
});
});
it('tolerates whitespace around = in parameter (v1.13.16 tightening)', () => {
const block = '<tool_call><function=view_file><parameter = path>/tmp/foo</parameter></function></tool_call>';
expect(parseXmlToolCall(block)).toEqual({
name: 'view_file',
args: { path: '/tmp/foo' },
});
});
it('returns null when function name is missing', () => {
const block = '<tool_call><parameter=path>/tmp/foo</parameter></tool_call>';
expect(parseXmlToolCall(block)).toBeNull();
});
});
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
// Spec case 1
it('parses a well-formed single-parameter call (spec case 1)', () => {
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({
name: 'view_file',
args: { path: '/tmp/foo' },
});
});
// Spec case 2
it('parses a multi-parameter call (spec case 2)', () => {
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({
name: 'grep',
args: { pattern: 'foo', path: 'src/' },
});
});
// Spec case 3
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
const block = `<invoke
name="view_file"
>
<parameter
name="path"
>/tmp/foo</parameter>
</invoke>`;
expect(parseInvokeToolCall(block)).toEqual({
name: 'view_file',
args: { path: '/tmp/foo' },
});
});
// Spec case 4 (parser portion — the not-found enrichment is tested below)
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({
name: 'read_file',
args: { path: '/tmp/foo' },
});
});
it('supports single-quoted attribute values', () => {
const block = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
expect(parseInvokeToolCall(block)).toEqual({
name: 'view_file',
args: { path: '/tmp/foo' },
});
});
it('JSON-parses numeric parameter values', () => {
const block = '<invoke name="foo"><parameter name="count">42</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
});
it('tolerates spaces around = inside name attribute', () => {
const block = '<invoke name = "view_file"><parameter name = "path">/tmp/foo</parameter></invoke>';
expect(parseInvokeToolCall(block)).toEqual({
name: 'view_file',
args: { path: '/tmp/foo' },
});
});
it('returns null when name attribute is missing', () => {
const block = '<invoke><parameter name="path">/tmp/foo</parameter></invoke>';
expect(parseInvokeToolCall(block)).toBeNull();
});
it('returns null when name attribute is empty', () => {
const block = '<invoke name=""><parameter name="path">/tmp/foo</parameter></invoke>';
expect(parseInvokeToolCall(block)).toBeNull();
});
it('exports the expected delimiters', () => {
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
expect(XML_TOOL_OPEN).toBe('<tool_call>');
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
});
});
describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
it('returns -1 when the buffer is empty', () => {
expect(partialXmlOpenerStart('')).toBe(-1);
});
it('returns -1 when the buffer has no openers', () => {
expect(partialXmlOpenerStart('plain prose, no markup')).toBe(-1);
});
it('returns the index of a complete <tool_call> opener (existing)', () => {
expect(partialXmlOpenerStart('prose <tool_call>more')).toBe(6);
});
it('returns the index of a complete <invoke opener (v1.13.16)', () => {
expect(partialXmlOpenerStart('prose <invoke name=')).toBe(6);
});
it('holds a partial <tool_ prefix at end of buffer', () => {
expect(partialXmlOpenerStart('text <tool_')).toBe(5);
});
it('holds a partial <invo prefix at end of buffer (v1.13.16)', () => {
expect(partialXmlOpenerStart('text <invo')).toBe(5);
});
it('holds a bare < at end of buffer', () => {
expect(partialXmlOpenerStart('text <')).toBe(5);
});
it('returns -1 when < is followed by non-opener text', () => {
expect(partialXmlOpenerStart('text <unknown>')).toBe(-1);
});
it('returns the earliest opener when both flavors are present', () => {
expect(partialXmlOpenerStart('xxx <tool_call>YYY <invoke>')).toBe(4);
expect(partialXmlOpenerStart('xxx <invoke>YYY <tool_call>')).toBe(4);
});
});
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
// Spec case 1 (extraction-level)
it('extracts a single <invoke> block (spec case 1)', () => {
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
const result = extractToolCallBlocks(input);
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
expect(result.flushed).toBe('');
expect(result.remaining).toBe('');
});
// Spec case 5: opener arrives in one chunk, closer in the next.
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
const result = extractToolCallBlocks(firstChunk);
expect(result.calls).toEqual([]);
expect(result.flushed).toBe('');
expect(result.remaining).toBe(firstChunk);
});
it('extracts the block once the closer arrives in a later chunk (spec case 5, completion)', () => {
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
const r1 = extractToolCallBlocks(firstChunk);
const combined = r1.remaining + '</invoke>';
const r2 = extractToolCallBlocks(combined);
expect(r2.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
expect(r2.flushed).toBe('');
expect(r2.remaining).toBe('');
});
// Spec case 6: prose interleaving
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
const result = extractToolCallBlocks(input);
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
expect(result.flushed).toBe('I will read the file.\n\nThanks.');
expect(result.remaining).toBe('');
});
// Spec case 7 regression
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
const result = extractToolCallBlocks(input);
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
expect(result.flushed).toBe('');
expect(result.remaining).toBe('');
});
it('extracts mixed-format blocks in source order (hand-back: shared counter)', () => {
const input =
'<invoke name="view_file"><parameter name="path">/a</parameter></invoke>' +
' middle ' +
'<tool_call><function=grep><parameter=pattern>foo</parameter></function></tool_call>';
const result = extractToolCallBlocks(input);
expect(result.calls).toEqual([
{ name: 'view_file', args: { path: '/a' } },
{ name: 'grep', args: { pattern: 'foo' } },
]);
expect(result.flushed).toBe(' middle ');
expect(result.remaining).toBe('');
});
it('drops a malformed <invoke> block silently (matches existing <tool_call> behavior)', () => {
const input = 'prose <invoke><parameter name="path">/a</parameter></invoke> trailing';
const result = extractToolCallBlocks(input);
expect(result.calls).toEqual([]);
expect(result.flushed).toBe('prose trailing');
expect(result.remaining).toBe('');
});
it('holds a tail with a fresh partial opener after extracting earlier complete blocks', () => {
const input = '<invoke name="view_file"><parameter name="path">/a</parameter></invoke> next: <tool_';
const result = extractToolCallBlocks(input);
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/a' } }]);
expect(result.flushed).toBe(' next: ');
expect(result.remaining).toBe('<tool_');
});
it('passes plain prose straight through when no markup is present', () => {
const input = 'just some text with a < character but no opener';
const result = extractToolCallBlocks(input);
expect(result.calls).toEqual([]);
expect(result.flushed).toBe(input);
expect(result.remaining).toBe('');
});
});
describe('levenshtein', () => {
it('returns 0 for identical strings', () => {
expect(levenshtein('view_file', 'view_file')).toBe(0);
});
it('returns the length when one string is empty', () => {
expect(levenshtein('', 'view_file')).toBe(9);
expect(levenshtein('view_file', '')).toBe(9);
});
it('computes a small distance for a single-character substitution', () => {
expect(levenshtein('cat', 'bat')).toBe(1);
});
it('computes a known case: read_file → view_file is 4', () => {
// r→v, e→i, a→e, d→w → 4 substitutions, same length
expect(levenshtein('read_file', 'view_file')).toBe(4);
});
});
describe('suggestToolName (v1.13.16)', () => {
const tools = [
'view_file',
'list_dir',
'grep',
'find_files',
'view_truncated_output',
'ask_user_input',
'web_search',
];
it('suggests the closest match when distance is small', () => {
expect(suggestToolName('view_files', tools)).toBe('view_file');
});
it('suggests via substring match when distance alone would miss', () => {
// 'file' is a substring of multiple tools; closest by distance wins.
expect(suggestToolName('file', tools)).toBe('view_file');
});
it('returns null when nothing is close', () => {
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
});
it('is case-insensitive in the distance check', () => {
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
});
});
describe('formatUnknownToolError (v1.13.16)', () => {
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
it('includes the wrong name and the available tools list', () => {
const msg = formatUnknownToolError('read_file', tools);
expect(msg).toContain("Tool 'read_file' not found");
expect(msg).toContain('Available tools:');
expect(msg).toContain('view_file');
expect(msg).toContain('find_files');
});
it('includes a suggestion when the drifted name is within threshold', () => {
// distance(view_files, view_file) = 1 (one extra char)
const msg = formatUnknownToolError('view_files', tools);
expect(msg).toContain('Did you mean: view_file?');
});
it('omits the suggestion clause when no tool is close enough', () => {
const msg = formatUnknownToolError('zzzzzzz', tools);
expect(msg).toContain("Tool 'zzzzzzz' not found");
expect(msg).toContain('Available tools:');
expect(msg).not.toContain('Did you mean');
});
// The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the
// model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so
// the spec's threshold (<=3) doesn't suggest view_file — the model still
// gets the available-tools list to pick from. This pins that behavior so a
// future loosening of the threshold is a deliberate choice.
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
const msg = formatUnknownToolError('read_file', tools);
expect(msg).not.toContain('Did you mean');
});
});

View File

@@ -1,7 +1,7 @@
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
import { ALL_TOOLS } from './tools.js';
import { ALL_TOOLS, resolveToolTier } from './tools.js';
// v1.8.1: global agents live at /data/AGENTS.md inside the container
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
@@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000;
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
// codecontext tools were missing), silently filtering valid tool names out
// of agents that opted in. Single source of truth is tools.ts now.
const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
let DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
export function refreshToolNames(): void {
ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
DEFAULT_TOOLS = [...ALL_TOOL_NAMES];
}
const DEFAULT_TEMPERATURE = 0.7;
// ---- Tool glob matching (v1.15.0-mcp-multi) --------------------------------
/**
* Simple glob match for tool names. Supports `*` as a wildcard for any
* characters. No `?` or `**` — tool names are flat (no path separators).
*/
function simpleGlobMatch(str: string, pattern: string): boolean {
if (pattern === '*') return true;
if (!pattern.includes('*')) return str === pattern;
// Escape regex metacharacters, then replace escaped \* with .*
const regex = new RegExp(
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
);
return regex.test(str);
}
/**
* Check if a tool name matches a set of glob patterns. Last-match-wins.
* Patterns starting with `!` are deny rules.
*
* Examples:
* - `["grep", "view_file"]` — exact-match whitelist (same as pre-v1.15)
* - `["context7_*"]` — all tools from the context7 MCP server
* - `["*", "!web_*"]` — all tools except web tools
* - `[]` — nothing matches (agent gets no tools)
*/
export function matchToolGlob(toolName: string, patterns: string[]): boolean {
let matched = false;
for (const pattern of patterns) {
const deny = pattern.startsWith('!');
const glob = deny ? pattern.slice(1) : pattern;
if (simpleGlobMatch(toolName, glob)) {
matched = !deny;
}
}
return matched;
}
/**
* Returns true if a tools: entry is a glob pattern (contains * or starts
* with !). Glob patterns can't be validated against the current tool list
* since MCP tools are discovered at runtime.
*/
function isGlobPattern(entry: string): boolean {
return entry.includes('*') || entry.startsWith('!');
}
export function slugify(name: string): string {
return name
.toLowerCase()
@@ -37,6 +89,10 @@ interface ParsedFrontmatter {
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
// from the agent's toolset at runtime.
max_tool_calls?: number;
// v1.14.0: optional per-agent step cap. Absent → bounded only by MAX_STEPS
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
// allowed" — the model responds text-only.
steps?: number;
}
function stripQuotes(s: string): string {
@@ -112,6 +168,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
} else {
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
}
} else if (key === 'steps') {
// v1.14.0: per-agent step cap for the outer inference loop. Integer ≥ 0.
// steps: 0 means "no tool calls allowed" — model responds text-only.
// Non-integer or negative values are warned and ignored (falls back to
// MAX_STEPS ceiling), matching the max_tool_calls pattern above.
const n = Number(valueRaw);
if (Number.isInteger(n) && n >= 0) {
data.steps = n;
} else if (Number.isInteger(n)) {
console.warn(
`agents: steps ${n} is negative, ignoring (falling back to default)`,
);
} else {
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
}
}
// Unknown keys silently ignored — forward-compat.
}
@@ -186,11 +257,18 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
throw new Error(fmErrors.join('; '));
}
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
// Unset → resolveToolTier returns ALL tool names → no narrowing.
// v1.15.0-mcp-multi: glob patterns (entries containing * or starting with !)
// pass through unvalidated — MCP tools are discovered at runtime and can't
// be checked against ALL_TOOL_NAMES at parse time.
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
const filteredTools = Array.isArray(fm.tools)
? fm.tools.filter((t): t is string =>
(ALL_TOOL_NAMES as readonly string[]).includes(t),
isGlobPattern(t) ||
((ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t)),
)
: DEFAULT_TOOLS;
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
return {
id: slugify(section.name),
@@ -201,6 +279,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
tools: filteredTools,
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
steps: typeof fm.steps === 'number' ? fm.steps : null,
};
}
@@ -252,6 +331,22 @@ export function invalidateAgentsCache(projectPath?: string): void {
}
}
// v1.13.8: cache-read accessor for the system-prompt prefix-fingerprint log.
// Returns the AGENTS.md mtimes that getAgentsForProject() observed on its
// last cache fill for this projectPath. Both fields are null when the cache
// is cold (e.g. tests, fresh boot before the first inference turn). Does no
// I/O — a fresh stat would race the cache and isn't what the fingerprint
// wants anyway (we want what was actually used to resolve the agent).
export function getAgentsMtimes(projectPath: string): {
global: number | null;
project: number | null;
} {
const key = projectPath || '__none__';
const entry = cache.get(key);
if (!entry) return { global: null, project: null };
return { global: entry.globalMtime, project: entry.projectMtime };
}
async function safeStat(path: string): Promise<number | null> {
try {
const s = await fs.stat(path);

View File

@@ -0,0 +1,255 @@
// v1.14.x-html-artifact-panes: artifact writer + slug derivation.
//
// Writes Markdown and HTML artifacts to `<projectRoot>/.boocode/artifacts/`
// as plain files. Returns `{path, url}` where:
// - path is the absolute on-disk path
// - url is a project-scoped REST URL pointing at the GET download route
// registered in routes/artifacts.ts. The route streams the file with
// Content-Disposition: attachment.
//
// Path safety: we do NOT use path_guard.ts (it realpaths and throws ENOENT
// for files that don't exist yet, which artifact creation requires).
// Instead we mirror the v1.13.18 codecontext_client.ts pattern: resolve
// the candidate path against the realpath'd projectRoot, then verify the
// result starts with projectRoot + sep (or equals projectRoot).
import { mkdir, realpath, writeFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { PathScopeError } from './path_guard.js';
import type { Message } from '../types/api.js';
export interface HtmlArtifactPayload {
html_content: string;
char_count: number;
title: string | null;
}
export interface ArtifactWriteResult {
path: string;
url: string;
}
const ARTIFACT_SUBDIR = '.boocode/artifacts';
// ---- slug helpers ----
// Lowercase, replace non-alnum runs with '-', trim leading/trailing '-',
// collapse repeated '-', cap at 60 chars. Empty → 'artifact'.
function slugify(input: string): string {
const cleaned = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.slice(0, 60)
.replace(/^-+|-+$/g, '');
return cleaned || 'artifact';
}
function firstHeading(md: string): string | null {
// Match the first `# ` ATX heading at the start of a line.
const m = md.match(/^[ \t]*#[ \t]+(.+?)\s*$/m);
if (!m) return null;
const text = m[1]?.trim() ?? '';
return text.length > 0 ? text : null;
}
function firstNWords(s: string, n: number): string {
const words = s.trim().split(/\s+/).filter(Boolean).slice(0, n);
return words.join(' ');
}
export function deriveMarkdownSlug(messageContent: string): string {
const heading = firstHeading(messageContent);
if (heading) return slugify(heading);
const sixWords = firstNWords(messageContent, 6);
return slugify(sixWords);
}
// Strip HTML tags for inner-text extraction. Crude but sufficient for slug
// derivation — we're not rendering, just finding readable words.
function stripTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function extractTitleTag(html: string): string | null {
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!m) return null;
const text = stripTags(m[1] ?? '').trim();
return text.length > 0 ? text : null;
}
function extractH1(html: string): string | null {
const m = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (!m) return null;
const text = stripTags(m[1] ?? '').trim();
return text.length > 0 ? text : null;
}
export function deriveHtmlSlug(payload: {
html_content: string;
title: string | null;
}): string {
if (payload.title && payload.title.trim().length > 0) {
return slugify(payload.title);
}
const title = extractTitleTag(payload.html_content);
if (title) return slugify(title);
const h1 = extractH1(payload.html_content);
if (h1) return slugify(h1);
const inner = stripTags(payload.html_content);
return slugify(firstNWords(inner, 6));
}
// Derive title for the html_artifact part payload: <title> → first <h1> →
// first 80 chars of inner text. Returns null if nothing useful is found.
export function deriveHtmlTitle(html: string): string | null {
const t = extractTitleTag(html);
if (t) return t;
const h1 = extractH1(html);
if (h1) return h1;
const inner = stripTags(html);
if (inner.length === 0) return null;
return inner.slice(0, 80);
}
// ---- HTML detection (B4) ----
// Returns the inner HTML content if `text` is a recognised HTML artifact:
// - starts with <!DOCTYPE html> (case-insensitive, whitespace-trimmed), OR
// - wrapped entirely in a fenced ```html ... ``` block.
// Returns null if neither matches.
export function detectHtmlArtifact(text: string): string | null {
const trimmed = text.trim();
if (trimmed.length === 0) return null;
if (/^<!doctype\s+html/i.test(trimmed)) {
return trimmed;
}
// Fenced ```html block consuming the entire (trimmed) message. Allow an
// optional trailing newline before the closing fence.
const fence = trimmed.match(/^```html\s*\n([\s\S]*?)\n?```\s*$/i);
if (fence) {
const inner = fence[1] ?? '';
if (/^\s*<!doctype\s+html/i.test(inner) || /<html[\s>]/i.test(inner)) {
return inner.trim();
}
}
return null;
}
// ---- path resolution ----
// Resolve `<projectRoot>/.boocode/artifacts/<filename>` and verify the
// result stays under projectRoot. Mirrors the v1.13.18 codecontext_client.ts
// approach: realpath projectRoot first, then prefix-check the candidate.
// Throws on escape.
async function resolveArtifactPath(
projectRoot: string,
filename: string,
): Promise<{ resolvedRoot: string; artifactsDir: string; absPath: string }> {
const resolvedRoot = await realpath(projectRoot);
const artifactsDir = resolve(resolvedRoot, ARTIFACT_SUBDIR);
const absPath = resolve(artifactsDir, filename);
// Lexical prefix check on the resolved candidates. (The `!== resolvedRoot`
// branch was dead — ARTIFACT_SUBDIR is non-empty so artifactsDir always
// differs from resolvedRoot.)
if (!artifactsDir.startsWith(resolvedRoot + sep)) {
throw new PathScopeError(
`artifacts dir escapes project root: ${artifactsDir}`,
);
}
if (!absPath.startsWith(artifactsDir + sep)) {
throw new PathScopeError(
`artifact filename escapes artifacts dir: ${filename}`,
);
}
return { resolvedRoot, artifactsDir, absPath };
}
// After mkdir, realpath the artifacts dir and re-verify it stays under
// resolvedRoot. Closes the symlink-escape gap: if `.boocode/artifacts` (or
// any ancestor below resolvedRoot) is a symlink pointing outside the
// project, the lexical check in resolveArtifactPath passes but the actual
// write lands outside the sandbox. Throws PathScopeError on escape.
async function assertArtifactsDirSafe(
artifactsDir: string,
resolvedRoot: string,
): Promise<void> {
const realDir = await realpath(artifactsDir);
if (realDir !== resolvedRoot && !realDir.startsWith(resolvedRoot + sep)) {
throw new PathScopeError(
`artifacts dir resolves outside project root: ${realDir}`,
);
}
}
// Pure decision helper for whether finalizeCompletion should write the
// `html_artifact` part. Exported for unit testing the cap-skip branch.
// Returns `{write: true, byteLen}` when the payload is under the cap, or
// `{write: false, byteLen, reason: 'cap_exceeded'}` when oversize.
export type HtmlArtifactDecision =
| { write: true; byteLen: number }
| { write: false; byteLen: number; reason: 'cap_exceeded' };
export function decideHtmlArtifactWrite(
htmlContent: string,
): HtmlArtifactDecision {
const byteLen = Buffer.byteLength(htmlContent, 'utf8');
if (byteLen > HTML_ARTIFACT_MAX_BYTES) {
return { write: false, byteLen, reason: 'cap_exceeded' };
}
return { write: true, byteLen };
}
function buildUrl(projectId: string, filename: string): string {
return `/api/projects/${projectId}/artifacts/${encodeURIComponent(filename)}`;
}
export interface WriteContext {
projectId: string;
projectRoot: string;
}
export async function writeMarkdownArtifact(
message: Pick<Message, 'content'>,
ctx: WriteContext,
): Promise<ArtifactWriteResult> {
const slug = deriveMarkdownSlug(message.content);
const filename = `${slug}-${Date.now()}.md`;
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
ctx.projectRoot,
filename,
);
await mkdir(artifactsDir, { recursive: true });
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
await writeFile(absPath, message.content, 'utf8');
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
}
export async function writeHtmlArtifact(
payload: HtmlArtifactPayload,
ctx: WriteContext,
): Promise<ArtifactWriteResult> {
const slug = deriveHtmlSlug(payload);
const filename = `${slug}-${Date.now()}.html`;
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
ctx.projectRoot,
filename,
);
await mkdir(artifactsDir, { recursive: true });
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
await writeFile(absPath, payload.html_content, 'utf8');
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
}
// 1MB cap on HTML artifacts (proposal S6). Larger payloads are not written
// to the `html_artifact` part — the assistant text lands as plain content
// and a warning is logged. Streaming abort was considered but the graceful
// "no artifact, plain text falls back" path is simpler and lossless from
// the user's perspective.
export const HTML_ARTIFACT_MAX_BYTES = 1_048_576;

View File

@@ -1,7 +1,7 @@
import type { InferenceContext } from './inference.js';
import type { InferenceContext } from './inference/index.js';
const NAMING_SYSTEM_PROMPT =
'You name chat sessions. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
'You name chat sessions based on what the assistant did. Summarize the topic or outcome — do NOT copy the first few words verbatim. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
const MAX_TITLE_CHARS = 60;
@@ -70,12 +70,6 @@ export async function maybeAutoNameChat(
const model = sessionRows[0]?.model;
if (!model) return;
const userMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE chat_id = ${chatId} AND role = 'user'
ORDER BY created_at ASC
LIMIT 1
`;
const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE chat_id = ${chatId}
@@ -85,9 +79,8 @@ export async function maybeAutoNameChat(
ORDER BY created_at ASC
LIMIT 1
`;
if (!userMsg[0] || !assistantMsg[0]) return;
if (!assistantMsg[0]) return;
const userText = userMsg[0].content.slice(0, 2000);
const assistantText = assistantMsg[0].content.slice(0, 2000);
const body = {
@@ -96,7 +89,7 @@ export async function maybeAutoNameChat(
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
{
role: 'user',
content: `First user message: ${userText}\nFirst assistant reply: ${assistantText}`,
content: assistantText,
},
],
max_tokens: 30,

View File

@@ -1,3 +1,6 @@
import type { FastifyBaseLogger } from 'fastify';
import { WsFrameSchema, type WsFrame } from '../types/ws-frames.js';
export type Frame = Record<string, unknown> & { type: string };
export type Listener = (frame: Frame) => void;
@@ -6,9 +9,15 @@ export interface Broker {
subscribe(sessionId: string, listener: Listener): () => void;
publishUser(user: string, frame: Frame): void;
subscribeUser(user: string, listener: Listener): () => void;
// v1.13.11-a: typed publish wrappers. Validate against WsFrameSchema and
// delegate to publish / publishUser on success; log + drop on failure
// (fail-closed). Existing publish / publishUser callers stay legal — they
// get converted to the typed variant in v1.13.11-b.
publishFrame(sessionId: string, frame: WsFrame): void;
publishUserFrame(user: string, frame: WsFrame): void;
}
export function createBroker(): Broker {
export function createBroker(log?: FastifyBaseLogger): Broker {
const topics = new Map<string, Set<Listener>>();
const userTopics = new Map<string, Set<Listener>>();
@@ -39,6 +48,28 @@ export function createBroker(): Broker {
};
}
// v1.13.11-a: shared validation guard. Returns the parsed/typed frame on
// success, or null on failure (after logging). Brief mandates fail-closed
// semantics: invalid frames don't reach subscribers; throwing here could
// cascade into stream-phase aborts which v1.13.7 already had to defend
// against, so log + drop is the right shape.
function validate(channel: 'session' | 'user', key: string, frame: WsFrame): WsFrame | null {
const parsed = WsFrameSchema.safeParse(frame);
if (parsed.success) return parsed.data;
const frameType = (frame as { type?: unknown })?.type;
const errors = parsed.error.flatten();
if (log) {
log.error(
{ channel, key, frame_type: frameType, errors },
'ws-frame-validation-failed: dropping invalid frame',
);
} else {
// Fallback for callers that didn't pass a logger (e.g. unit tests).
console.error('ws-frame-validation-failed', { channel, key, frame_type: frameType, errors });
}
return null;
}
return {
publish(sessionId, frame) {
publishTo(topics, sessionId, frame);
@@ -52,5 +83,15 @@ export function createBroker(): Broker {
subscribeUser(user, listener) {
return subscribeTo(userTopics, user, listener);
},
publishFrame(sessionId, frame) {
const valid = validate('session', sessionId, frame);
if (!valid) return;
publishTo(topics, sessionId, valid as Frame);
},
publishUserFrame(user, frame) {
const valid = validate('user', user, frame);
if (!valid) return;
publishTo(userTopics, user, valid as Frame);
},
};
}

View File

@@ -16,7 +16,79 @@
// file parser bug (upstream issue #37) returns a generic error string,
// which we re-surface with a hint to add the file to .codecontextignore.
import { realpath } from 'node:fs/promises';
import { access, copyFile, realpath } from 'node:fs/promises';
import { isAbsolute, join, resolve, sep } from 'node:path';
import { truncateIfNeeded } from './truncate.js';
// v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37)
// when it can't ignore them. The .codecontextignore.template ships with the
// project at /opt/boocode/codecontext/.codecontextignore.template (path inside
// the container; the host's /opt is bind-mounted). On the first call to any
// project, copy the template in if no per-project ignore exists yet. The user
// can subsequently edit the file to customize. Idempotent — once any file is
// at the project root we never overwrite.
const IGNORE_TEMPLATE_PATH = '/opt/boocode/codecontext/.codecontextignore.template';
const ensuredIgnoreProjects = new Set<string>();
async function ensureIgnoreFile(projectRoot: string): Promise<void> {
if (ensuredIgnoreProjects.has(projectRoot)) return;
const ignorePath = join(projectRoot, '.codecontextignore');
try {
await access(ignorePath);
ensuredIgnoreProjects.add(projectRoot);
return;
} catch {
// missing — install the default
}
try {
await copyFile(IGNORE_TEMPLATE_PATH, ignorePath);
ensuredIgnoreProjects.add(projectRoot);
} catch {
// Template missing or project root read-only — proceed without it. The
// codecontext call may still crash on empty source files; the model gets
// the existing hint-message via the catch below telling it to add to
// .codecontextignore manually.
}
}
// v1.13.18: resolve a `file_path` arg to an absolute path anchored within
// the (already realpath'd) projectRoot. Contract:
// - empty/whitespace-only → INVALID_FILE_PATH error
// - relative path → resolve(projectRoot, rawPath) (normalises dot-segments)
// - absolute path → resolve(rawPath) (also normalises — e.g. /root/../etc
// becomes /etc so the prefix-check below rejects it even in the ENOENT
// fallthrough where realpath couldn't canonicalise)
// - try realpath; on ENOENT fall through with the (normalised) absolute
// (the sidecar issues its own "File not found in graph" that the model
// can self-correct on; re-implementing the check here would diverge)
// - if the final path doesn't sit inside projectRoot → escape error
// (same shape as target_dir escape, only the field name differs)
async function resolveProjectPath(
projectRoot: string,
rawPath: string,
): Promise<string> {
if (rawPath.trim() === '') {
throw new Error('INVALID_FILE_PATH: file_path must not be empty');
}
const candidate = isAbsolute(rawPath) ? resolve(rawPath) : resolve(projectRoot, rawPath);
let resolved: string;
try {
resolved = await realpath(candidate);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
// File doesn't exist yet (or was deleted). Forward the absolute path;
// codecontext will return "File not found in graph" which the model
// can self-correct on.
resolved = candidate;
} else {
throw err;
}
}
if (resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)) {
throw new Error(`file_path ${rawPath} escapes project root ${projectRoot}`);
}
return resolved;
}
export interface CodecontextRequest {
toolName: string;
@@ -27,6 +99,9 @@ export interface CodecontextRequest {
export interface CodecontextResponse {
result: string;
truncated: boolean;
// v1.13.5: optional opaque id pointing at the full pre-slice content on
// tmpfs. Set when truncated=true and storage succeeded.
outputPath?: string;
}
const CODECONTEXT_BASE_URL = process.env['CODECONTEXT_URL'] ?? 'http://codecontext:8080';
@@ -42,6 +117,10 @@ export async function callCodecontext(
// never pass target_dir; tests can override). A non-existent target_dir
// throws before we hit the network so the model gets a sharp error.
const resolvedProject = await realpath(req.projectPath);
// v1.13.12 fix: install the default .codecontextignore on first call to any
// project so codecontext doesn't crash on empty node_modules files. One file
// written per project, idempotent (set-membership check inside).
await ensureIgnoreFile(resolvedProject);
const requestedTarget = req.args['target_dir'];
const targetDir = typeof requestedTarget === 'string' && requestedTarget.length > 0
? requestedTarget
@@ -56,7 +135,14 @@ export async function callCodecontext(
// Step 2: re-build args with the resolved target_dir so codecontext sees
// the real absolute path, not a symlink or relative form.
const argsToSend = { ...req.args, target_dir: resolvedTarget };
// v1.13.18: also resolve file_path when present — the sidecar index is keyed
// on absolute paths, so a relative path from the model yields "File not found
// in graph". Same escape check as target_dir; ENOENT falls through so the
// sidecar produces the canonical "File not found in graph" the model can fix.
const argsToSend: Record<string, unknown> = { ...req.args, target_dir: resolvedTarget };
if (typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== '') {
argsToSend['file_path'] = await resolveProjectPath(resolvedProject, req.args['file_path']);
}
// Step 3: POST with a hard timeout. AbortController + setTimeout pattern
// matches web_fetch.ts; nothing fancier needed.
@@ -105,13 +191,22 @@ export async function callCodecontext(
// Step 4: inline truncation. The model gets a clear hint about how to
// narrow the next call rather than a silent cut. Mirrors web_fetch.ts.
// v1.13.5: stash the full body on tmpfs when truncating so the model can
// retrieve more via view_truncated_output(id).
if (body.result.length > TRUNCATION_LIMIT) {
const truncated = body.result.slice(0, TRUNCATION_LIMIT);
const omitted = body.result.length - TRUNCATION_LIMIT;
const slicedWithMarker =
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`;
const wrapped = await truncateIfNeeded({
fullContent: body.result,
slicedContent: slicedWithMarker,
wasTruncated: true,
});
return {
result:
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`,
truncated: true,
result: wrapped.content,
truncated: wrapped.truncated,
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
};
}
return { result: body.result, truncated: false };

View File

@@ -23,7 +23,13 @@ import type { Broker } from './broker.js';
import { SUMMARY_TEMPLATE } from './compaction-prompt.js';
import * as modelContextLookup from './model-context.js';
const COMPACTION_BUFFER = 20_000;
// v1.13.9: ratio-only overflow trigger. Fires compaction at 85% of ctx_max
// (opencode session/overflow.ts pattern). Replaces the v1.11.0-era
// `ctx_max - 20_000` formula which degenerated to 0 for contexts ≤20k and
// gave only 7-8% headroom to the summarizer at 262k. Ratio gives consistent
// 15% headroom at any scale, and small-ctx models no longer get an
// effectively-disabled trigger.
const EARLY_TRIGGER_RATIO = 0.85;
const MIN_PRESERVE_RECENT_TOKENS = 2_000;
const MAX_PRESERVE_RECENT_TOKENS = 8_000;
const DEFAULT_TAIL_TURNS = 2;
@@ -39,19 +45,24 @@ export interface CompactionMessage {
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
tool_calls: Array<{ id: string; name: string; args: Record<string, unknown> }> | null;
tool_results: { tool_call_id: string; output: unknown; truncated: boolean; error?: string } | null;
// v1.13.6: reasoning_parts captured by v1.13.1-C and read back through
// messages_with_parts. Embedded into the head-assembly payload as prose so
// the summarizer LLM sees what the model was reasoning through when it
// chose its tool calls.
reasoning_parts: Array<{ text: string }> | null;
metadata: { kind?: string } | null;
created_at: string;
}
// === overflow ===
// Tokens we hold in reserve for the model's response so a near-full context
// can still produce a useful turn. Mirrors opencode's COMPACTION_BUFFER.
// Returns 0 when the context limit is unknown (caller treats 0 as "do not
// trigger overflow"); avoids dividing-by-zero downstream.
// Returns the token budget at which overflow fires. Triggers compaction at
// 85% of contextLimit (opencode session/overflow.ts pattern). Returns 0 when
// the context limit is unknown caller treats 0 as "do not trigger overflow",
// keeping inference flowing rather than compacting a turn we can't size.
export function usable(contextLimit: number): number {
if (!contextLimit || contextLimit <= 0) return 0;
return Math.max(0, contextLimit - COMPACTION_BUFFER);
return Math.floor(EARLY_TRIGGER_RATIO * contextLimit);
}
export interface Usage {
@@ -197,7 +208,8 @@ export function buildPrompt(
// would silently drop pre-legacy-compact history before the LLM sees it.
// Compaction wants to send the entire head, full stop.) ===
interface OpenAiMessage {
// v1.13.6: exported for unit-test access (reasoning render coverage).
export interface OpenAiMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: Array<{
@@ -212,7 +224,8 @@ function isCapHitSentinel(m: CompactionMessage): boolean {
return m.role === 'system' && m.metadata != null && m.metadata.kind === 'cap_hit';
}
function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
// v1.13.6: exported for unit-test access (reasoning render coverage).
export function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
const out: OpenAiMessage[] = [];
for (const m of head) {
if (isCapHitSentinel(m)) continue;
@@ -243,9 +256,22 @@ function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
continue;
}
if (m.role === 'assistant') {
// v1.13.6: embed reasoning text as prose prefixed onto the assistant
// content. OpenAI wire shape doesn't carry reasoning as a structured
// field, but the summarizer is reading text — a tagged prose block
// gives it the same signal. We mirror the AI SDK ReasoningPart shape
// by using a <reasoning>...</reasoning> wrapper so the summarizer can
// distinguish reasoning from user-visible answer.
let body = m.content && m.content.length > 0 ? m.content : '';
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
const reasoning = m.reasoning_parts.map((r) => r.text).join('');
body = body.length > 0
? `<reasoning>${reasoning}</reasoning>\n\n${body}`
: `<reasoning>${reasoning}</reasoning>`;
}
const msg: OpenAiMessage = {
role: 'assistant',
content: m.content && m.content.length > 0 ? m.content : null,
content: body.length > 0 ? body : null,
};
if (m.tool_calls && m.tool_calls.length > 0) {
msg.tool_calls = m.tool_calls.map((tc) => ({
@@ -342,9 +368,14 @@ export async function process(input: ProcessInput): Promise<void> {
// 2. All currently-active messages in this chat (compacted_at IS NULL).
// ORDER BY (created_at, id) matches loadContext in inference.ts so the
// turns() boundary logic sees the same sequence the LLM will.
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view so
// the compaction payload matches what the LLM saw on the original turn.
// v1.13.6: also pulls reasoning_parts (added in v1.13.1-C) so summaries
// capture what the model was working through before each tool call.
const messages = await sql<CompactionMessage[]>`
SELECT id, role, content, kind, summary, status, tool_calls, tool_results, metadata, created_at
FROM messages
SELECT id, role, content, kind, summary, status, tool_calls, tool_results,
reasoning_parts, metadata, created_at
FROM messages_with_parts
WHERE chat_id = ${chatId} AND compacted_at IS NULL
ORDER BY created_at ASC, id ASC
`;
@@ -400,15 +431,16 @@ export async function process(input: ProcessInput): Promise<void> {
'compaction: invoking model',
);
// 6a. Flip the chat dot amber for the duration of the LLM call + DB writes.
// Same { type: 'chat_status', status: 'working', at } shape inference.ts
// emits at runner enqueue. publishUser → broadcasts on the per-user channel
// (all devices / tabs see it) since chat_status is a user-channel frame in
// BooCode (see useChatStatus.ts, which is the consumer).
broker.publishUser('default', {
// 6a. Flip the chat dot for the duration of the LLM call + DB writes.
// v1.13.11-b: publish status='streaming' (the v1.12.1-widened replacement
// for the dropped 'working' value). Compaction's LLM call has the same
// semantic as an inference turn for dot-state purposes. The v1.12.1
// chat_status widening missed this site; v1.13.11's WsFrame Zod schema
// surfaced the drift via the unknown-enum-value check.
broker.publishUserFrame('default', {
type: 'chat_status',
chat_id: chatId,
status: 'working',
status: 'streaming',
at: new Date().toISOString(),
});
@@ -477,7 +509,7 @@ export async function process(input: ProcessInput): Promise<void> {
// Always restore the dot. Status='idle' (not 'error') even on failure —
// the caller logs/re-surfaces the error separately; the dot doesn't
// need to stay red across reloads for a transient compaction blip.
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'chat_status',
chat_id: chatId,
status: 'idle',
@@ -491,7 +523,7 @@ export async function process(input: ProcessInput): Promise<void> {
// toast. Order matters: idle must precede 'compacted' so the dot is
// already green by the time the refetch toast appears.
if (succeeded) {
broker.publish(sessionId, {
broker.publishFrame(sessionId, {
type: 'compacted',
session_id: sessionId,
chat_id: chatId,

View File

@@ -47,8 +47,12 @@ export interface FindFilesResult {
truncated: boolean;
}
export async function listDir(projectRoot: string, relPath: string): Promise<ListDirResult> {
const real = await pathGuard(projectRoot, relPath);
export async function listDir(
projectRoot: string,
relPath: string,
opts?: { extra_roots?: readonly string[] },
): Promise<ListDirResult> {
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
const s = await stat(real);
if (!s.isDirectory()) {
throw new PathScopeError(`not a directory: ${relPath}`);
@@ -82,8 +86,12 @@ export async function listDir(projectRoot: string, relPath: string): Promise<Lis
};
}
export async function viewFile(projectRoot: string, relPath: string): Promise<ViewFileResult> {
const real = await pathGuard(projectRoot, relPath);
export async function viewFile(
projectRoot: string,
relPath: string,
opts?: { extra_roots?: readonly string[] },
): Promise<ViewFileResult> {
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
const s = await stat(real);
if (!s.isFile()) {
throw new PathScopeError(`not a file: ${relPath}`);
@@ -119,10 +127,10 @@ interface RipgrepMatch {
export async function grep(
projectRoot: string,
pattern: string,
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean }
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean; extra_roots?: readonly string[] }
): Promise<GrepResult> {
const targetPath = opts?.path ?? projectRoot;
const target = await pathGuard(projectRoot, targetPath);
const target = await pathGuard(projectRoot, targetPath, opts?.extra_roots);
const limit = Math.min(
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
MAX_GREP_RESULTS
@@ -192,14 +200,14 @@ export async function grep(
export async function findFiles(
projectRoot: string,
pattern?: string,
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string }
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string; extra_roots?: readonly string[] }
): Promise<FindFilesResult> {
const limit = Math.min(
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
MAX_FIND_RESULTS
);
const target = opts?.path != null
? await pathGuard(projectRoot, opts.path)
? await pathGuard(projectRoot, opts.path, opts?.extra_roots)
: projectRoot;
const args = ['--files'];
if (pattern) args.push('--glob', pattern);

View File

@@ -0,0 +1,161 @@
// v1.13.17-cross-repo-reads: derives the grant root for a path the user is
// being asked to approve cross-repo read access to.
//
// Per design decision D1: grant unit = nearest registered project root,
// then nearest path-whitelist ancestor that looks like a repo root, then
// refuse. Granting the literal file path is too narrow (next file in the
// same repo re-prompts). Granting an arbitrary parent dir over-scopes.
//
// The resolver runs in two contexts:
// 1. request_read_access.execute — pre-prompt validation (cheap; bails
// early if the path can't plausibly be granted so the user is never
// asked about /etc/passwd)
// 2. POST /api/chats/:id/grant_read_access — at decision time, re-derives
// the root and persists it on sessions.allowed_read_paths
//
// Sam (2026-05-22 dispatch confirmation): "in the project-root resolver
// ancestor walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
// filesystem root — check on every iteration, not just final parent.
// Symlinked input must not be able to escape the whitelist during the
// walk." Hence the loop here checks both the walk bound AND the still-
// inside-whitelist invariant every step.
import { access, realpath } from 'node:fs/promises';
import { constants } from 'node:fs';
import { dirname, isAbsolute, sep } from 'node:path';
import type { Sql } from '../db.js';
// Files whose presence in a directory marks it as a repo root for grant
// purposes. Kept narrow on purpose; broader heuristics (e.g. ".project",
// "pyproject.toml") can be added with measured intent. Each entry is a
// literal basename — no globs.
const REPO_MARKERS: ReadonlyArray<string> = [
'.git',
'package.json',
'go.mod',
'Cargo.toml',
];
export type GrantResolution =
| { ok: true; root: string; source: 'project' | 'whitelist' }
| { ok: false; reason: string };
function isUnder(child: string, parent: string): boolean {
return child === parent || child.startsWith(parent + sep);
}
async function exists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
async function isRepoShaped(dir: string): Promise<boolean> {
for (const marker of REPO_MARKERS) {
if (await exists(`${dir}${sep}${marker}`)) return true;
}
return false;
}
// Resolves an absolute path to its grant root or refuses with a reason
// string suitable for surfacing to the model. Pure helper — no DB writes,
// no broker publishes. Caller persists the root on session.allowed_read_paths
// if it wants the grant to stick.
//
// Arguments:
// sql — used only to read projects.path (no writes)
// requestedPath — absolute path the model wants to read
// projectRoot — the session's primary project root (already
// realpath'd by caller). Used to short-circuit
// "already in scope".
// whitelistRoot — PROJECT_ROOT_WHITELIST from config (default /opt).
// Walk bound for the repo-shape fallback.
//
// Returns { ok: true, root, source } on success; { ok: false, reason } else.
export async function resolveGrantRoot(
sql: Sql,
requestedPath: string,
projectRoot: string,
whitelistRoot: string,
): Promise<GrantResolution> {
if (typeof requestedPath !== 'string' || requestedPath.length === 0) {
return { ok: false, reason: 'path is required' };
}
if (!isAbsolute(requestedPath)) {
return { ok: false, reason: 'path must be absolute' };
}
// Resolve symlinks so subsequent ancestor checks compare apples-to-apples
// with realpath'd projectRoot. If the path doesn't exist at all, bail
// before bothering the user — the model is asking about a phantom.
let real: string;
try {
real = await realpath(requestedPath);
} catch {
return { ok: false, reason: `path does not exist: ${requestedPath}` };
}
// Whitelist guard. Symlinked inputs can resolve outside the whitelist
// even when the surface-form path looks inside it; that's why we test
// the *real* path here, not the requested one.
let realWhitelist: string;
try {
realWhitelist = await realpath(whitelistRoot);
} catch {
return { ok: false, reason: `whitelist root does not exist: ${whitelistRoot}` };
}
if (!isUnder(real, realWhitelist)) {
return { ok: false, reason: 'path outside permitted scope' };
}
// Already in scope? No prompt needed; the tool's caller should retry.
if (isUnder(real, projectRoot)) {
return { ok: false, reason: 'path already accessible without a grant' };
}
// Look for a registered project whose root is an ancestor of the
// requested path. Pick the LONGEST match (nearest ancestor wins) so
// sub-projects don't get over-broadened.
const projectRows = await sql<{ path: string }[]>`
SELECT path FROM projects WHERE status = 'open'
`;
let bestProject: string | null = null;
for (const row of projectRows) {
if (!row.path) continue;
if (!isUnder(real, row.path)) continue;
if (bestProject === null || row.path.length > bestProject.length) {
bestProject = row.path;
}
}
if (bestProject !== null) {
return { ok: true, root: bestProject, source: 'project' };
}
// Repo-shape fallback. Walk from the requested path upward toward the
// whitelist root. At every iteration: confirm we're still inside the
// whitelist (so a symlinked component can't slip the bound mid-walk)
// and confirm we haven't hit the filesystem root. The first dir with a
// REPO_MARKER child is the grant root.
let cursor = real;
while (true) {
// Don't grant the whitelist root itself — that would be far too broad.
if (cursor === realWhitelist) {
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
}
if (!isUnder(cursor, realWhitelist)) {
return { ok: false, reason: 'path outside permitted scope' };
}
const parent = dirname(cursor);
if (parent === cursor) {
// Hit filesystem root without finding a repo marker.
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
}
if (await isRepoShaped(cursor)) {
return { ok: true, root: cursor, source: 'whitelist' };
}
cursor = parent;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
import type { Agent } from '../../types/api.js';
import { READ_ONLY_TOOL_NAMES } from '../tools.js';
// v1.8.2: tool-call budget defaults. Resolved per-turn by resolveToolBudget.
// - Agent with explicit max_tool_calls: that value.
// - Agent with read-only-only tools: BUDGET_READ_ONLY (50).
// - Agent with any non-read-only tool: BUDGET_NON_READ_ONLY (10).
// - No agent (raw chat): BUDGET_NO_AGENT (50).
// v1.13.7: bumped BUDGET_NO_AGENT 15→30 to match BUDGET_READ_ONLY. Every tool
// in ALL_TOOLS today is read-only (see services/tools.ts comment at
// READ_ONLY_TOOL_NAMES); the cautious 15-cap was a forward-looking guard for
// write tools that haven't landed yet. No-agent mode gets the same toolset as
// an all-read-only agent at runtime, so they should share the same budget.
// v1.13.12: bumped read-only caps 30→50. Real recon sessions were hitting 30
// with ~3 turns wasted on codecontext parse failures (empty node_modules
// files); legitimate need was ~27, and Architect-class system overviews want
// deeper recon than a 30-cap permits. Headroom of 20 absorbs failure-retry
// turns + deeper exploration without changing the safety floor materially —
// the doom-loop guard (3 identical calls → abort) catches the actual failure
// mode this cap was guarding against.
export const BUDGET_READ_ONLY = 50;
export const BUDGET_NON_READ_ONLY = 10;
export const BUDGET_NO_AGENT = 50;
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
export function resolveToolBudget(agent: Agent | null): number {
if (agent?.max_tool_calls != null) return agent.max_tool_calls;
if (!agent) return BUDGET_NO_AGENT;
const allReadOnly = agent.tools.every((t) => READ_ONLY_SET.has(t));
return allReadOnly ? BUDGET_READ_ONLY : BUDGET_NON_READ_ONLY;
}

View File

@@ -0,0 +1,199 @@
import type { MessageMetadata, Session } from '../../types/api.js';
import {
decideHtmlArtifactWrite,
detectHtmlArtifact,
deriveHtmlTitle,
HTML_ARTIFACT_MAX_BYTES,
} from '../artifacts.js';
import * as modelContext from '../model-context.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage } from './parts.js';
import type { PartInsert } from './parts.js';
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
export async function handleAbortOrError(
ctx: InferenceContext,
args: TurnArgs,
accumulated: string,
err: unknown
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
const errMsg = err instanceof Error ? err.message : String(err);
// v1.8.2: persist a structured error metadata blob on genuine failures so
// the bubble can render the reason on reload without re-deriving from the
// (one-shot) WS error frame. User-initiated abort skips this — there's no
// "reason" to surface for a stop the user already explicitly chose.
const errorMetadata: MessageMetadata | null = isAbort
? null
: { kind: 'error', error_reason: 'llm_provider_error', error_text: errMsg };
if (errorMetadata) {
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp(),
metadata = ${ctx.sql.json(errorMetadata as never)}
WHERE id = ${assistantMessageId}
`;
} else {
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
}
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
// v1.8 mobile-tabs: cancellation is a user-initiated stop, treat as idle;
// genuine errors flip the dot red. v1.8.2: error path also carries a
// machine-readable `reason` so the UI can render specifics inline.
if (isAbort) {
// v1.12.1: defensive cancellation write. The status=${finalStatus} UPDATE
// above already sets 'cancelled' for the AbortError case, but a row can
// leak as 'streaming' when the abort fires between the post-tool-phase
// INSERT (executeToolPhase) and the next runAssistantTurn's stream setup,
// bypassing the try/catch around executeStreamPhase. The status guard
// makes this a no-op when the earlier write already landed.
await ctx.sql`
UPDATE messages
SET status = 'cancelled', content = ${accumulated}, finished_at = clock_timestamp()
WHERE id = ${args.assistantMessageId} AND status = 'streaming'
`;
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
});
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'error',
at: new Date().toISOString(),
reason: 'llm_provider_error',
});
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: errMsg,
reason: 'llm_provider_error',
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
}
export async function finalizeCompletion(
ctx: InferenceContext,
args: TurnArgs,
result: StreamResult,
startedAt: string | null,
session: Session
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const { content, finishReason, promptTokens, completionTokens } = result;
// v1.11.3: see executeToolPhase for the rationale.
const mctx = await modelContext.getModelContext(session.model);
const nCtx = mctx?.n_ctx ?? null;
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
>`
UPDATE messages
SET content = ${content},
status = 'complete',
tokens_used = ${completionTokens},
ctx_used = ${promptTokens},
ctx_max = ${nCtx},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
// v1.13.0: dual-write the text part. finalizeCompletion is the terminal
// path for text-only assistant turns (no tool calls); tool_calls are null
// here by construction (the tool-bearing path goes through executeToolPhase).
// v1.13.1-C: include result.reasoning so reasoning-channel models capture
// a kind='reasoning' part alongside the text.
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
// sql.begin before flipping read authority to message_parts.
const baseParts: PartInsert[] = partsFromAssistantMessage({
content,
tool_calls: null,
reasoning: result.reasoning,
}).map((p) => ({
...p,
message_id: assistantMessageId,
}));
// v1.14.x-html-artifact-panes: opportunistic HTML detection. Adds a
// SIBLING html_artifact part — never replaces the text part. 1MB cap is
// graceful: oversized payloads are skipped and the assistant message
// lands as plain content (warn logged).
const htmlContent = detectHtmlArtifact(content);
if (htmlContent !== null) {
const decision = decideHtmlArtifactWrite(htmlContent);
if (!decision.write) {
ctx.log.warn(
{ assistantMessageId, byteLen: decision.byteLen, cap: HTML_ARTIFACT_MAX_BYTES },
'html_artifact exceeded 1MB cap; skipping artifact part',
);
} else {
const title = deriveHtmlTitle(htmlContent);
const nextSeq = baseParts.reduce((m, p) => Math.max(m, p.sequence), -1) + 1;
baseParts.push({
message_id: assistantMessageId,
sequence: nextSeq,
kind: 'html_artifact',
payload: {
html_content: htmlContent,
char_count: htmlContent.length,
title,
},
});
}
}
await insertParts(ctx.sql, baseParts);
// v1.11: flag for compaction on the terminal turn too. Catches the common
// case of a turn that hit the limit without invoking tools.
await maybeFlagForCompaction(ctx, chatId, updated);
const [completeSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: completeSessRow!.project_id, name: completeSessRow!.name, updated_at: completeSessRow!.updated_at });
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
});
ctx.log.info(
{
sessionId,
chatId,
assistantMessageId,
finishReason,
chars: content.length,
tokens_used: updated?.tokens_used,
ctx_used: updated?.ctx_used,
},
'inference complete'
);
}

View File

@@ -0,0 +1,22 @@
// v1.12.4: re-export shim. Outside callers (apps/server/src/index.ts and the
// vitest inference tests) import from './services/inference/index.js'. The
// directory is now the public surface; turn.ts holds runAssistantTurn /
// runInference / createInferenceRunner while the other inference/*.ts files
// stay implementation-private.
export {
createInferenceRunner,
MAX_STEPS,
runAssistantTurn,
runInference,
} from './turn.js';
export type {
FramePublisher,
InferenceContext,
InferenceFrame,
StreamResult,
TurnArgs,
} from './turn.js';
export type { ToolPhaseResult } from './tool-phase.js';
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
export { buildMessagesPayload } from './payload.js';

View File

@@ -0,0 +1,108 @@
import type { Sql } from '../../db.js';
import type { ToolCall, ToolResult } from '../../types/api.js';
// v1.13.0: dual-write helper. Every site that writes the legacy
// messages.tool_calls / messages.tool_results JSON columns calls into here
// to mirror the same data into message_parts rows. Reads still go to the
// JSON columns; the swap to parts-as-source-of-truth happens in a later
// v1.13 dispatch alongside the AI SDK streamText migration.
// v1.13.13: 'synthesis' added. Schema CHECK constraint is updated in lockstep
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
// dispatch's claim that no schema migration was needed assumed kind was a
// bare text column — it isn't; the constraint enumerates allowed values.
// v1.14.x-html-artifact-panes: 'html_artifact' added. Schema CHECK constraint
// in schema.sql updated in lockstep.
export type PartKind =
| 'text'
| 'tool_call'
| 'tool_result'
| 'reasoning'
| 'step_start'
| 'synthesis'
| 'html_artifact';
export interface PartInsert {
message_id: string;
sequence: number;
kind: PartKind;
payload: unknown;
}
export async function insertParts(sql: Sql, parts: PartInsert[]): Promise<void> {
if (parts.length === 0) return;
// postgres-js fans out an array of objects to a multi-row INSERT. Each
// payload field needs sql.json() so jsonb storage receives a JSON value
// rather than a quoted string.
await sql`
INSERT INTO message_parts ${sql(
parts.map((p) => ({
message_id: p.message_id,
sequence: p.sequence,
kind: p.kind,
payload: sql.json(p.payload as never),
})),
'message_id',
'sequence',
'kind',
'payload',
)}
`;
}
// Derive parts from the canonical messages row for an assistant message.
// reasoning (when non-empty) becomes a 'reasoning' part at sequence 0 —
// it precedes user-visible content logically. content (when non-empty)
// becomes a 'text' part next; each tool_call becomes a 'tool_call' part
// with payload { id, name, args } where args is the parsed object (we
// use the in-memory ToolCall shape, not the OpenAI stringified one).
export function partsFromAssistantMessage(args: {
content: string;
tool_calls: ToolCall[] | null;
// v1.13.1-C: optional reasoning text streamed alongside the answer.
// Most rows have none — only models with separate reasoning channels
// (qwen3.6 etc.) populate this.
reasoning?: string;
}): Omit<PartInsert, 'message_id'>[] {
const out: Omit<PartInsert, 'message_id'>[] = [];
let seq = 0;
if (args.reasoning && args.reasoning.length > 0) {
out.push({ sequence: seq, kind: 'reasoning', payload: { text: args.reasoning } });
seq += 1;
}
if (args.content && args.content.length > 0) {
out.push({ sequence: seq, kind: 'text', payload: { text: args.content } });
seq += 1;
}
for (const tc of args.tool_calls ?? []) {
out.push({
sequence: seq,
kind: 'tool_call',
payload: { id: tc.id, name: tc.name, args: tc.args },
});
seq += 1;
}
return out;
}
// Derive a single tool_result part from a tool message's tool_results JSON.
// The payload includes the same shape that buildMessagesPayload reads from
// later: tool_call_id, output, optional error/truncated metadata.
export function partsFromToolMessage(args: {
tool_results: ToolResult | null;
}): Omit<PartInsert, 'message_id'>[] {
if (!args.tool_results) return [];
const tr = args.tool_results;
return [
{
sequence: 0,
kind: 'tool_result',
payload: {
tool_call_id: tr.tool_call_id,
output: tr.output,
truncated: tr.truncated,
...(tr.error ? { error: tr.error } : {}),
},
},
];
}

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