Compare commits

..

4 Commits

Author SHA1 Message Date
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
50 changed files with 3325 additions and 526 deletions

View File

@@ -28,6 +28,13 @@
- 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.

View File

@@ -2,6 +2,22 @@
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.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).

View File

@@ -46,7 +46,7 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
- **Zod** for request validation and config parsing.
Key services:
- **`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`), `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; value back-edges into turn.ts for the runAssistantTurn recursion — cycle safe because deref at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + 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` (v1.13.0 dual-write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts`), `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 threaded through the `executeToolPhase → runAssistantTurn` recursion; reset in `runInference` at user-message boundary. Add new per-turn state to `TurnArgs`, not module-level closures.
- **`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.
@@ -63,7 +63,7 @@ Key services:
- **`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. `COALESCE`s parts-table rows over the legacy JSON columns, so pre-v1.13.0 history still resolves. Writes still target `messages`; the v1.13.0 dual-write into `message_parts` keeps both halves in sync. New payload-assembly code must use the view — calling `messages.tool_calls` directly will miss anything written post-v1.13.1-B if the JSON column ever drifts (and dual-write makes that easy to miss). Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`.
- **`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.
@@ -118,6 +118,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- 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.

View File

@@ -10,6 +10,7 @@ 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';
@@ -160,6 +161,7 @@ async function main() {
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);

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

@@ -296,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'
@@ -385,21 +385,25 @@ export function registerChatRoutes(
reply.code(409);
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
}
const updated = await sql<Message[]>`
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, 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
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,
@@ -411,7 +415,7 @@ export function registerChatRoutes(
message_id: msg.id,
chat_id: msg.chat_id,
});
return updated[0];
return refreshed[0];
}
);

View File

@@ -605,15 +605,11 @@ export function registerMessageRoutes(
const toolMessageId = toolRow.message_id;
const result = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolMessageId}
`;
// v1.13.0: 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.
// 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`
INSERT INTO message_parts (message_id, sequence, kind, payload)
@@ -796,13 +792,9 @@ export function registerMessageRoutes(
};
const toolMessageId = toolRow.message_id;
const dbResult = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolMessageId}
`;
// Same delete+insert dance as /answer — UNIQUE (message_id, sequence)
// blocks plain UPDATE on append-style parts.
// 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)

View File

@@ -13,12 +13,37 @@ 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']),
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({

View File

@@ -86,12 +86,12 @@ 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.0: dual-write the synthetic assistant message's tool_call.
// Single skill_use tool_call, no text content, so one part at seq 0.
// 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({
@@ -101,11 +101,11 @@ export function registerSkillsRoutes(
} 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.0: dual-write the synthetic tool result (the skill body).
// 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)})

View File

@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS message_parts (
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')),
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);
@@ -79,6 +79,10 @@ CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
-- '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
@@ -87,55 +91,48 @@ BEGIN
) THEN
ALTER TABLE message_parts
ADD CONSTRAINT message_parts_kind_chk
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis'));
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. The COALESCE means pre-v1.13.0
-- history (no parts rows) still resolves via the legacy JSON columns; the
-- dual-write from v1.13.0 keeps both in sync for all rows written since.
-- 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 new — only
-- consumed by the inference history fetch (payload.ts) so v1.13.1-C can
-- wire reasoning into the model payload. Not surfaced in external APIs yet.
-- 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,
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
-- (prune intended — return null/empty so the row drops from the model
-- payload). A naive COALESCE would fall back to the legacy column when
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
-- splits the two cases.
CASE
WHEN EXISTS (SELECT 1 FROM message_parts pp
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
(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)
ELSE m.tool_calls
END AS tool_calls,
CASE
WHEN EXISTS (SELECT 1 FROM message_parts pp
WHERE pp.message_id = m.id AND pp.kind = 'tool_result')
THEN (SELECT p.payload
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)
ELSE m.tool_results
END AS tool_results,
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
@@ -286,19 +283,6 @@ BEGIN
END IF;
END $$;
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
-- named *_chk variants above. messages_status_check missed 'cancelled' and
-- messages_role_check missed 'system' — both narrower than what's in use.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
END IF;
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
END IF;
END $$;
-- v1.2-project-ux: projects.status + projects.gitea_remote
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';

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

@@ -78,16 +78,18 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
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,
tool_calls, tokens_used, ctx_used,
tokens_used, ctx_used,
metadata, created_at
)
VALUES (
${sessionId}, ${chatId}, 'assistant', '', 'message',
${opts.status ?? 'complete'},
${sql.json(toolCalls as never)},
${opts.tokensUsed},
${opts.ctxUsed},
${opts.metadata ? sql.json(opts.metadata as never) : null},
@@ -95,7 +97,14 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
)
RETURNING id
`;
return rows[0]!.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 () => {
@@ -197,18 +206,17 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
const t = tname('parts');
// Insert an assistant row with messages.tool_calls=NULL but a
// message_parts row carrying the tool_call. The view reads via
// messages_with_parts, which COALESCEs the parts table over the legacy
// column — so this row should still aggregate.
// 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,
tool_calls, tokens_used, ctx_used
tokens_used, ctx_used
)
VALUES (
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
NULL, 200, 5000
200, 5000
)
RETURNING id
`;

View File

@@ -37,6 +37,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 +116,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.
}
@@ -204,6 +223,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,
};
}

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

@@ -17,7 +17,7 @@
// which we re-surface with a hint to add the file to .codecontextignore.
import { access, copyFile, realpath } from 'node:fs/promises';
import { join } from 'node:path';
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)
@@ -51,6 +51,45 @@ async function ensureIgnoreFile(projectRoot: string): Promise<void> {
}
}
// 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;
args: Record<string, unknown>;
@@ -96,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.

View File

@@ -1,7 +1,14 @@
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(
@@ -120,17 +127,42 @@ export async function finalizeCompletion(
// 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.
await insertParts(
ctx.sql,
partsFromAssistantMessage({
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);

View File

@@ -6,6 +6,7 @@
export {
createInferenceRunner,
MAX_STEPS,
runAssistantTurn,
runInference,
} from './turn.js';
@@ -16,5 +17,6 @@ export type {
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

@@ -11,13 +11,16 @@ import type { ToolCall, ToolResult } from '../../types/api.js';
// (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';
| 'synthesis'
| 'html_artifact';
export interface PartInsert {
message_id: string;

View File

@@ -476,6 +476,202 @@ export async function runDoomLoopSummary(
);
}
// v1.14.0: step-cap wrap-up. Mirrors runCapHitSummary structurally — same
// in-flight-slot reuse, same tools-disabled streaming-summary call, same
// post-finalize sentinel insert + chat_status drop. Difference: the note
// text names the step limit rather than the tool budget. Sentinel reuses
// metadata.kind = 'cap_hit' so the frontend CapHitSentinel component
// renders it without changes.
const STEP_CAP_NOTE = (steps: number, cap: number) =>
`You've reached the step limit (${steps}/${cap} steps). Produce the best answer you can with what you have. Do not call more tools.`;
export async function runStepCapSummary(
ctx: InferenceContext,
args: TurnArgs,
session: Session,
project: Project,
history: Message[],
agent: Agent | null,
steps: number,
cap: number,
): Promise<void> {
const { sessionId, chatId, assistantMessageId, signal } = args;
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
messages.push({ role: 'system', content: STEP_CAP_NOTE(steps, cap) });
const startedRow = await ctx.sql<{ started_at: string }[]>`
UPDATE messages
SET started_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING started_at
`;
const startedAt = startedRow[0]?.started_at ?? null;
ctx.publish(sessionId, {
type: 'message_started',
message_id: assistantMessageId,
chat_id: chatId,
role: 'assistant',
});
let accumulated = '';
let pendingFlushTimer: NodeJS.Timeout | null = null;
let flushPromise: Promise<unknown> = Promise.resolve();
const flushNow = () => {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
const snapshot = accumulated;
flushPromise = flushPromise.then(() =>
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
);
};
const scheduleFlush = () => {
if (pendingFlushTimer) return;
pendingFlushTimer = setTimeout(() => {
pendingFlushTimer = null;
flushNow();
}, DB_FLUSH_INTERVAL_MS);
};
let summaryOk = false;
let summarySoftCancelled = false;
let summaryError: string | null = null;
let result: StreamResult | null = null;
try {
result = await streamCompletion(
ctx,
session.model,
messages,
{ tools: null, temperature: agent?.temperature },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {
type: 'delta',
message_id: assistantMessageId,
chat_id: chatId,
content: delta,
});
scheduleFlush();
},
undefined,
signal,
);
summaryOk = true;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
summarySoftCancelled = true;
} else {
summaryError = err instanceof Error ? err.message : String(err);
}
} finally {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
await flushPromise;
}
if (summaryOk && result) {
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 = ${result.content},
status = 'complete',
tokens_used = ${result.completionTokens},
ctx_used = ${result.promptTokens},
ctx_max = ${nCtx},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
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,
});
} else if (summarySoftCancelled) {
await ctx.sql`
UPDATE messages
SET content = ${accumulated},
status = 'cancelled',
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
});
} else {
const errMeta: MessageMetadata = {
kind: 'error',
error_reason: 'summary_after_cap_failed',
error_text: summaryError ?? 'step-cap summary failed',
};
await ctx.sql`
UPDATE messages
SET content = ${accumulated},
status = 'failed',
finished_at = clock_timestamp(),
metadata = ${ctx.sql.json(errMeta as never)}
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: summaryError ?? 'step-cap summary failed',
reason: 'summary_after_cap_failed',
});
}
const [sessRow] = 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: sessRow!.project_id,
name: sessRow!.name,
updated_at: sessRow!.updated_at,
});
// Reuse cap_hit sentinel so the frontend CapHitSentinel component renders
// it without changes. The content text distinguishes step cap from budget.
await insertCapHitSentinel(ctx, sessionId, chatId, agent, cap);
if (summaryOk || summarySoftCancelled) {
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
} else {
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'error',
at: new Date().toISOString(),
reason: 'summary_after_cap_failed',
});
}
ctx.log.info(
{ sessionId, chatId, assistantMessageId, steps, cap, summaryOk, summaryCancelled: summarySoftCancelled },
'inference step-cap summary finished',
);
}
async function insertDoomLoopSentinel(
ctx: InferenceContext,
sessionId: string,

View File

@@ -19,11 +19,6 @@ import type {
StreamResult,
TurnArgs,
} from './turn.js';
// v1.12.4: ESM value-import cycle. executeToolPhase recurses into
// runAssistantTurn which lives in inference.ts. The cycle is safe because
// the reference is read at call time (inside an async function body), not
// at module top-level. Node + tsc resolve this cleanly.
import { runAssistantTurn } from './turn.js';
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
// recursion on synthesis failure (timeout / model error). See module header
@@ -86,6 +81,16 @@ async function executeToolCall(
}
}
// v1.14.0: return struct from executeToolPhase so the caller (the outer
// while loop in turn.ts) can decide whether to continue, break, or handle
// synthesis. Replaces the recursive call into runAssistantTurn.
export interface ToolPhaseResult {
action: 'continue' | 'paused' | 'synthesis_done';
toolCallCount: number;
toolCalls: ToolCall[];
nextAssistantId: string | null;
}
export async function executeToolPhase(
ctx: InferenceContext,
args: TurnArgs,
@@ -93,8 +98,8 @@ export async function executeToolPhase(
startedAt: string | null,
session: Session,
projectRoot: string
): Promise<void> {
const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
): Promise<ToolPhaseResult> {
const { sessionId, chatId, assistantMessageId } = args;
const { content, toolCalls, promptTokens, completionTokens } = result;
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
@@ -110,7 +115,6 @@ export async function executeToolPhase(
UPDATE messages
SET content = ${content},
status = 'complete',
tool_calls = ${ctx.sql.json(toolCalls as never)},
tokens_used = ${completionTokens},
ctx_used = ${promptTokens},
ctx_max = ${nCtx},
@@ -118,15 +122,11 @@ export async function executeToolPhase(
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
// v1.13.0: dual-write to message_parts. v1.13.1-B made parts authoritative
// for reads via the messages_with_parts view; the JSON column write above
// remains for v1.13.1 fallback compatibility (dropped in v1.13.2).
// v1.13.20: message_parts is the sole source of truth for tool_calls.
// Legacy messages.tool_calls column was dropped; reads route through the
// messages_with_parts view.
// v1.13.1-C: include result.reasoning so models with separate reasoning
// channels (qwen3.6) get a kind='reasoning' part at sequence 0.
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
// sql.begin before flipping read authority to message_parts. Without the
// transaction, a crash between the two leaves an orphan message that
// becomes invisible in the parts-authoritative read path.
await insertParts(
ctx.sql,
partsFromAssistantMessage({
@@ -192,16 +192,9 @@ export async function executeToolPhase(
if (tc.name === 'ask_user_input') {
pausingForUserInput = true;
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(sentinel as never)}
WHERE id = ${toolMessageId}
`;
// v1.13.0: mirror the pending sentinel into message_parts. The
// answer-endpoint UPDATE later (messages.ts:576) will delete and
// re-insert this part when the user submits their answer.
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in
// a per-iteration sql.begin before flipping read authority.
// v1.13.20: parts-only. The answer-endpoint UPDATE later
// (messages.ts) will delete and re-insert this part when the user
// submits their answer.
await insertParts(
ctx.sql,
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
@@ -234,11 +227,7 @@ export async function executeToolPhase(
output: `denied: ${resolution.reason}`,
truncated: false,
};
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(stored as never)}
WHERE id = ${toolMessageId}
`;
// v1.13.20: parts-only write.
await insertParts(
ctx.sql,
partsFromToolMessage({ tool_results: stored }).map((p) => ({
@@ -261,11 +250,7 @@ export async function executeToolPhase(
// (state may have changed in the meantime) so we don't stash it here.
pausingForUserInput = true;
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(sentinel as never)}
WHERE id = ${toolMessageId}
`;
// v1.13.20: parts-only write.
await insertParts(
ctx.sql,
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
@@ -285,14 +270,7 @@ export async function executeToolPhase(
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
};
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(stored as never)}
WHERE id = ${toolMessageId}
`;
// v1.13.0: dual-write the tool_result part.
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in a
// per-iteration sql.begin before flipping read authority.
// v1.13.20: parts-only write. Reads route through messages_with_parts.
await insertParts(
ctx.sql,
partsFromToolMessage({ tool_results: stored }).map((p) => ({
@@ -323,7 +301,12 @@ export async function executeToolPhase(
{ sessionId, chatId, assistantMessageId },
'inference paused awaiting user input',
);
return;
return {
action: 'paused' as const,
toolCallCount: toolCalls.length,
toolCalls,
nextAssistantId: null,
};
}
// v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
@@ -355,30 +338,30 @@ export async function executeToolPhase(
...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
});
if (ran) return;
if (ran) {
return {
action: 'synthesis_done' as const,
toolCallCount: toolCalls.length,
toolCalls,
nextAssistantId: null,
};
}
// ran === false → synthesis failed (timeout / model error) → fall through
// to the standard recursive turn below. The synth message (if created)
// to the standard continue path below. The synth message (if created)
// was already marked status='failed' inside runSynthesisPass.
}
// v1.14.0: create the next assistant row and return a continue result.
// The caller (outer while loop in turn.ts) handles the iteration.
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await runAssistantTurn(ctx, {
sessionId,
chatId,
assistantMessageId: nextAssistant!.id,
// v1.8.2: charge this turn's actual tool invocations against the budget.
// One assistant message can emit multiple tool_calls, so we add the run
// count, not 1. The next turn's budget check sees the cumulative total.
toolsUsed: toolsUsed + result.toolCalls.length,
// v1.11.6: append the just-executed tool calls to the per-turn history
// so the next runAssistantTurn's doom-loop check can see them. We don't
// cap the array length here — per-turn budgets keep it bounded
// (typically <30 entries), and slicing happens inside detectDoomLoop.
recentToolCalls: [...args.recentToolCalls, ...result.toolCalls],
signal,
});
return {
action: 'continue' as const,
toolCallCount: toolCalls.length,
toolCalls,
nextAssistantId: nextAssistant!.id,
};
}

View File

@@ -16,11 +16,9 @@ import { resolveProjectRoot } from '../path_guard.js';
import { maybeAutoNameChat } from '../auto_name.js';
import { getAgentById } from '../agents.js';
import * as compaction from '../compaction.js';
import * as modelContext from '../model-context.js';
import type { Broker } from '../broker.js';
import { resolveToolBudget } from './budget.js';
import {
DOOM_LOOP_THRESHOLD,
detectDoomLoop,
} from './sentinels.js';
import {
@@ -33,15 +31,23 @@ import {
} from './error-handler.js';
import {
executeStreamPhase,
streamCompletion,
} from './stream-phase.js';
import { executeToolPhase } from './tool-phase.js';
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
import type { StreamPhaseState } from './types.js';
import {
runCapHitSummary,
runDoomLoopSummary,
runStepCapSummary,
} from './sentinel-summaries.js';
// v1.14.0: hard ceiling on the number of stream-and-tool iterations per
// user-message turn. Per-agent cap via agent.steps is the primary knob;
// MAX_STEPS is the safety ceiling. 200 is 4x the effective budget ceiling
// (50 tool calls) — in practice budget fires first unless the model makes
// many 0-tool-call iterations (which exit the loop via the non-tool finish
// path anyway).
export const MAX_STEPS = 200;
// v1.12.4: re-exported so external callers (tests, future consumers) keep
// importing from services/inference.js as the public surface.
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
@@ -145,13 +151,70 @@ export async function runAssistantTurn(
ctx: InferenceContext,
args: TurnArgs,
): Promise<void> {
const { sessionId, chatId } = args;
const { sessionId, chatId, signal } = args;
// v1.11: if the prior turn flagged this chat for compaction, run it first
// so loadContext below reads the post-compaction history. We swallow
// compaction failures (clearing the flag so we don't loop) and proceed
// with the un-compacted history — a slow turn that hits the model's
// hard limit is recoverable; a dead session is not.
// v1.14.0: resolve agent once at the top. The agent stays fixed for the
// duration of this user-message turn — PATCH agent_id mid-conversation
// takes effect on the next runInference, not mid-loop.
const initialLoaded = await loadContext(ctx.sql, sessionId, chatId);
if (!initialLoaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
}
const { session, project } = initialLoaded;
const agent = session.agent_id
? await getAgentById(project.path, session.agent_id)
: null;
const budget = resolveToolBudget(agent);
// v1.14.0: effectiveCap = min(agent.steps ?? Infinity, MAX_STEPS).
// steps: 0 means "no tool calls allowed" — the first stream phase runs
// but if it emits tool calls they are not executed (finalize as text-only).
const effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS);
// steps: 0 special case — model responds text-only. The while loop would
// never enter (effectiveCap === 0), so we handle it explicitly before the
// loop. The model always gets at least one chance to respond with text.
if (effectiveCap === 0) {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
}
return;
}
let stepNumber = 0;
let toolsUsed = args.toolsUsed;
let recentToolCalls = args.recentToolCalls;
let assistantMessageId = args.assistantMessageId;
while (stepNumber < effectiveCap) {
// ---- doom-loop check (moved from top-of-function) ----
const loop = detectDoomLoop(recentToolCalls);
if (loop) {
// Need fresh history for the summary.
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, loop);
}
break;
}
// ---- budget check (moved from top-of-function) ----
if (toolsUsed >= budget) {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
await runCapHitSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, budget);
}
break;
}
// ---- compaction check ----
// v1.11: if the prior turn flagged this chat for compaction, run it
// before loadContext so we read post-compaction history. Swallow
// failures and proceed with un-compacted history.
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
SELECT needs_compaction FROM chats WHERE id = ${chatId}
`;
@@ -170,50 +233,103 @@ export async function runAssistantTurn(
}
}
// ---- load context (must re-load each iteration — new messages since last step) ----
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
break;
}
const { session, project, history } = loaded;
const projectRoot = await resolveProjectRoot(project.path);
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
// effect on the next message. Unknown agent_id returns null silently —
// session falls back to base prompt + all tools + default temperature.
const agent = session.agent_id
? await getAgentById(project.path, session.agent_id)
: null;
const { session: iterSession, project: iterProject, history } = loaded;
const projectRoot = await resolveProjectRoot(iterProject.path);
// v1.8.2: cap-hit replaces the older "tool loop depth exceeded" failure.
// When we've already burned the budget *before* this turn even runs, we
// skip straight to the summary flow — the in-flight assistant message slot
// gets reused for the wrap-up reply instead of being marked failed.
const budget = resolveToolBudget(agent);
if (args.toolsUsed >= budget) {
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
return;
// v1.14.0: log step boundary for instrumentation. step_start parts are in
// the schema CHECK but not emitted here — writing to the assistant message
// before the stream phase creates a sequence-0 collision with
// partsFromAssistantMessage. A WS frame or structured log is sufficient
// since the frontend doesn't render step boundaries in v1.14.
ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
// ---- build messages + stream phase ----
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
const webToolsEnabled =
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
const state: StreamPhaseState = { accumulated: '', startedAt: null };
let result: StreamResult;
try {
result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
} catch (err) {
await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
break;
}
// v1.11.6: doom-loop guard. Detected BEFORE the budget cap (the model can
// burn through 3 identical calls long before the 15-call budget fires).
// Same in-flight-slot-reuse pattern as runCapHitSummary — wrap-up reply
// lands in args.assistantMessageId, then a doom_loop sentinel is inserted
// to make the abort visible in the chat history.
const loop = detectDoomLoop(args.recentToolCalls);
if (loop) {
await runDoomLoopSummary(ctx, args, session, project, history, agent, loop);
return;
// ---- non-tool finish → finalize and exit ----
if (result.toolCalls.length === 0) {
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
break;
}
// ---- steps: 0 edge case ----
// effectiveCap check above guarantees we're inside the loop, but this
// guard handles the theoretical case where the model emits tool calls
// on step 0 when effectiveCap would have been 0 (impossible since the
// while condition prevents entry, but kept for safety). If effectiveCap
// is 1 and we're on step 0, tool calls ARE executed — steps counts
// iterations, not post-first-stream.
// ---- tool phase ----
let toolPhaseResult: ToolPhaseResult;
try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
} catch (err) {
// Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break.
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
break;
}
// ---- update loop locals ----
toolsUsed += toolPhaseResult.toolCallCount;
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls];
stepNumber++;
if (toolPhaseResult.action !== 'continue') {
// 'paused' (user input) or 'synthesis_done' — stop the loop.
break;
}
// 'continue' — advance to next assistant message.
assistantMessageId = toolPhaseResult.nextAssistantId!;
}
// ---- post-loop: step-cap sentinel ----
// When the loop exits because stepNumber reached effectiveCap, the last
// iteration's tool phase returned 'continue' with a nextAssistantId that
// is still in 'streaming' status (unfilled). Use it for the wrap-up.
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) {
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
await runStepCapSummary(ctx, capArgs, loaded.session, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
}
}
}
// v1.14.0: special handling for steps: 0 — the model responds text-only.
// The while loop never enters (effectiveCap === 0). We stream once with
// no tools, finalize, and return. If the model emits tool calls despite
// not being offered tools, they're ignored (finalize as text-only).
async function runTextOnlyTurn(
ctx: InferenceContext,
args: TurnArgs,
session: Session,
project: Project,
history: Message[],
agent: Agent | null,
): Promise<void> {
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
// v1.11.8: resolve per-chat web-tools opt-in. Tri-state on the wire:
// - session.web_search_enabled = null → inherit project default
// - session.web_search_enabled = true/false → explicit
// Both web_search and web_fetch are gated by this single flag (the UI
// label is "Enable web search and fetch" — same store, both tools).
// Default is false unless explicitly opted in, matching the v1.9
// plumbing intent ("inert until Batch 8 ships the actual tools").
// Web tools are irrelevant when steps: 0 (no tool execution), but we
// still need to resolve the flag for executeStreamPhase's signature.
const webToolsEnabled =
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
@@ -227,8 +343,12 @@ export async function runAssistantTurn(
}
if (result.toolCalls.length > 0) {
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
return;
ctx.log.warn(
{ chatId: args.chatId, toolCallCount: result.toolCalls.length },
'steps: 0 agent emitted tool calls; ignoring and finalizing as text-only',
);
// Override: strip tool calls so finalizeCompletion treats it as text-only.
result = { ...result, toolCalls: [] };
}
await finalizeCompletion(ctx, args, result, state.startedAt, session);

View File

@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
export const GetDependenciesInput = z.object({
file_path: z.string().optional(),
file_path: z.string().trim().optional(),
direction: z.enum(['incoming', 'outgoing', 'both']).optional(),
});
export type GetDependenciesInputT = z.infer<typeof GetDependenciesInput>;

View File

@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
export const GetFileAnalysisInput = z.object({
file_path: z.string().min(1),
file_path: z.string().trim().min(1),
});
export type GetFileAnalysisInputT = z.infer<typeof GetFileAnalysisInput>;

View File

@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
export const GetSemanticNeighborhoodsInput = z.object({
file_path: z.string().optional(),
file_path: z.string().trim().optional(),
include_basic: z.boolean().optional(),
include_quality: z.boolean().optional(),
max_results: z.number().int().positive().optional(),

View File

@@ -6,7 +6,7 @@ import { callCodecontext, type CodecontextResponse } from '../../codecontext_cli
export const GetSymbolInfoInput = z.object({
symbol_name: z.string().min(1),
file_path: z.string().optional(),
file_path: z.string().trim().optional(),
framework_type: z.string().optional(),
});
export type GetSymbolInfoInputT = z.infer<typeof GetSymbolInfoInput>;

View File

@@ -50,7 +50,32 @@ export interface Session {
allowed_read_paths: string[];
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
// Optional payload state lives on the pane row itself so the jsonb survives
// a hard reload without needing a re-fetch.
export type WorkspacePaneKind =
| 'chat'
| 'terminal'
| 'agent'
| 'empty'
| 'settings'
| 'markdown_artifact'
| 'html_artifact';
// v1.14.x: reference-only — the actual artifact body lives in the message
// row (markdown) or message_parts.payload (html_artifact). Pane components
// fetch on mount.
export interface MarkdownArtifactState {
chat_id: string;
message_id: string;
title: string;
}
export interface HtmlArtifactState {
chat_id: string;
message_id: string;
title: string;
}
export interface WorkspacePane {
id: string;
@@ -58,6 +83,9 @@ export interface WorkspacePane {
chatId?: string;
chatIds: string[];
activeChatIdx: number;
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
markdown_artifact_state?: MarkdownArtifactState;
html_artifact_state?: HtmlArtifactState;
}
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
@@ -78,6 +106,9 @@ export interface Agent {
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
// raw chat with no agent.
max_tool_calls: number | null;
// v1.14.0: per-agent step cap for the outer inference loop. null means
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
steps: number | null;
}
// One entry per malformed `## Name` block. Per-block errors don't fail the

View File

@@ -276,6 +276,24 @@ export const api = {
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
method: 'DELETE',
}),
// v1.14.x-html-artifact-panes: write the artifact to
// <projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext> and return the
// path + a /api/projects/.../artifacts/<filename> URL the browser can
// GET to download. fmt=html requires the assistant message to carry an
// html_artifact part (404 otherwise).
downloadArtifact: (chatId: string, messageId: string, fmt: 'md' | 'html') =>
request<{ path: string; url: string }>(
`/api/chats/${chatId}/messages/${messageId}/artifacts/download?fmt=${fmt}`,
{ method: 'POST' },
),
// v1.14.x-html-artifact-panes: fetch the html_artifact part payload so
// HtmlArtifactPane can render the iframe srcdoc. 404 = no html_artifact
// part on this message; MessageBubble uses that as a signal to fall back
// to the markdown pane variant.
getHtmlArtifact: (chatId: string, messageId: string) =>
request<{ html_content: string; char_count: number; title: string }>(
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
),
},
models: () => request<ModelInfo[]>('/api/models'),

View File

@@ -73,6 +73,9 @@ export interface Agent {
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
// chat with no agent.
max_tool_calls: number | null;
// v1.14.0: per-agent step cap for the outer inference loop. null means
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
steps: number | null;
}
export interface AgentParseError {
@@ -316,7 +319,37 @@ export interface AskUserAnswerSet {
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via toggleSettingsPane().
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
// Both carry payload state on the WorkspacePane row itself so
// useWorkspacePanes's JSON-string dedup + persisted jsonb stay self-contained
// — no extra fetch on rehydrate.
export type WorkspacePaneKind =
| 'chat'
| 'terminal'
| 'agent'
| 'empty'
| 'settings'
| 'markdown_artifact'
| 'html_artifact';
// v1.14.x: per-pane artifact payloads. Optional + namespaced so older saved
// pane rows (without these fields) deserialize unchanged.
// v1.14.x: pane state is a reference only — the pane component fetches the
// actual content on mount. This keeps sessions.workspace_panes jsonb small and
// makes the message body / html_artifact part the single source of truth.
export interface MarkdownArtifactState {
// chat_id is needed for the download endpoint
// (POST /api/chats/:chat_id/messages/:msg_id/artifacts/download).
chat_id: string;
message_id: string;
title: string;
}
export interface HtmlArtifactState {
chat_id: string;
message_id: string;
title: string;
}
export interface WorkspacePane {
id: string;
@@ -324,6 +357,9 @@ export interface WorkspacePane {
chatId?: string;
chatIds: string[];
activeChatIdx: number;
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
markdown_artifact_state?: MarkdownArtifactState;
html_artifact_state?: HtmlArtifactState;
}
export type WsFrame =

View File

@@ -0,0 +1,116 @@
// v1.14.x-html-artifact-panes: full-height HTML artifact viewer. Renders the
// model's HTML inside a sandboxed iframe — no allow-same-origin, srcdoc only
// (no separate URL), CSP injected by the backend writer. JS runs inside the
// iframe (interactive controls work) but fetch / WS / tracking pixels are
// blocked by connect-src 'none' on the CSP. NO Copy button per the spec.
//
// Pane state is a reference only (chat_id + message_id + title); the iframe
// payload is fetched on mount from
// GET /api/chats/:chat_id/messages/:msg_id/html_artifact so that
// sessions.workspace_panes jsonb stays small and message_parts.payload is the
// single source of truth.
import { useEffect, useState } from 'react';
import { Download, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { HtmlArtifactState } from '@/api/types';
interface Props {
chatId: string;
state: HtmlArtifactState;
onClose: () => void;
}
export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
const [downloading, setDownloading] = useState(false);
const [htmlContent, setHtmlContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setHtmlContent(null);
setLoadError(null);
void (async () => {
try {
const payload = await api.messages.getHtmlArtifact(chatId, state.message_id);
if (cancelled) return;
setHtmlContent(payload.html_content);
} catch (err) {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : 'failed to load HTML artifact');
}
})();
return () => {
cancelled = true;
};
}, [chatId, state.message_id]);
async function download() {
if (downloading) return;
setDownloading(true);
try {
const { url, path } = await api.messages.downloadArtifact(
chatId,
state.message_id,
'html',
);
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.click();
toast.success(`Saved to ${path}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'download failed');
} finally {
setDownloading(false);
}
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
{state.title || 'HTML artifact'}
</span>
<button
type="button"
onClick={() => void download()}
disabled={downloading || htmlContent === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Download HTML"
title="Download"
>
<Download size={12} />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close artifact pane"
title="Close"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden bg-background">
{loadError ? (
<div className="p-4 text-sm text-destructive">Failed to load: {loadError}</div>
) : htmlContent === null ? (
<div className="p-4 text-sm text-muted-foreground">Loading HTML artifact</div>
) : (
<iframe
// Sandbox attributes are non-negotiable per the v1.14.x spec S5:
// no allow-same-origin → opaque origin → can't reach parent cookies
// or DOM. srcdoc (not src) means no URL exists to leak. JS runs
// (allow-scripts) but connect-src 'none' on the CSP inside the
// payload blocks fetch / WS / pixels.
srcDoc={htmlContent}
sandbox="allow-scripts allow-clipboard-write allow-downloads"
className="w-full h-full border-0"
title={state.title || 'HTML artifact'}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
// v1.14.x-html-artifact-panes: dedicated full-height Markdown viewer used
// when a user clicks "Open in pane" on an assistant message that has NO
// html_artifact part. Header carries Copy (raw source) + Download (server-
// materialised .md under <projectRoot>/.boocode/artifacts/) + close.
//
// Pane state is a reference only (chat_id + message_id + title); the markdown
// body is fetched on mount from GET /api/chats/:chat_id/messages by locating
// the matching message_id. This keeps sessions.workspace_panes jsonb small
// and the assistant message row remains the single source of truth.
import { useEffect, useState } from 'react';
import { Check, Copy, Download, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { MarkdownArtifactState } from '@/api/types';
import { MarkdownRenderer } from './MarkdownRenderer';
interface Props {
chatId: string;
state: MarkdownArtifactState;
onClose: () => void;
}
export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
const [justCopied, setJustCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
const [content, setContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setContent(null);
setLoadError(null);
void (async () => {
try {
// No single-message GET endpoint exists; the chat-messages list is
// already cached server-side and the lookup is O(n) over a small
// window. Cheaper than adding a new route for one call site.
const messages = await api.chats.messages(chatId);
if (cancelled) return;
const msg = messages.find((m) => m.id === state.message_id);
if (!msg) {
setLoadError('Message not found');
return;
}
setContent(msg.content ?? '');
} catch (err) {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : 'failed to load message');
}
})();
return () => {
cancelled = true;
};
}, [chatId, state.message_id]);
async function copy() {
if (content === null) return;
try {
await navigator.clipboard.writeText(content);
setJustCopied(true);
setTimeout(() => setJustCopied(false), 1200);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'copy failed');
}
}
async function download() {
if (downloading) return;
setDownloading(true);
try {
const { url, path } = await api.messages.downloadArtifact(
chatId,
state.message_id,
'md',
);
// Trigger browser download from the returned URL. The endpoint stamps
// Content-Disposition: attachment so the click lands as a save.
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.click();
toast.success(`Saved to ${path}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'download failed');
} finally {
setDownloading(false);
}
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
{state.title || 'Markdown artifact'}
</span>
<button
type="button"
onClick={() => void copy()}
disabled={content === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Copy markdown source"
title="Copy"
>
{justCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
<button
type="button"
onClick={() => void download()}
disabled={downloading || content === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Download markdown"
title="Download"
>
<Download size={12} />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close artifact pane"
title="Close"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto px-4 py-3 text-sm">
{loadError ? (
<div className="text-destructive">Failed to load: {loadError}</div>
) : content === null ? (
<div className="text-muted-foreground">Loading</div>
) : (
<MarkdownRenderer content={content} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
// v1.14.x-html-artifact-panes: extracted from MessageBubble.tsx so both the
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
import { Children, cloneElement, isValidElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock';
import { sessionEvents } from '@/hooks/sessionEvents';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
// per Sam's design decision (2026-05-14).
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function isPathLike(s: string): boolean {
return s.includes('/');
}
function emitOpenFile(path: string): void {
sessionEvents.emit({ type: 'open_file_in_browser', path });
}
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!isPathLike(matchedText)) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={`${keyPrefix}-${idx}`}
type="button"
onClick={() => emitOpenFile(matchedText)}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (out.length === 0) return text;
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
const arr = Children.toArray(children);
return arr.map((child, i) => {
if (typeof child === 'string') {
return (
<span key={`${keyPrefix}-${i}`}>
{linkifyPaths(child, `${keyPrefix}-${i}`)}
</span>
);
}
if (isValidElement(child)) {
const el = child as ReactElement<{ children?: ReactNode }>;
if (el.type === 'code' || el.type === CodeBlock) return child;
const grandchildren = el.props.children;
if (grandchildren === undefined) return child;
return cloneElement(el, {
key: el.key ?? `linkified-${i}`,
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
});
}
return child;
});
}
const codeRenderer = (props: { children?: unknown; className?: string }) => {
const { children, className, ...rest } = props;
const text = String(children ?? '').replace(/\n$/, '');
const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n');
if (isBlock) {
return <CodeBlock code={text} lang={langMatch?.[1]} />;
}
return (
<code
{...rest}
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
>
{children as React.ReactNode}
</code>
);
};
export function MarkdownRenderer({ content }: { content: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: codeRenderer,
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
),
}}
>
{content}
</Markdown>
);
}

View File

@@ -1,16 +1,14 @@
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { api, ApiError } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel';
import { DoomLoopSentinel } from './DoomLoopSentinel';
import { CodeBlock } from './CodeBlock';
import { MarkdownRenderer } from './MarkdownRenderer';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
@@ -90,76 +88,20 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
summary_after_cap_failed: 'Summary after tool budget hit failed',
};
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
// per Sam's design decision (2026-05-14).
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
// v1.14.x-html-artifact-panes: MarkdownBody and its path-linkifier helpers
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
// panes can render assistant content with the same Shiki + remark-gfm setup.
function isPathLike(s: string): boolean {
return s.includes('/');
}
function emitOpenFile(path: string): void {
sessionEvents.emit({ type: 'open_file_in_browser', path });
}
// Split a plain string into a flat array of strings and clickable button
// nodes for path-shaped substrings. If no matches, returns the original
// string verbatim (no array wrapping).
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!isPathLike(matchedText)) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={`${keyPrefix}-${idx}`}
type="button"
onClick={() => emitOpenFile(matchedText)}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (out.length === 0) return text;
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
// Walk react-markdown children, linkifying string text nodes. Children of
// <code> nodes (CodeBlock and inline code) are left untouched — the regex
// shouldn't run inside code spans.
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
const arr = Children.toArray(children);
return arr.map((child, i) => {
if (typeof child === 'string') {
return (
<span key={`${keyPrefix}-${i}`}>
{linkifyPaths(child, `${keyPrefix}-${i}`)}
</span>
);
}
if (isValidElement(child)) {
const el = child as ReactElement<{ children?: ReactNode }>;
// Skip inline/block code — paths in code spans aren't link targets.
if (el.type === 'code' || el.type === CodeBlock) return child;
const grandchildren = el.props.children;
if (grandchildren === undefined) return child;
return cloneElement(el, {
key: el.key ?? `linkified-${i}`,
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
});
}
return child;
});
// Pane-header title derivation for a markdown artifact. Order matches the
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
// readable.
function deriveMarkdownTitle(content: string): string {
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
if (words) return words.slice(0, 80);
return 'Markdown artifact';
}
interface Props {
@@ -170,80 +112,6 @@ interface Props {
capHitInfo?: { position: number; isLatest: boolean };
}
function MarkdownBody({ content }: { content: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: (props) => {
const { children, className, ...rest } = props as {
children?: unknown;
className?: string;
};
const text = String(children ?? '').replace(/\n$/, '');
const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n');
if (isBlock) {
return <CodeBlock code={text} lang={langMatch?.[1]} />;
}
return (
<code
{...rest}
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
>
{children as React.ReactNode}
</code>
);
},
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
),
}}
>
{content}
</Markdown>
);
}
function StatsLine({ message }: { message: Message }) {
const tokens = message.tokens_used;
if (typeof tokens !== 'number' || tokens <= 0) return null;
@@ -337,6 +205,54 @@ function ActionRow({
const canRegen = isAssistant && message.status !== 'streaming';
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
const [openingPane, setOpeningPane] = useState(false);
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
// open the HTML pane variant; otherwise fall back to the markdown variant.
// Title derivation for markdown: first `# ` heading → first 6 words of the
// body → 'Markdown artifact' (mirrors the slug logic in
// services/artifacts.ts).
async function openInPane() {
if (openingPane || message.status === 'streaming') return;
setOpeningPane(true);
try {
try {
const payload = await api.messages.getHtmlArtifact(
message.chat_id,
message.id,
);
sessionEvents.emit({
type: 'open_html_artifact_pane',
state: {
chat_id: message.chat_id,
message_id: message.id,
title: payload.title,
},
});
return;
} catch (err) {
// 404 (no html_artifact part) is the expected fall-through path —
// markdown variant opens below. Any other error (network, 500) is
// a real failure; toast and bail rather than masquerading as markdown.
const status = err instanceof ApiError ? err.status : null;
if (status !== 404) {
toast.error(err instanceof Error ? err.message : 'open in pane failed');
return;
}
}
const title = deriveMarkdownTitle(message.content);
sessionEvents.emit({
type: 'open_markdown_artifact_pane',
state: {
chat_id: message.chat_id,
message_id: message.id,
title,
},
});
} finally {
setOpeningPane(false);
}
}
return (
<>
@@ -350,6 +266,18 @@ function ActionRow({
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{isAssistant && (
<button
type="button"
onClick={() => void openInPane()}
disabled={openingPane || message.status === 'streaming'}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Open in pane"
title="Open in pane"
>
<PanelRightOpen className="size-3" />
</button>
)}
{isAssistant && (
<button
type="button"
@@ -588,7 +516,7 @@ function SummaryCard({ message }: { message: Message }) {
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
<MarkdownBody content={message.content} />
<MarkdownRenderer content={message.content} />
</div>
)}
</div>
@@ -667,7 +595,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
{(hasContent || isStreaming) && (
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{hasContent ? <MarkdownRenderer content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}

View File

@@ -8,6 +8,8 @@ import { terminalsRegistry } from '@/lib/events';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -182,6 +184,7 @@ export function Workspace({
{panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
@@ -195,7 +198,7 @@ export function Workspace({
}
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
// are not drag-reorderable for now — keeps the layout grid simple.
const isChromeless = isSettings || isTerminal;
const isChromeless = isSettings || isTerminal || isArtifact;
return (
<div
key={pane.id}
@@ -318,6 +321,18 @@ export function Workspace({
label={terminalLabels.get(pane.id) ?? 'Terminal'}
active={idx === activePaneIdx}
/>
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
<MarkdownArtifactPane
chatId={pane.markdown_artifact_state.chat_id}
state={pane.markdown_artifact_state}
onClose={() => removePane(idx)}
/>
) : pane.kind === 'html_artifact' && pane.html_artifact_state ? (
<HtmlArtifactPane
chatId={pane.html_artifact_state.chat_id}
state={pane.html_artifact_state}
onClose={() => removePane(idx)}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}

View File

@@ -2,7 +2,14 @@
// across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list).
import type { Chat, ErrorReason, Project, Session } from '@/api/types';
import type {
Chat,
ErrorReason,
HtmlArtifactState,
MarkdownArtifactState,
Project,
Session,
} from '@/api/types';
import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent {
@@ -68,6 +75,19 @@ export interface OpenChatInActivePaneEvent {
chat_id: string;
}
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
// pane (or focuses an existing one keyed by message_id).
export interface OpenMarkdownArtifactPaneEvent {
type: 'open_markdown_artifact_pane';
state: MarkdownArtifactState;
}
export interface OpenHtmlArtifactPaneEvent {
type: 'open_html_artifact_pane';
state: HtmlArtifactState;
}
// Client-side event fired by the sidebar Settings button when a session is
// currently mounted. Session.tsx subscribes and calls
// panesHook.toggleSettingsPane() (open on first click, close on second).
@@ -154,6 +174,8 @@ export type SessionEvent =
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenMarkdownArtifactPaneEvent
| OpenHtmlArtifactPaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent
| ChatCreatedEvent

View File

@@ -154,6 +154,11 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_markdown_artifact_pane':
case 'open_html_artifact_pane':
// v1.14.x-html-artifact-panes: consumed by useWorkspacePanes; sidebar
// has no business with pane state.
return prev;
case 'open_settings_pane':
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
// Sidebar data is untouched.

View File

@@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { WorkspacePane } from '@/api/types';
import type {
HtmlArtifactState,
MarkdownArtifactState,
WorkspacePane,
} from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -43,6 +47,28 @@ function settingsPane(): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
// the pane row so the sessions.workspace_panes jsonb survives reload.
function markdownArtifactPane(state: MarkdownArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'markdown_artifact',
chatIds: [],
activeChatIdx: -1,
markdown_artifact_state: state,
};
}
function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'html_artifact',
chatIds: [],
activeChatIdx: -1,
html_artifact_state: state,
};
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
@@ -169,6 +195,50 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, [sessionId]);
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
// these per click. If a pane already exists for the same message_id, focus
// it instead of stacking a duplicate. Otherwise append (capped at MAX_PANES;
// settings panes don't count, matching addSplitPane's rule).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (
ev.type !== 'open_markdown_artifact_pane' &&
ev.type !== 'open_html_artifact_pane'
) {
return;
}
setPanes((prev) => {
const targetKind: WorkspacePane['kind'] =
ev.type === 'open_html_artifact_pane' ? 'html_artifact' : 'markdown_artifact';
const messageId = ev.state.message_id;
const existingIdx = prev.findIndex((p) =>
p.kind === 'markdown_artifact'
? p.markdown_artifact_state?.message_id === messageId
: p.kind === 'html_artifact'
? p.html_artifact_state?.message_id === messageId
: false,
);
if (existingIdx >= 0) {
setActivePaneIdx(existingIdx);
return prev;
}
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane =
ev.type === 'open_html_artifact_pane'
? htmlArtifactPane(ev.state)
: markdownArtifactPane(ev.state);
// Defensive: assert kind matches for the discriminated union.
if (newPane.kind !== targetKind) return prev;
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});
});
}, []);
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
// before saving (ephemeral per v1.9).
useEffect(() => {

View File

@@ -1,6 +1,6 @@
# BooCode v1.x — Roadmap
Last updated: 2026-05-22
Last updated: 2026-05-23
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
@@ -27,7 +27,7 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
-----
## Shipped (status as of 2026-05-22)
## Shipped (status as of 2026-05-23)
|Version |Theme |Tag |
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
@@ -72,9 +72,9 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
-----
### Shipped (v1.13.x — written 2026-05-22, retagged same day)
### Shipped (v1.13.x — strangler-fig closed 2026-05-23)
All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). Tip is `v1.13.14-skills-audit` (`0fa46cd`); the next batch is `v1.13.15-codecontext-synth` (this batch, tag pending). Tags in chronological order:
All v1.13.x batches use the `vMAJOR.MINOR.PATCH-slug` tag scheme adopted 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). The v1.13.x line ran 21 batches over a single intense window; the umbrella `v1.13` tag sits on `211e903` (same commit as `v1.13.20-drop-legacy-cols`), marking the strangler-fig closed. Tags in chronological order:
- `v1.13.0-ai-sdk-v6` — AI SDK v6 migration; `streamCompletion` adapter; `messages_with_parts` view; reasoning_parts end-to-end
- `v1.13.1-cleanup-bundle``statement_timeout='30s'`, alpha-sorted tool registry, 60s stuck-row sweeper, `experimental_repairToolCall` pass-through
@@ -93,113 +93,13 @@ All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 202
- `v1.13.14-skills-audit` — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md
- `v1.13.15-codecontext-synth` — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs; 32k payload-budget contract preserved)
- `v1.13.16-xml-parser` — Anthropic `<invoke>` parser support + Levenshtein-based unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names like `read_file`); xml-parser test coverage
- `v1.13.17-cross-repo-reads``request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extended with `extraRoots`; pause/resume reuses the `ask_user_input` mechanism
- `v1.13.18-codecontext-file-path``resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` arg the same way `target_dir` was; closes the silent-fail path the sidecar exhibited on relative paths
- `v1.13.19-html-artifact-panes` — pane-based artifact viewer with on-request HTML; `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; Markdown + HTML panes both open via "Open in pane" affordance; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`, `srcDoc`); CSP `connect-src 'none'`. Scope-revised mid-design from auto-bias-to-HTML to Markdown-default / HTML-on-request
- `v1.13.20-drop-legacy-cols` — final strangler-fig step. Drops `messages.tool_calls` + `tool_results` columns; 10 dual-write sites removed (recon caught 2 beyond the original roadmap inventory); `messages_with_parts` view simplified to parts-only subselects via `CREATE OR REPLACE` before the column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had a `RETURNING tool_calls, tool_results` clause; fixed via two-step UPDATE-then-SELECT-from-view. `Message` API type retains the fields — view synthesizes them from parts so the wire shape is unchanged
- `v1.13`**umbrella tag on the same commit as v1.13.20.** Marks the AI SDK v6 + parts-table migration complete
The remaining strangler-fig final step (drop `messages.tool_calls` + `tool_results` columns) is still pending under its old `v1.13.2` working name; will get a new tag slug when scoped.
## In flight / next (v1.13.x cleanup line)
Five more single-dispatch batches before the strangler-fig closes. Each ships independently with its own smoke and rollback surface. **Do not fold.** Order is locked:
### v1.13.8 — system-prompt prefix stability verify-and-measure (REFRAMED, 2026-05-22)
**Original plan:** add a `system_prompt_cache` DB table keyed by `(agent_id, project_id, skills_version)`, mtime-invalidated.
**Why reframed:** recon disproved the premise. `apps/server/src/services/system-prompt.ts:buildSystemPrompt` already runs over mtime-cached inputs at the file layer:
- BOOCHAT.md / BOOCODER.md cached in `system-prompt.ts:25` (`cachedGuidance`, keyed by mtime)
- global + per-project AGENTS.md cached in `agents.ts:245` (`safeStat` pattern, 60s TTL)
- `session.system_prompt` / `project.default_system_prompt` are DB scalars (byte-stable until edited)
- BASE_SYSTEM_PROMPT is a hardcoded template with `${projectPath}` interpolation
Output assembly is a microsecond pure-string concat with no I/O. Skills aren't in the prefix (runtime discovery via `skill_find`). Tools live in a separate request body field, alpha-sorted by v1.13.3. **In theory the prefix is already byte-stable across turns; nothing has measured it.**
**New scope — instrumentation only, no cache:**
1. SHA-256 fingerprint of `buildSystemPrompt`'s output logged per turn at `level=info`, msg `prefix-fingerprint`, with project_id / agent_id / session_id / prefix_hash / prefix_length / mtime fields.
2. Module-level `Map<sessionId, lastHash>` observer. On hash change for a known session → emit `prefix-drift` at `level=warn` with `prev_hash`, `new_hash`, and a field-level `changed_inputs` diff.
3. Unit-level byte-stability assertion in `system-prompt.test.ts`: two consecutive `buildSystemPrompt` calls with the same inputs return byte-identical strings.
**Decision criterion:** smoke 5 turns in a fresh session. 5 identical hashes + zero drift logs → close v1.13.8 as no-op, **drop the DB cache plan permanently**, move to v1.13.9. If drift surfaces → characterize the failure mode in a follow-up batch (the answer may not be a cache at all).
**Doctrine:** matches the v1.13.6 audit pattern. Don't add infrastructure without a proven cache miss. The v1.12.0 mtime caches at the input layer plus alpha tool ordering at the request body layer already address the load-bearing cache-stability surfaces.
**Dispatch brief:** `handoff_v1.13.8_prefix_verify.md`.
**Estimated:** ~95 LoC (system-prompt.ts + small `getAgentsMtimes` accessor in agents.ts + 3 new tests).
### v1.13.9 — compaction overflow trigger formula
opencode pattern: `0.85 * ctx_max` early trigger (not at 100% saturation). Reduces tail-loss risk and gives compaction a safer window. Tiny change but tied to v1.13.4's tier logic — sequence matters.
**Lift source:** `anomalyco/opencode` `session/overflow.ts`.
**Estimated:** ~30 LoC.
### v1.13.10 — per-tool token cost accounting
Rolling average per tool, surfaced in AgentPicker tooltip + agent-pick decisions. Backend tracks `(tool_name, prompt_tokens_in, completion_tokens_out)` per call; surfaces a 100-call rolling mean. Frontend reads it for tool-cost hints. **Depends on v1.13.7's `includeUsage` fix** — without real token numbers in DB rows, the rolling average is empty.
**Estimated:** ~250 LoC.
### v1.13.11 — WebSocket frame typing
Zod schemas validated both ends. Catches the recurring class of bug that drove the 2026-05-21 debugging spike (silent protocol drift). Upfront work that pays back every time the protocol changes. `chat_status`, `usage`, `parts_appended`, `session_workspace_updated`, `tool_running` — every frame gets a Zod schema, every send/receive site validates.
**Estimated:** ~300 LoC.
### v1.13.12 — skills audit pass (NEW, 2026-05-22)
**Goal:** apply the rules→recipes split (per Codeminer42 activation-gap data: plain skills invoke 6% in clean multi-turn, `CLAUDE.md`/`AGENTS.md` is 100% present) to BooCode's 7 vendored v1.12 skills. Sort each into: (a) move to `AGENTS.md` as always-true rule, (b) keep as recipe invoked via `/skill <name>`, (c) move bulky context into `references/` flat subdirectory inside the skill, (d) delete (Claude already does it reliably).
**Scope:**
1. **Audit each of the 7 vendored skills against the 4-way split.** Most workflow-rule content ("always do X before Y", "never do Z") moves to `AGENTS.md` since it should be 100% present. Recipe content ("here's how to scaffold a component", "here's the release checklist") stays as skill, gets `context: fork` if heavy.
1. **Adopt Anthropic best-practices conventions** for any skills that remain after audit: gerund names (`scaffolding-components`, not `component-helper`), SKILL.md ≤500 lines, references one level deep, third-person imperative voice, MCP tool references in `ServerName:tool_name` format, no Windows-style paths, no time-sensitive info, consistent terminology, no "voodoo constants."
1. **Run each remaining skill through the 4-step validation protocol** from `mgechev/skills-best-practices` (Discovery → Logic → Edge Case → Architecture Refinement) using a fresh Claude chat per step. Prompts are paste-ready; ~10 minutes per skill.
1. **Install `skillgrade` on Sam's host** (`npm i -g skillgrade`). For each remaining skill, write a minimal `eval.yaml` with 23 tasks and run `skillgrade --smoke` (5 trials, ~5 min) to confirm the skill triggers when expected and produces correct output. **Likely outcome: some skills show 020% trigger rate — confirms they belong in AGENTS.md, not as skills.**
1. **Document the rules→recipes split as a BooCode convention** in `BOOCODER.md` / `BOOCHAT.md`. Future-proofs against re-adding workflow rules as skills.
**Lift sources:**
- `blog.codeminer42.com/stop-putting-best-practices-in-skills/` — empirical 6%/33%/66%/100% invocation-rate data with Vercel-style multi-turn methodology. The activation-gap framing.
- `mgechev/skills-best-practices` (25 stars, MIT) — 4-step validation protocol with paste-ready prompts. Directory structure conventions.
- `mgechev/skillgrade` (132 stars, MIT) — agent-agnostic skill eval framework. `eval.yaml` task+grader schema. Smoke/reliable/regression presets.
- `platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices` — canonical Anthropic standard. 500-line ceiling, gerund naming, progressive disclosure patterns, MCP tool reference format, verification checklist.
**Dependencies:** none (the 7 v1.12 skills already exist; this is an audit pass on shipped material). Can ship at any point in the v1.13.x line.
**Estimated:** zero code changes, ~one evening of audit work, plus skillgrade install. Per-skill eval.yaml authoring is ~30 min per skill including the 4-step validation. Total roughly 56 hours of focused work for all 7 skills.
### v1.13.2 — drop legacy columns (final phase of strangler-fig)
**Wait at least one week of production traffic on v1.13.1 before shipping.** The dual-write is rollback insurance. Drop the columns and that rollback is gone.
**Verification query before shipping:**
```sql
SELECT
COUNT(*) FILTER (WHERE m.tool_calls IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_call'
)) AS missing_tool_call_parts,
COUNT(*) FILTER (WHERE m.tool_results IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_result'
)) AS missing_tool_result_parts
FROM messages m
WHERE m.created_at > '2026-05-22'::timestamptz;
```
Both columns must read 0.
**Scope (~150 LoC, mostly deletions):**
1. Remove dual-write from every v1.13.0 site: `tool-phase.ts` (3 sites), `finalizeCompletion`, `skills.ts` (2 sites), `messages.ts` answer flow, `chats.ts` (fork). Keep only the parts write.
1. Simplify `messages_with_parts` view — drop COALESCE fallbacks since legacy columns are about to disappear.
1. `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results`.
1. Remove `tool_calls`/`tool_results` fields from `Message` API type. API boundary unchanged (frontend already reads parts-derived values).
1. Drop the stale `messages_status_check` cleanup DO block from v1.12.1 schema if still present.
1. Update test fixtures in `inference.test.ts` and `compaction.test.ts` to construct parts instead of inline `tool_calls: null, tool_results: null` literals. ~30 fixture rewrites.
After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — Sam's call).
The v1.13.x line is closed. Three batches still sit in the **In flight** column conceptually but none of them are v1.13.x scope: **live-smoke of v1.13.19** (manual browser exercise of the artifact panes — five minutes, independent), and the two v1.14 branches below. Independent siblings (`v1.14.x-mcp`, `v1.14.x-html`, `v1.16`) can ship in any order relative to v1.14 itself.
-----
@@ -263,45 +163,52 @@ After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — S
-----
## v1.14.x-html — HTML artifacts in BooChat (NEW, 2026-05-22)
## v1.14.x-html — pane-based artifact viewer with Markdown + HTML (REVISED, 2026-05-23)
**Goal:** integrate Thariq Shihipar's "HTML > Markdown for agent output at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026) into BooChat. Bias the model toward HTML for outputs >100 lines: information density, visual clarity, interactive controls (sliders/knobs/SVG diagrams/side-by-side comparisons), shareability. BooChat already renders into a webview, so the surface fit is unusually good.
**Goal:** every assistant message gets an "Open in pane" affordance that renders it as an artifact — Markdown by default (the model's normal output), HTML only when the user explicitly asks for it (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Both artifact types open in BooChat's existing workspace splitter. Markdown panes have **Copy** (raw source) + **Download** (`.md`); HTML panes have **Download** (`.html`) only. No inline iframe preview — artifacts are pane-only.
Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026), but scoped down from that post's "auto-bias to HTML for >100 lines" recommendation: Markdown stays the default everywhere, HTML is an on-request rendering target for cases where interactive controls / diagrams / side-by-side layouts pay off.
**Scope:**
1. **Model-side prompting** (no code change yet, just AGENTS.md guidance):
- Add HTML-bias rule to global `AGENTS.md`: "For outputs >100 lines, default to a self-contained `<!DOCTYPE html>...</html>` artifact unless the user explicitly asks for Markdown. For outputs <100 lines or for short conversational replies, stay in Markdown."
- Reasoning shown in the rule: HTML carries diagrams, tabs, illustrations, code-with-syntax-highlighting, interactive controls, mobile-responsive layouts. Markdown is restrictive at any length.
- Cite Thariq's blog post in the rule comment so future audit passes know where it came from.
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available.
1. **Three render targets (Sam's pick: "3 with a download"):**
- **Inline preview** in the chat stream: small sandboxed iframe (~400px tall), renders the artifact next to where it was streamed. Default size, click-to-expand.
- **Open in pane**: button on the inline preview opens the artifact in a full-height pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
- **Download**: button writes the artifact to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.html` (path-guarded same as native write tools), surfaces an OS download link via the existing file-serving path. Filename slug derived from artifact title.
1. **Security stance — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so Thariq's interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does. **None of Thariq's blog examples need the relaxed permissions** — they're all client-side.
1. **Frontend rendering** (`apps/web/src/components/HtmlArtifactPart.tsx`):
- Inline preview: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="..." />` with the strict-sandbox attributes above.
- "Open in pane" button: dispatches workspace-pane action with `{type: 'html_artifact', message_part_id, html_content}`.
- "Download" button: POST to new endpoint `/api/chats/:id/artifacts/:part_id/download` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` with the chat. Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
1. **Token-budget guard.** Single artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets."
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles (no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font) as conventions inlined in the HTML-bias AGENTS.md rule. The init/bundle scripts are out of scope.
1. **Model-side prompting** (no code change, just AGENTS.md guidance):
- Add HTML-on-request rule to global `AGENTS.md`: "Stay in Markdown by default for all outputs, 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 a dashboard', 'build a diagram')."
- Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics.
- Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from.
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
- **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons.
- **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect.
1. **Download path & filename slug.** Both formats write to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>` (path-guarded same as native write tools), and surface an OS download link via the existing file-serving path.
- Markdown slug: derived from the message's first heading (`# ...`) if present, else the first 6 words of the message body, lowercased + hyphenated.
- HTML slug: derived from the artifact's `<title>` tag if present, else first `<h1>`, else first 6 words of the inner text. Same lowercase-hyphen treatment.
1. **Security stance for HTML pane — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does.
1. **Frontend components:**
- `apps/web/src/components/MarkdownArtifactPane.tsx` — pane shell + header (Copy + Download) + Markdown render reusing the existing component.
- `apps/web/src/components/HtmlArtifactPane.tsx` — pane shell + header (Download only) + `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />`.
- `MessageBubble.tsx` — add "Open in pane" affordance to every assistant message footer. Dispatches workspace-pane action `{type: 'markdown_artifact' | 'html_artifact', message_id, html_content?}`. When the message has an `html_artifact` part, the affordance opens as an HTML pane; otherwise it opens as a Markdown pane.
- Download button → POST to new endpoint `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` (for HTML) or are derived on-demand from the assistant message's content (for Markdown). Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
1. **Token-budget guard.** Single HTML artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets." Markdown artifacts have no separate cap — they're bounded by the existing message-size envelope.
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles as conventions inlined in the HTML-on-request AGENTS.md rule. The init/bundle scripts are out of scope.
**Lift sources:**
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — the pattern, the use-case taxonomy (specs/code-review/design/reports/custom editors), the design philosophy.
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — design conventions and use-case taxonomy (specs/code-review/design/reports/custom editors). The "auto-bias for >100 lines" recommendation is deliberately NOT lifted.
- HTML iframe sandbox spec (web platform standard, no license issues).
- `anthropics/skills/web-artifacts-builder` — design-principle reference only ("avoid AI slop" rules). **Do not vendor the toolchain.**
**Dependencies:** v1.13 merged (`message_parts` table is where artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
**Dependencies:** v1.13 merged (`message_parts` table is where HTML artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
**Estimated:** ~400 LoC. Roughly half backend (detection + part-kind extension + download endpoint + path-guard integration), half frontend (HtmlArtifactPart component + pane integration + download button wiring).
**Estimated:** ~400 LoC. Roughly half backend (HTML detection + part-kind extension + download endpoint + path-guard integration + Markdown slug derivation) and half frontend (two artifact-pane components + MessageBubble affordance + pane integration + download wiring).
**Schema addition:**
- `message_parts.kind` CHECK constraint adds `'html_artifact'` to the allowed set.
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Highest user-visible payoff of any v1.13.x/v1.14.x batch (transforms what the model can produce, not just how the backend handles it).
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Pane-based artifact view is a structural UX improvement (full-height read surface for long replies, durable download path) on top of the HTML-on-request rendering capability.
**Shipped as `v1.13.19-html-artifact-panes` on 2026-05-23.** Two scope-revisions during impl: (a) the HTML-on-request rule landed in `BOOCHAT.md` (always-true rules layer), not `data/AGENTS.md` (per-agent registry) — per BOOCHAT.md's own convention block. (b) Pane state stayed reference-only — `{chat_id, message_id, title}` — content fetched on mount via the existing chat-messages endpoint (Markdown) and a new `GET /api/chats/:id/messages/:msg_id/html_artifact` (HTML). Storing content in pane state would have ridden 1MB blobs through the `session_workspace_updated` WS frame and bloated the jsonb column on multi-pane sessions. Defense-in-depth additions beyond the original proposal: `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` on the GET serve route, and `assertArtifactsDirSafe` realpaths the artifacts dir after `mkdir` to close a symlink-escape gap that would otherwise let a planted symlink under `.boocode/artifacts/` route writes outside the project root. Smoke not run pre-tag; first deploy is the smoke.
-----
@@ -501,8 +408,12 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
- **v1.13.12-ws-schemas:** none (Zod schemas + wrappers in TS, no DB)
- **v1.13.13-ws-publish:** none (publish-site conversion + protocol-drift fix in `compaction.ts`, no DB)
- **v1.13.14-skills-audit:** none (skills + AGENTS.md migration into git via `.gitignore` negation patterns; no DB)
- **v1.13.15-codecontext-synth (this batch, tag pending):** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
- **(column drop, pending — old working name v1.13.2):** drop `messages.tool_calls`, `messages.tool_results`; simplify `messages_with_parts` view
- **v1.13.15-codecontext-synth:** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
- **v1.13.16-xml-parser:** none (parser change + new `tool-suggestions.ts` helper in TS, no DB)
- **v1.13.17-cross-repo-reads:** `sessions.allowed_read_paths text[] NOT NULL DEFAULT ARRAY[]::text[]` (per-session cross-repo read grants)
- **v1.13.18-codecontext-file-path:** none (path resolver in `codecontext_client.ts`, no DB)
- **v1.13.19-html-artifact-panes:** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value (same v1.13.15 pattern)
- **v1.13.20-drop-legacy-cols:** `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results` (the strangler-fig's final phase). `messages_with_parts` view rewritten to parts-only subselects via `CREATE OR REPLACE VIEW` BEFORE the drops (Postgres ordering constraint). v1.12.1 `messages_status_check`/`messages_role_check` cleanup block removed (one-shot effective long ago)
- **v1.14:** `agents.steps` column (or AGENTS.md parser extension; no DB if file-only)
- **v1.14.x-mcp (NEW):** none — single-server MCP-client PoC is config-only at first, no schema change
- **v1.14.x-html (NEW):** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
@@ -612,7 +523,17 @@ Earlier May 18 chat recommended Option A (thin orchestration shell over OpenCode
### v1.13.x cleanup line locked (2026-05-22)
After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug` form is **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth ✅ → v1.13.16-xml-parser ✅ → column drop (final, pending — old working name v1.13.2)**. **Do not fold.** Smoke isolation matters: each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches.
The v1.13.x cleanup line shipped 21 batches over a single intense window in `vMAJOR.MINOR.PATCH-slug` form: **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth ✅ → v1.13.16-xml-parser ✅ → v1.13.17-cross-repo-reads ✅ → v1.13.18-codecontext-file-path ✅ → v1.13.19-html-artifact-panes ✅ → v1.13.20-drop-legacy-cols ✅** → umbrella `v1.13`. **Do not fold** was the discipline — each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches. Held throughout; CHANGELOG.md is the per-tag canonical record.
### Numbering and scope-revision discipline during v1.13.x (2026-05-23)
The v1.13.x line ran 21 batches; planned-vs-shipped numbering diverged for half of them, and three batches had material scope revisions mid-design. Pattern that emerged and is worth carrying forward:
- **Patch numbers are assigned at ship time, not in planning.** The proposal/openspec folder uses a planning slug (e.g. `v1.14.x-html-artifact-panes`); the final tag uses a concrete patch monotonic-per-minor (e.g. `v1.13.19-html-artifact-panes`). Avoids the "we said v1.13.8 but actually shipped seventh" confusion that ate two retrospective passes on the roadmap.
- **Scope-revise the proposal before dispatching.** v1.13.19-html-artifact-panes flipped mid-design from "auto-bias to HTML for >100 lines" to "Markdown default, HTML on request" — the proposal got rewritten before recon. Far cheaper than discovering the wrong approach in implementation. The "brainstorm before code" discipline.
- **Recon-first dispatch finds 2530% more sites than the roadmap inventory.** v1.13.20 recon caught 2 extra dual-write sites (chats.ts fork-clone + 2 in tool-phase.ts) and an extra fixture file. v1.13.19 recon corrected which `Pane` type to extend. Skipping recon to save a step doesn't.
- **Adversarial reviews catch what test suites miss.** v1.13.19 reviewer caught silent error-promotion in `openInPane`; v1.13.20 reviewer caught a `RETURNING tool_calls, tool_results` clause that crashes in production but slips past green tests. Both are routine code-reviewer dispatches; both saved a same-day hotfix. **Two-stage review (spec then quality) is non-negotiable when shipping fast.**
- **Calendar-gated waits are production-safety hedges that don't apply here.** v1.13.20 originally said "wait one week of production traffic on v1.13.1 before dropping columns." Sam called it out: single-user self-hosted, no rollback constraint, code-level audit + DB COUNT query is the actual safety check. Dropped the wait. Don't ritualize production-grade hedges in a single-user codebase.
### v1.13 retrospective (what shipped)
@@ -625,7 +546,21 @@ After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug
- **v1.13.5** — opencode truncate.ts port + view_truncated_output tool. Tagged on `f8fc5db`.
- **v1.13.6** — compaction head-assembly audit + reasoning fix. Closed the Q3 reasoning gap from v1.13.1-C. Tagged on `81d837c`.
- **v1.13.7** — stability bundle: includeUsage fix + trim guards + payload filter + budget bump. Surfaces tokens (closes a v1.13.1-A latent regression where `result.usage` resolved empty), kills the empty-bubble + ActionRow noise between tool calls on single-tool-call turns, and unblocks Continue after cap-hit on chats that have trailing empty/failed assistants.
- **v1.13.2 deferred** — at least one week of production traffic on v1.13.1 before dropping legacy columns. Dual-write is rollback insurance.
- **v1.13.6 (numbering re-aligned)** — system-prompt prefix verify-and-measure batch (originally numbered v1.13.8 in the planning doc). Reframed mid-design from "add a `system_prompt_cache` table" to "instrument-and-prove" after recon showed input-layer mtime caches already achieve byte-stable prefixes. Smoke confirmed zero drift across 5 turns; dropped the planned DB table.
- **v1.13.7-compaction-trigger** — 0.85×ctx_max early trigger (planned as v1.13.8 / v1.13.9).
- **v1.13.8-tool-cost** — `tool_cost_stats` SQL view + AgentPicker tooltip surfacing (planned as v1.13.9 / v1.13.10).
- **v1.13.9-agentlint** — instruction-file AgentLint pass (planned as part of v1.13.11 skills audit; split into its own batch when it grew larger than fitting).
- **v1.13.10-openspec** — `openspec/changes/<slug>/{proposal,tasks,design}.md` batch-doc structure adoption.
- **v1.13.11-tools** — tiered tool loading via `BOOCODE_TOOLS=core|standard|all` env (~30 LoC; was a far-future optional item, slotted in).
- **v1.13.12-ws-schemas** + **v1.13.13-ws-publish** — Zod schemas for all 27 wire-format frames, `publishFrame`/`publishUserFrame` wrappers, ~80 publish sites converted (planned as v1.13.10 / v1.13.11).
- **v1.13.14-skills-audit** — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md. Codeminer42 rules-vs-recipes framing applied.
- **v1.13.15-codecontext-synth** — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs under 32k payload budget).
- **v1.13.16-xml-parser** — Anthropic `<invoke>` parser support + Levenshtein unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names).
- **v1.13.17-cross-repo-reads** — `request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extraRoots; reuses the `ask_user_input` pause/resume mechanism.
- **v1.13.18-codecontext-file-path** — `resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` the same way `target_dir` was already resolved.
- **v1.13.19-html-artifact-panes** — pane-based artifact viewer (Markdown default + HTML on request). Scope-revised mid-design from auto-bias-HTML to Markdown-default. `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`); CSP `connect-src 'none'` + `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` defense-in-depth. Pane state is reference-only — content fetched on mount to keep jsonb small.
- **v1.13.20-drop-legacy-cols** — final strangler-fig step. 10 dual-write sites stripped (recon caught 2 beyond the original v1.13.2 inventory). `messages_with_parts` simplified to parts-only via `CREATE OR REPLACE` before column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had `RETURNING tool_calls, tool_results` — fixed via two-step UPDATE-then-SELECT-from-view. `Message` type retains the fields, populated by the view. v1.12.1 cleanup DO block removed.
- **`v1.13` umbrella** — tagged on the same commit as v1.13.20 (`211e903`). AI SDK v6 + parts-table migration complete.
### Pre-v1.13 architectural decisions (still load-bearing)

View File

@@ -59,6 +59,7 @@ Rules:
## Refactorer
---
temperature: 0.3
steps: 5
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
---
@@ -97,6 +98,7 @@ Codecontext usage:
## Architect
---
temperature: 0.5
steps: 20
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
description: Designs new features, modules, or architectural changes. Outputs a build plan.
---

View File

@@ -0,0 +1,46 @@
# v1.13.18 — design notes
## Resolver contract
`resolveProjectPath(projectRoot: string, rawPath: string): Promise<string>`
1. **Trim check**`rawPath.trim() === ''` throws `INVALID_FILE_PATH`. This is defensive code; the Zod `.trim().min(1)` in required-`file_path` wrappers catches empty paths before the shim. For optional-`file_path` wrappers, the caller guard `file_path.trim() !== ''` prevents `resolveProjectPath` from being reached at all when the string is empty or whitespace-only.
2. **Absolute branch**`isAbsolute(rawPath)` uses the candidate as-is; otherwise `resolve(projectRoot, rawPath)` anchors it.
3. **realpath with ENOENT fallthrough**`realpath(candidate)` resolves symlinks and normalises the path. On `ENOENT` (file doesn't exist), the un-realpathed absolute is used as the forwarded value. Any other error (EACCES, EBADF, etc.) re-throws immediately.
4. **Escape check**`resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)`. Uses `path.sep` not a string literal `'/'` so the check is platform-safe (Windows posture, forward compatibility).
5. **Return** — the resolved absolute path, which replaces `req.args['file_path']` in `argsToSend`.
The guard in `callCodecontext` only invokes `resolveProjectPath` when `typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== ''`. Wrappers that don't include `file_path` in their args object are unaffected.
## Error-shape parity rationale
The `target_dir` escape error message is: `target_dir <targetDir> escapes project root <resolvedProject>`.
The `file_path` escape error message is: `file_path <rawPath> escapes project root <projectRoot>`.
The template is byte-identical except for the field name prefix. This is intentional:
- The existing escape error regex `/escapes project root/` used in tests and potentially in log alerting applies to both error types without special-casing.
- A model receiving either error message can apply the same self-correction: the escape check is the same invariant (`path starts with project root + sep`), so the same remediation applies (use a path inside the project).
- Keeping the shapes uniform reduces cognitive overhead when reading logs that mix both error types.
## ENOENT fallthrough rationale
When a `file_path` doesn't exist on disk, `resolveProjectPath` forwards the un-realpathed absolute path to the sidecar. The sidecar responds with its own error: `"file not found: <path>"` (or `"File not found in graph: <path>"`).
The alternative — re-implementing the "file not found" check in the resolver — would:
1. Diverge from the sidecar's canonical error language, producing two different "not found" messages depending on whether the file existed at realpath time.
2. Conflict with future scenarios where the sidecar's graph is stale (file existed at index time but was deleted, or vice versa). The sidecar's error is always authoritative.
3. Add no user-visible value: the model can self-correct on either "file not found" message by checking the path.
The resolver's job is path safety (scope enforcement) and path normalisation (relative → absolute). Existence checking is the sidecar's job.
## `codecontext_tools.test.ts` impact
The existing `get_file_analysis forwards file_path` test in `codecontext_tools.test.ts` passes `'apps/server/src/index.ts'` as a relative `file_path` and asserts it reaches the wire unchanged. After this fix the path is resolved to `join(projectDir, 'apps/server/src/index.ts')`. The test now fails.
This test file is outside this batch's allowed file list. Sam should update the test assertion to expect the resolved absolute path, or create the file in the test tmpdir and assert the full resolved path. The fix is a one-liner: change `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` in the `expect(body).toMatchObject(...)` call, and create the file before the call (so realpath succeeds).

View File

@@ -0,0 +1,36 @@
# v1.13.18 — codecontext file_path resolver
Fixes a silent failure that caused all four `file_path`-taking codecontext wrappers to return "file not found" whenever the model passed a relative path.
## Why
BooCode's codecontext sidecar (`codecontext_client.ts`) already realpath-resolves `target_dir` before forwarding it to the HTTP shim. It did not do the same for `file_path`. The sidecar's internal file index is keyed on absolute paths, so any relative path from the model produced a JSON error response:
```
{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}
```
This was observed repeatedly in the 2026-05-22 docker logs (17:56 UTC window) — the model passed relative paths on every `get_file_analysis` tool call and received no useful output, burning tool budget on dead calls.
## Scope
Four wrappers take a `file_path` argument:
- `tools/codecontext/get_file_analysis.ts``file_path` required
- `tools/codecontext/get_symbol_info.ts``file_path` optional
- `tools/codecontext/get_dependencies.ts``file_path` optional
- `tools/codecontext/get_semantic_neighborhoods.ts``file_path` optional
Fix lands in one place: `callCodecontext` in `codecontext_client.ts`. A new `resolveProjectPath` helper is inserted at the args-spread site and invoked whenever `file_path` is present and non-empty. All four wrappers benefit automatically; no per-wrapper edits required.
Zod `.trim()` is added to all four `file_path` schema entries so that whitespace-padded paths from the model are cleaned before they reach the resolver.
## Decision: single resolver over per-wrapper edits
Four wrappers, one shared code path. Per-wrapper edits would require four edits and make it easy to miss one. The `callCodecontext` shim already owns `target_dir` validation; `file_path` validation belongs there too for symmetry.
## Non-goals
- No changes to the `target_dir` resolver — it already works correctly.
- No extension to wrappers that do not take `file_path` (`get_codebase_overview`, `get_framework_analysis`, `search_symbols`, `watch_changes`).
- No fix for the unrelated RPC errors and Go map-race warnings visible in the codecontext sidecar logs — those are upstream bugs.

View File

@@ -0,0 +1,57 @@
# v1.13.18 tasks
## B1 — Backups
- [x] `apps/server/src/services/codecontext_client.ts.bak-v1.13.18-20260522`
- [x] `apps/server/src/services/tools/codecontext/get_file_analysis.ts.bak-v1.13.18-20260522`
- [x] `apps/server/src/services/tools/codecontext/get_symbol_info.ts.bak-v1.13.18-20260522`
- [x] `apps/server/src/services/tools/codecontext/get_dependencies.ts.bak-v1.13.18-20260522`
- [x] `apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts.bak-v1.13.18-20260522`
## B2 — Resolver implementation in `codecontext_client.ts`
- [x] Import `isAbsolute`, `resolve`, `sep` from `node:path` (alongside existing `join`)
- [x] Add `resolveProjectPath(projectRoot, rawPath)` helper — trim check, isAbsolute branch, realpath with ENOENT fallthrough, escape check
- [x] Wire into `callCodecontext` at args-spread site — guard on `file_path.trim() !== ''`
- [x] Error-shape parity verified: `file_path <raw> escapes project root <root>` mirrors `target_dir <dir> escapes project root <root>`
## B3 — Zod `.trim()` on wrapper schemas
- [x] `get_file_analysis.ts``z.string().trim().min(1)`
- [x] `get_symbol_info.ts``z.string().trim().optional()`
- [x] `get_dependencies.ts``z.string().trim().optional()`
- [x] `get_semantic_neighborhoods.ts``z.string().trim().optional()`
## B4 — Tests
- [x] Added `describe('callCodecontext — file_path resolution', ...)` to `codecontext_client.test.ts`
- [x] Case 1: relative path resolves to absolute inside project root
- [x] Case 2: absolute path inside project root passes through
- [x] Case 3: relative escape (`../../etc/passwd`) rejected with `escapes project root`
- [x] Case 4: absolute path outside project root rejected
- [x] Case 5: nonexistent file (ENOENT) forwarded as un-realpath'd absolute
- [x] Case 6: empty string skipped by guard (treated as not provided)
- [x] Case 7: wrapper without `file_path` — resolver not invoked, no `file_path` in wire body
- [x] All 17 tests in `codecontext_client.test.ts` pass
## B5 — Typecheck + smoke
- [x] `npx tsc --noEmit -p apps/server` — 0 errors
- [x] Before-fix smoke (relative path): `{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}`
- [x] Before-fix smoke (absolute path): returns `Lines: 330 / Symbols: 48` as expected
## B6 — Test asserting old buggy behavior updated
- [x] `apps/server/src/services/__tests__/codecontext_tools.test.ts` — assertion at line 73 updated from `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` to match the new resolved-absolute contract.
## B7 — OpenSpec docs
- [x] `openspec/changes/v1.13.18-codecontext-file-path/proposal.md`
- [x] `openspec/changes/v1.13.18-codecontext-file-path/tasks.md`
- [x] `openspec/changes/v1.13.18-codecontext-file-path/design.md`
## B8 — Review-pass defence-in-depth (P2 fixes from adversarial review)
- [x] `codecontext_client.ts:71` — absolute branch now goes through `resolve()` to normalise dot-segments. Closes the ENOENT-fallthrough escape gap where `<projectRoot>/../etc/x` would prefix-match `<projectRoot>/` literally.
- [x] `codecontext_client.test.ts` — added Case 8 (absolute path with `..` resolving outside root, ENOENT branch) and Case 9 (in-project symlink whose target sits outside root). 19 tests pass.
- [x] Updated `resolveProjectPath` docstring to reflect the new normalisation step.

View File

@@ -0,0 +1,126 @@
# v1.13.20-drop-legacy-cols — drop messages.tool_calls + messages.tool_results
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. After this batch, `message_parts` is the only source of truth for tool-call and tool-result data.
Tag `v1.13` (umbrella) ships on the same commit per the original roadmap entry.
## Why
v1.13.0 (AI SDK v6 migration) introduced `message_parts` as the new canonical store for tool calls, tool results, reasoning, text, synthesis, and now html_artifact. To stay safe during the migration, every write site also dual-wrote to the legacy `messages.tool_calls` / `messages.tool_results` JSON columns, and `messages_with_parts` view COALESCEs over both. Reads have been migrated; dual-writes are pure overhead at this point.
Verification query (per the original v1.13.2 plan) returns `0 / 0` orphan rows. Today's DB is also empty (0 messages on the live instance), so the COUNT query alone is weakly informative — the safety check shifts to a code-level audit: every dual-write site listed in the v1.13.2 roadmap entry must be located and its parts-write half kept, JSON-column half removed.
## Scope
### S1. Remove dual-write from every site
Per the v1.13.2 roadmap entry, dual-writes live at:
- `services/inference/tool-phase.ts` — 3 sites
- `services/inference/error-handler.ts``finalizeCompletion`
- `routes/skills.ts` — 2 sites
- `routes/messages.ts` — answer flow
- `routes/chats.ts` — fork flow
Implementer must grep for every UPDATE / INSERT that touches `tool_calls` or `tool_results` columns and verify it has a paired `insertParts(...)` call. Keep the parts write, remove the column write. If a site only writes to the JSON column with no parts pair — STOP and escalate (would indicate a bug in the v1.13.0 dual-write rollout we haven't caught).
### S2. Simplify `messages_with_parts` view
Current view COALESCEs parts-table rows over legacy JSON columns to support pre-v1.13.0 history. After this batch, the JSON columns no longer exist — drop the COALESCE fallbacks. The view should read only from `message_parts` joined to `messages`.
### S3. Drop the columns
```sql
ALTER TABLE messages DROP COLUMN tool_calls;
ALTER TABLE messages DROP COLUMN tool_results;
```
Idempotent via `IF EXISTS`. Apply unconditionally on startup (matches the rest of `schema.sql`'s shape).
### S4. Remove from API types
`Message` interface in `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts` — drop `tool_calls?` and `tool_results?` fields. The API boundary is unchanged because every consumer already reads parts-derived values through `messages_with_parts`. Mirror byte-for-byte.
### S5. Drop the stale `messages_status_check` cleanup DO block from v1.12.1 if still present
Per the v1.13.2 roadmap entry, there's a v1.12.1 `DO $$ DROP CONSTRAINT messages_status_check` block that was meant to clean up the old anonymous constraint. If still present in `schema.sql`, remove — it's been one-shot effective.
### S6. Update test fixtures
`inference.test.ts` and `compaction.test.ts` (and any other test file the grep finds) construct Message-shaped fixtures with `tool_calls: null, tool_results: null` literals. Rewrite ~30 fixtures to construct via `message_parts` rows where the test actually exercises tool calls. For tests that don't exercise tool calls at all, just drop the now-absent fields.
`partsFromAssistantMessage` and `partsFromToolMessage` helpers in `parts.ts` currently take `tool_calls` and `tool_results` as args (because that's what the legacy Message shape carried). Keep their input shapes — they're useful constructors. The change is at the call sites, not the helpers.
## Non-goals
- **No changes to `message_parts` schema.** It's correct as-is.
- **No changes to the `messages_with_parts` view name or interface.** Just the implementation simplifies.
- **No removal of `partsFromAssistantMessage` / `partsFromToolMessage`.** They're useful as constructors; their job becomes producing parts from raw ToolCall/ToolResult objects, not from a legacy Message row.
- **No frontend changes beyond the type mirror.** Web reads parts via `messages_with_parts` already.
- **No reads from the legacy columns in any code path.** Verify with grep.
## Hard rules
- No git commits during dispatch. Sam commits manually (handled by controller after all dispatches done).
- Backups: every modified file → `.bak-v1.13.20-20260523`.
- TS strict, no `any`.
- No new deps.
- Schema migration: additive-or-destructive but idempotent (`IF EXISTS` on the column drops).
- Run the full server test suite after — must be green.
- Frontend: `tsc -p apps/web/tsconfig.app.json --noEmit` + `pnpm -C apps/web build` clean.
## Stop checkpoints
1. **After recon** (grep-driven inventory of dual-write call sites + read sites still touching the legacy columns): stop, hand back inventory. The roadmap listed 7+ sites; verify nothing's been missed.
2. **After code edits, before schema migration**: stop, hand back diff + test results. Confirm the parts write at every former dual-write site still happens.
3. **After schema migration applies in dev**: stop, run tests, run a fresh `applySchema()` cycle (boot twice), confirm idempotent.
## Smoke plan
1. **Fresh boot.** Restart the boocode container, confirm `applySchema()` completes without error.
2. **Idempotent boot.** Restart again, confirm no error on the second pass (column DROP IF EXISTS is a no-op).
3. **Send a chat that triggers a tool call.** Confirm:
- Assistant message lands with content + reasoning + tool_call parts (all in `message_parts`).
- Tool result lands as a `tool_result` part.
- `messages_with_parts` returns the same shape the frontend expects (verify by reading the live chat in the UI).
4. **DB inspection.** `\d messages` — confirm `tool_calls` and `tool_results` columns are gone.
5. **Compaction roundtrip.** Trigger a compaction-eligible turn (long context); confirm the rolling summary still anchors correctly and uses parts as input.
## Done when
- All dual-write sites converted to parts-only writes.
- View simplified, columns dropped, types updated.
- Test suite green.
- Frontend typecheck + build clean.
- Smoke green.
- Tagged `v1.13.20-drop-legacy-cols` AND the umbrella `v1.13` on the same commit.
- CHANGELOG.md entry + roadmap retrospective bullet.
## Files expected to touch
**Backend:**
- `apps/server/src/schema.sql` — DROP columns + simplify view + remove v1.12.1 cleanup block
- `apps/server/src/services/inference/tool-phase.ts` — remove 3 dual-write sites
- `apps/server/src/services/inference/error-handler.ts` — remove dual-write in `finalizeCompletion`
- `apps/server/src/routes/skills.ts` — remove 2 dual-write sites
- `apps/server/src/routes/messages.ts` — remove dual-write in answer flow
- `apps/server/src/routes/chats.ts` — remove dual-write in fork
- `apps/server/src/types/api.ts` — drop `tool_calls?` / `tool_results?` from Message
- `apps/server/src/services/__tests__/inference.test.ts` — fixture rewrites
- `apps/server/src/services/__tests__/compaction.test.ts` — fixture rewrites
- `apps/server/src/services/__tests__/parts.test.ts` — likely some fixture updates
- `apps/server/src/services/__tests__/tool_cost_stats.test.ts` — likely some fixture updates
- `apps/server/src/services/__tests__/system-prompt.test.ts` — likely some fixture updates
**Frontend:**
- `apps/web/src/api/types.ts` — mirror Message change
**Docs:**
- `BOOCHAT.md` — no change expected (rules don't mention the legacy columns)
- `boocode_roadmap.md` — retrospective bullet
- `CHANGELOG.md` — new section
- `CLAUDE.md` — drop the v1.13.0 dual-write notes that no longer apply (audit the surrounding paragraphs)
## Estimate
~150 LoC net (mostly deletions). Mechanical work — same per-batch shape as v1.13.18.

View File

@@ -0,0 +1,104 @@
# v1.13.20-drop-legacy-cols tasks
## B1 — Recon (STOP after this step)
- [ ] Grep `apps/server/src/**/*.ts` for every `tool_calls` and `tool_results` mention. Categorize each hit as:
- **dual-write** (an UPDATE / INSERT that writes the JSON column)
- **read** (a SELECT that reads the JSON column, or code that destructures it from a row)
- **type-only** (interface / type field reference)
- **test fixture** (literal in a test file)
- **comment / docs**
- [ ] Confirm the v1.13.2 roadmap inventory is complete:
- tool-phase.ts: 3 sites
- error-handler.ts (`finalizeCompletion`): 1 site
- routes/skills.ts: 2 sites
- routes/messages.ts (answer flow): 1 site
- routes/chats.ts (fork): 1 site
- Any extras the grep finds: list them
- [ ] Confirm no READ sites still touching the legacy columns (everything should go through `messages_with_parts`). If reads remain, flag them — they need to migrate to the view BEFORE dropping the columns.
- [ ] Hand back inventory as a per-file table: file, line, kind (dual-write / read / type / fixture), action (delete / migrate-to-view / type-prune).
## B2 — Backups
- [ ] `cp <file> <file>.bak-v1.13.20-20260523` for every file in B1's action list before editing.
## B3 — Remove dual-writes
- [ ] Remove the JSON-column UPDATE / INSERT at every site identified in B1 as a dual-write. Keep the paired `insertParts(...)` call.
- [ ] If a site only writes the JSON column with no parts pair (would indicate a bug from v1.13.0) — STOP, report as BLOCKED.
- [ ] Verify by grep: zero remaining writes to `tool_calls` or `tool_results` outside of `schema.sql` and test fixtures.
## B4 — Simplify `messages_with_parts` view
- [ ] Open `schema.sql`. Find the view definition.
- [ ] Drop the COALESCE fallbacks that read `m.tool_calls` / `m.tool_results` from `messages`.
- [ ] View now reads only from `message_parts` joined to `messages`.
- [ ] Confirm view's output column shapes are unchanged: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]`.
## B5 — Drop columns
- [ ] `ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;`
- [ ] `ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;`
- [ ] Idempotent on re-run.
- [ ] Apply order in `schema.sql`: AFTER the view is updated (view depends on the columns; can't drop a column referenced by a view).
- [ ] Actually verify the order — if the view references the columns, you must drop the view first OR change it before the ALTER.
## B6 — Remove v1.12.1 cleanup block
- [ ] Find the `DO $$ DROP CONSTRAINT messages_status_check` block in `schema.sql` (likely near the messages CHECK constraints).
- [ ] Confirm it's safe to remove (the constraint should have been dropped long ago).
- [ ] Delete the block.
## B7 — Type pruning
- [ ] `apps/server/src/types/api.ts` — remove `tool_calls?` and `tool_results?` from the `Message` interface.
- [ ] `apps/web/src/api/types.ts` — mirror byte-for-byte.
- [ ] Search for any other type references — `ToolCallsField`, `ToolResultsField`, etc.
## B8 — Test fixture updates
- [ ] Run `pnpm -C apps/server test` to see what breaks.
- [ ] For each failing test that constructs a `Message` literal with `tool_calls: null` / `tool_results: null` — remove those fields.
- [ ] For tests that exercised tool-call behavior via the legacy columns, rewrite to construct via `message_parts` rows.
- [ ] Confirm: `pnpm -C apps/server test` — all green.
## B9 — Type / build verification
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors.
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors.
- [ ] `pnpm -C apps/web build` — green.
## B10 — STOP checkpoint, hand back diff
- [ ] Hand controller the diff for backend changes + test results.
## B11 — Schema deploy
- [ ] `docker compose up --build -d` rebuilds with new schema.
- [ ] Boot twice in sequence — confirm idempotent (column DROP IF EXISTS is a no-op on the second boot).
- [ ] `docker exec boocode_db psql -U boocode -d boocode -c "\d messages"` — confirm columns absent.
- [ ] `docker logs boocode 2>&1 | tail -50` — confirm no schema errors.
## B12 — Smoke
- [ ] Live-smoke: send a chat that triggers at least one tool call. Confirm:
- [ ] Assistant message renders with content + tool_call ActionRow.
- [ ] Tool result renders.
- [ ] No console errors in browser or `docker logs boocode`.
- [ ] Trigger a compaction-eligible turn (long context). Confirm rolling summary anchors correctly.
## B13 — Docs
- [ ] `CHANGELOG.md` entry for v1.13.20-drop-legacy-cols.
- [ ] `boocode_roadmap.md` retrospective bullet on the v1.13.2 section (note the slug rename and ship date).
- [ ] `CLAUDE.md` — drop the v1.13.0 dual-write notes that no longer apply. Audit the surrounding paragraphs.
## B14 — Tag + push + rebuild
- [ ] `git add` only the v1.13.20 batch files (per CLAUDE.md convention).
- [ ] `git commit` with HEREDOC commit message.
- [ ] `git tag v1.13.20-drop-legacy-cols` AND `git tag v1.13` (umbrella, per original v1.13.2 plan).
- [ ] Push: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin main`
- [ ] Push both tags.
- [ ] `docker compose up --build -d`.
- [ ] Curl health check.

View File

@@ -0,0 +1,72 @@
# v1.14.0-outer-loop — design decisions
Answers to the dispatch's blocking questions, resolved 2026-05-23.
## D1. Step cap — what replaces MAX_TOOL_LOOP_DEPTH?
`MAX_TOOL_LOOP_DEPTH` never existed — no hard recursion depth guard was ever in the codebase. Safety came from budget (50 tool calls) + doom-loop (3 identical calls).
**Decision:** introduce `MAX_STEPS = 200` as a hard ceiling. Per-agent cap via `agent.steps` is the primary knob. Resolution: `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)`.
**Rationale:** Sam reports BooChat gets stuck at 50 tool calls (the budget) too often. The step cap should be generous — 200 is 4x the current de-facto ceiling. Budget (50 tool calls total across all steps) remains a separate concern and is not changed in this batch.
Note: "step" ≠ "tool call." One step = one stream iteration that may produce multiple parallel tool calls. Budget counts individual tool calls; step cap counts iterations. At 200 steps with average 1-2 tool calls per step, the budget (50) will fire well before the step cap in most scenarios. The step cap is a safety ceiling for cases where the model makes many 1-tool-call iterations.
## D2. step_finish — emit or not?
**Decision:** No `step_finish` part. The next `step_start` (or assistant message completion) implicitly ends the previous step.
**Rationale:** opencode only emits `step_start`. Less noise in parts, simpler code. If UI ever needs step durations, compute from the timestamps of consecutive `step_start` parts.
## D3. Step-cap hit — sentinel or quiet?
**Decision:** Write a sentinel summary on step-cap hit. Visible to the user in chat, same as budget-exhaustion's `runCapHitSummary`.
**Implementation:** Extend `runCapHitSummary` to accept a `reason: 'budget' | 'step_cap'` parameter (or add a parallel `runStepCapSummary`). The sentinel metadata kind stays `cap_hit` — frontend `CapHitSentinel` component already renders it. The sentinel's text distinguishes the two cases ("Tool budget exhausted" vs "Step limit reached").
## D4. agent.steps = 0
**Decision:** `steps: 0` means "no tool calls allowed." The loop body never executes. The assistant can only respond with text.
**Implementation:** When `effectiveCap === 0`, skip the loop entirely. Stream the first assistant turn (text-only), finalize, return. The model receives no tools in the request payload when `steps: 0` (or equivalently, tools are passed but the loop never enters the tool-execution branch).
Actually, cleaner: `steps: 0` means the loop cap is 0. The while condition `stepNumber < effectiveCap` is false on the first check. The stream phase still runs (the model produces a text response), but if it emits tool calls they're ignored and the turn finalizes as text-only. This may produce a confusing response if the model's text references tool results it never got — but `steps: 0` is an explicit constraint the agent author chose. Document in AGENTS.md parser validation.
## D5. Synthesis success terminates the loop?
**Decision:** Yes. `break` out of the loop after synthesis success. Preserves current behavior (synthesis replaces the recursive call; no further iterations).
**Rationale:** The synthesis pass produces a self-contained summary turn. Continuing the loop after synthesis would let the model issue more tool calls on top of a synthesis summary, which is semantically wrong — the synthesis IS the final answer for that tool call batch.
## D6. executeToolPhase return struct
The recursive call at `tool-phase.ts:342` is currently the last thing `executeToolPhase` does (after creating the next assistant row). After the conversion, `executeToolPhase` returns a struct the loop body reads:
```typescript
interface ToolPhaseResult {
action: 'continue' | 'paused' | 'synthesis_done';
toolCallCount: number;
toolCalls: ToolCall[];
nextAssistantId: string | null;
}
```
- `continue` → loop continues; `nextAssistantId` is the new assistant message's UUID.
- `paused` → user-input or grant pause; loop breaks. `nextAssistantId` is null.
- `synthesis_done` → synthesis succeeded; loop breaks. `nextAssistantId` is null (synthesis wrote its own parts).
The loop body then:
1. Updates `toolsUsed += result.toolCallCount`
2. Appends `result.toolCalls` to `recentToolCalls`
3. Sets `assistantMessageId = result.nextAssistantId` for the next iteration
4. Increments `stepNumber`
5. Checks `result.action` — if not `continue`, breaks.
## D7. Budget vs steps interaction
Budget counts **individual tool calls** across the entire turn. Steps counts **loop iterations**. They are orthogonal:
- Budget fires when `toolsUsed >= resolveToolBudget(agent)` (currently 50 for read-only). Checked at the top of each iteration.
- Step cap fires when `stepNumber >= effectiveCap`. Checked by the loop condition.
Both produce a sentinel summary. A turn can be terminated by whichever fires first. In practice, budget (50 tool calls) fires before step cap (200 steps) unless the model produces many 0-tool-call iterations (which shouldn't happen — 0 tool calls means non-tool finish, which exits the loop via the `break` path).

View File

@@ -0,0 +1,112 @@
# v1.14.0-outer-loop — explicit outer agent loop
Replace the ad-hoc `executeToolPhase → runAssistantTurn` recursion with an explicit `while` loop. A **step** is one stream-and-tool-execute iteration; a step can contain multiple parallel tool calls. The loop terminates on non-tool finish OR step-cap hit OR doom-loop OR budget exhaustion OR abort OR synthesis success.
## Why
The current recursion works but has two problems: (a) stack depth grows linearly with tool iterations — 50 nested async frames is fragile, (b) there's no explicit step counter, so there's no per-agent step cap and no step-boundary instrumentation. BooChat also gets stuck at 50 tool calls (the budget ceiling) more often than it should — the new `MAX_STEPS = 200` hard ceiling lets the loop run much longer before the step cap fires, while the existing budget (50 tool calls) remains a separate concern.
## Recon findings (verified 2026-05-23)
- `runAssistantTurn` at `turn.ts:144-147` is the recursive entry. Returns `Promise<void>`.
- `executeToolPhase` at `tool-phase.ts:89-96` calls back into `runAssistantTurn` at `tool-phase.ts:342`.
- Recursion terminates on: non-tool finish, budget exhaustion (`args.toolsUsed >= budget`), doom-loop (3 identical calls via `detectDoomLoop`), user-input pause (ask_user_input / request_read_access), synthesis success, stream error, abort.
- **No existing hard recursion depth limit** — `MAX_TOOL_LOOP_DEPTH` does not exist. Safety comes from budget (50) + doom-loop (3 identical).
- `TurnArgs` defined in `turn.ts:127-141`, not `types.ts`. Fields: `sessionId`, `chatId`, `assistantMessageId`, `toolsUsed`, `recentToolCalls`, `signal`. All mutable fields are threaded through the recursive call.
- Synthesis pipeline (`synthesisPipeline.ts`) is a branch in `executeToolPhase` — if synthesis succeeds, recursion is skipped.
- `step_start` already in the `message_parts.kind` CHECK constraint. No schema change needed.
- `agents.ts` does NOT currently parse a `steps` field. Needs adding to `ParsedFrontmatter`.
## Scope
### S1. Outer loop in `turn.ts`
Convert the recursive chain to a `while (stepNumber < effectiveCap)` loop:
```
let stepNumber = 0
while (stepNumber < effectiveCap) {
// doom-loop check
// budget check
// emit step_start part
// stream phase (executeStreamPhase)
// if no tool calls → finalize, break
// tool phase (executeToolPhase — now returns, doesn't recurse)
// if paused (user input / grant) → break
// if synthesis succeeded → break
// create next assistant message row
// increment stepNumber, update toolsUsed, append recentToolCalls
}
// if stepNumber >= effectiveCap → sentinel summary
```
`effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)` where `MAX_STEPS = 200`.
### S2. `executeToolPhase` becomes non-recursive
Remove the `runAssistantTurn` call at `tool-phase.ts:342`. Instead, return a result indicating what happened: `{action: 'continue' | 'paused' | 'synthesis_done', toolsUsed, recentToolCalls, nextAssistantId}`. The caller (the while loop) uses the action to decide whether to continue or break.
### S3. `agent.steps` field
`agents.ts:ParsedFrontmatter` gains `steps?: number`. Parser extracts it from YAML frontmatter (integer ≥ 0). `steps: 0` means "no tool calls allowed" — loop body never executes; assistant responds text-only.
### S4. Step-boundary events
At the top of each loop iteration, emit a `step_start` part with payload `{step_number, started_at}`. Uses `insertParts` into the current assistant message. No `step_finish` — the next `step_start` (or message completion) implicitly ends the previous step.
### S5. Doom-loop migration
`detectDoomLoop` check moves from `runAssistantTurn` (top of function, pre-stream) to the top of the while-loop body (same logical position). Same predicate, same threshold (3). Same `runDoomLoopSummary` call. Control flow changes from `return` (unwinding recursion) to `break` (exiting loop).
### S6. Step-cap sentinel
When `stepNumber >= effectiveCap`, write a sentinel summary like the existing `runCapHitSummary`. Reuse `runCapHitSummary` with a reason parameter distinguishing "budget exhaustion" from "step cap hit", or create a parallel `runStepCapSummary`. The sentinel makes the cap visible in chat.
### S7. AGENTS.md updates
Add `steps:` to each agent in `data/AGENTS.md`:
- Refactorer: `steps: 5`
- Architect: `steps: 20`
- All others: unset (infinity — bounded only by `MAX_STEPS = 200`)
### S8. Tests
New test file `apps/server/src/services/__tests__/outer-loop.test.ts` covering:
- Clean finish (stream returns non-tool, loop exits after 1 iteration)
- Step-cap hit (loop exits at cap, sentinel written)
- Doom-loop break (3 identical calls, sentinel written)
- Budget exhaustion (toolsUsed >= budget, cap-hit sentinel written)
- Abort mid-step (signal fires, loop exits)
- `steps: 0` edge case (no loop iterations, text-only response)
- Synthesis success (loop exits after synthesis)
## Non-goals
- No frontend changes. `step_start` parts surface via `messages_with_parts` automatically; UI doesn't render them in v1.14.
- No `output_schema` / `exit_expression` / `execution_strategy` AGENTS.md fields.
- No per-step snapshot for revert (v2.0 BooCoder concern).
- No changes to budget constants (50 / 10 / 50). That's a separate concern.
- No `repairToolCall` changes.
- No compaction changes.
## Hard rules
- No git commit, push. Sam commits.
- Backup before editing.
- TS strict, no `any`.
- Doom-loop threshold stays at 3.
- 332+ existing tests still pass + new outer-loop tests.
## Files expected to touch
- `apps/server/src/services/inference/turn.ts` — recursion → loop
- `apps/server/src/services/inference/tool-phase.ts` — remove recursive call, return result struct
- `apps/server/src/services/inference/sentinel-summaries.ts` — step-cap sentinel (or extend cap-hit)
- `apps/server/src/services/agents.ts` — parse `steps` field
- `data/AGENTS.md` — add `steps:` to Refactorer + Architect
- `apps/server/src/services/__tests__/outer-loop.test.ts` — NEW
- `apps/server/src/services/inference/index.ts` — re-export if new types needed
## Estimate
~300 LoC net (turn.ts refactor + tool-phase return struct + agents parser + tests). The conversion is structural, not behavioral — every exit path is preserved, just expressed as loop control flow instead of recursion unwinding.

View File

@@ -0,0 +1,82 @@
# v1.14.0-outer-loop tasks
## B1 — Backups
- [ ] `turn.ts`, `tool-phase.ts`, `sentinel-summaries.ts`, `agents.ts`, `data/AGENTS.md`
## B2 — agents.ts: parse `steps` field
- [ ] Add `steps?: number` to `ParsedFrontmatter` interface
- [ ] Parse from YAML frontmatter: integer ≥ 0, warn on out-of-range (negative or non-integer), clamp to 0
- [ ] Expose on the `Agent` type returned by `getAgentsForProject`
- [ ] `npx tsc --noEmit -p apps/server` clean
## B3 — AGENTS.md: add `steps:` to Refactorer + Architect
- [ ] `data/AGENTS.md` — Refactorer: `steps: 5`
- [ ] `data/AGENTS.md` — Architect: `steps: 20`
- [ ] All others: leave unset (infinite, bounded by MAX_STEPS=200)
## B4 — tool-phase.ts: remove recursive call, return result struct
- [ ] Define `ToolPhaseResult` interface: `{action: 'continue' | 'paused' | 'synthesis_done', toolCallCount: number, toolCalls: ToolCall[], nextAssistantId: string | null}`
- [ ] Remove `runAssistantTurn` import and call at line ~342
- [ ] `executeToolPhase` returns `ToolPhaseResult` instead of `Promise<void>`
- [ ] On normal path (after creating next assistant row): return `{action: 'continue', toolCallCount, toolCalls: result.toolCalls, nextAssistantId}`
- [ ] On user-input pause: return `{action: 'paused', toolCallCount: <calls executed so far>, toolCalls: result.toolCalls, nextAssistantId: null}`
- [ ] On synthesis success: return `{action: 'synthesis_done', toolCallCount, toolCalls: result.toolCalls, nextAssistantId: null}`
- [ ] `npx tsc --noEmit -p apps/server` will FAIL here (turn.ts still expects void) — expected, fixed in B5
## B5 — turn.ts: recursion → while loop
- [ ] Add `MAX_STEPS = 200` constant
- [ ] Resolve `effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS)` at the top of `runAssistantTurn`
- [ ] Convert `runAssistantTurn` body into a `while (stepNumber < effectiveCap)` loop:
- Top of loop: doom-loop check (move from current position; `break` instead of `return`)
- Top of loop: budget check (move from current position; `break` instead of `return`, but still call `runCapHitSummary` before break)
- Emit `step_start` part via `insertParts` with payload `{step_number: stepNumber, started_at: new Date().toISOString()}`
- Call `executeStreamPhase`
- If no tool calls → `finalizeCompletion`, `break`
- Call `executeToolPhase` (now returns `ToolPhaseResult`)
- If `result.action !== 'continue'``break`
- Update `toolsUsed += result.toolCallCount`
- Update `recentToolCalls = [...recentToolCalls, ...result.toolCalls]`
- Update `assistantMessageId = result.nextAssistantId!`
- Increment `stepNumber`
- [ ] After loop: if `stepNumber >= effectiveCap` → call step-cap sentinel (B6)
- [ ] `effectiveCap === 0` edge case: the while condition is immediately false; stream the first turn text-only (the stream phase at the top of the function runs once before the loop — OR handle this by structuring the loop as do-while, OR handle by pre-checking and skipping tools from the request). Pick the cleanest approach.
- [ ] Remove `TurnArgs` from the module export if it's no longer threaded through recursion — OR keep it and populate from loop locals. (Design note: `TurnArgs` is still used by `executeStreamPhase`, `executeToolPhase`, `sentinel-summaries.ts`, `error-handler.ts`. Keep the interface; populate from loop locals each iteration.)
- [ ] `npx tsc --noEmit -p apps/server` clean
- [ ] `pnpm -C apps/server test` — all existing tests pass
## B6 — sentinel-summaries.ts: step-cap sentinel
- [ ] Add `runStepCapSummary` (or extend `runCapHitSummary` with a `reason` param)
- [ ] Write a sentinel with `metadata.kind = 'cap_hit'` (same as budget) so `CapHitSentinel` UI renders it
- [ ] Sentinel text distinguishes "Step limit reached (N steps)" from "Tool budget exhausted (N calls)"
- [ ] Called from the post-loop check in turn.ts (B5)
## B7 — Tests
- [ ] NEW `apps/server/src/services/__tests__/outer-loop.test.ts`
- [ ] Test: clean finish — stream returns no tool calls, loop exits after 1 step
- [ ] Test: step-cap hit — mock agent with `steps: 2`, model always returns tool calls, loop exits at 2, sentinel written
- [ ] Test: doom-loop — 3 identical tool calls, sentinel written, loop breaks
- [ ] Test: budget exhaustion — toolsUsed >= budget, cap-hit sentinel written
- [ ] Test: `steps: 0` — no loop iterations, text-only response
- [ ] Test: synthesis success — loop breaks after synthesis
- [ ] `pnpm -C apps/server test` — all 332+ existing + new tests pass
## B8 — Verification
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors (no web changes; should pass)
- [ ] `pnpm -C apps/web build` — green
- [ ] `pnpm -C apps/server test` — all green
## B9 — Docs + tag + deploy
- [ ] `CHANGELOG.md` entry for v1.14.0-outer-loop
- [ ] `boocode_roadmap.md` retrospective bullet on the v1.14 section
- [ ] `CLAUDE.md` updates: mention the outer loop, MAX_STEPS, agent.steps in the inference/ section
- [ ] Commit, tag `v1.14.0-outer-loop`, push, rebuild

View File

@@ -0,0 +1,194 @@
# v1.14.x-html-artifact-panes — pane-based artifact viewer (Markdown + HTML)
Every assistant message gets an "Open in pane" affordance that renders it as a full-height artifact in BooChat's existing workspace splitter. Markdown is the default render (the model's normal output, just promoted to a pane); HTML is opt-in when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Pane headers expose Copy + Download for Markdown, Download-only for HTML. **No inline iframe preview** — artifacts are pane-only.
Final tag slug to be assigned at ship time depending on ordering against v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). This batch is independent of both.
## Why
Three pressures land in the same place:
1. **Long assistant replies are uncomfortable to read in the chat stream.** Scrolling a 400-line Markdown reply between bubbles is worse than reading it in a dedicated pane next to the chat. The workspace splitter already exists; the splitter just has no artifact pane type yet.
2. **HTML output is a real format the model wants to produce sometimes** (Thariq Shihipar's "HTML > Markdown at length" pattern, May 20 2026 Claude blog) — diagrams, sliders, syntax-highlighted code, side-by-side comparisons, mobile-responsive layouts. But auto-biasing the model to HTML for >100-line outputs (the blog's recommendation) is too aggressive for BooChat's typical workflow; most replies are conversational and Markdown is the right surface. **HTML stays opt-in.**
3. **Durable artifact downloads** — Sam can already copy Markdown out of a chat bubble, but there's no path to "save this reply as a `.md` next to the project, keep it around." Adding a Download button parallel to Copy gives every long reply a portable form.
## Scope
### S1. AGENTS.md guidance (no code change)
Add HTML-on-request rule to global `data/AGENTS.md`:
> Stay in Markdown by default for all outputs, 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 a dashboard", "build a diagram"). When producing HTML, follow these design conventions: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics. See `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 2026) for the design taxonomy.
The "auto-bias to HTML for >100 lines" recommendation from the blog post is deliberately NOT adopted. Markdown stays the default at every length.
### S2. Backend: HTML detection + part-kind extension
In `apps/server/src/services/inference/stream-phase.ts` post-processing, detect when an assistant text part:
- Starts with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed), OR
- Is wrapped entirely in a fenced ` ```html ... ``` ` block
When detected, emit a new `message_parts` row with `kind='html_artifact'` and payload `{html_content, char_count, title}`. Title resolution order: `<title>` tag → first `<h1>` text → first 80 chars of inner text.
Detection is **opportunistic** — fires only when the model produced HTML (because the user asked). Otherwise the message stays plain-Markdown and no `html_artifact` part is written.
**Schema:**
```sql
-- v1.14.x: extend message_parts.kind CHECK constraint with html_artifact
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', 'reasoning', 'tool_call', 'tool_result', 'synthesis', 'html_artifact'));
END IF;
END $$;
```
Idempotent on re-run (drops + re-adds on every startup; trivial cost).
### S3. Frontend: pane affordance + two pane types
**MessageBubble.tsx** — add an "Open in pane" icon button to every assistant message footer, alongside the existing copy/regenerate controls. Click dispatches a workspace-pane action:
- If the message has an `html_artifact` part → opens `{type: 'html_artifact', message_id, html_content}`.
- Otherwise → opens `{type: 'markdown_artifact', message_id}`.
**New pane types** registered in the workspace splitter (currently chat / empty / placeholder terminal+agent — adds `markdown_artifact` and `html_artifact`):
- `MarkdownArtifactPane.tsx` — pane shell. Header: title (derived from first heading or first 6 words), Copy button (raw Markdown source via `navigator.clipboard.writeText`), Download button (POST to `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md`). Body: reuses the same Markdown component used inline in `MessageBubble` (Shiki syntax highlighting, fenced code, tables, all preserved).
- `HtmlArtifactPane.tsx` — pane shell. Header: title (from `html_artifact.payload.title`), Download button only (`?fmt=html`). Body: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />` at full pane height. **No Copy button** for HTML.
Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports arbitrary pane payloads — extend the `Pane` discriminated union with two new variants).
### S4. Download endpoint
New endpoint `POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html`:
- Resolves the message and (for HTML) its `html_artifact` part.
- Computes slug:
- Markdown: first `# ` heading text, else first 6 words of message body, lowercased + hyphenated.
- HTML: `<title>` tag content, else first `<h1>` text, else first 6 words of inner text. Same lowercase-hyphen treatment.
- Writes to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>`. Path-guarded same as native write tools — must stay under the project root.
- Returns `{path, url}` where `url` is the pre-signed link via the existing static-file serving route.
### S5. HTML iframe security stance
Locked from the original 2026-05-22 design:
```
sandbox="allow-scripts allow-clipboard-write allow-downloads"
```
**No `allow-same-origin`** — artifact has its own opaque origin, cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=` inline (not `src=`) so no separate URL exists to disclose.
CSP applied to the iframe content (via `<meta http-equiv="Content-Security-Policy">` injected into the artifact's `<head>` if not already present):
```
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'
```
`connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping tracking pixels, can't exfiltrate. JS runs (interactive controls work) but nothing network-touching does.
### S6. Token-budget guard
Single HTML artifact: max 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error:
> Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets.
Markdown artifacts have no separate cap — they're bounded by the existing message-size envelope.
## Hard rules
- No git commit, no git push, no git pull during dispatch. Sam commits manually.
- Backup every file before edit per the standard convention (`.bak-v1.14.x-html-<YYYYMMDD>`).
- TS strict, no `any`.
- No new deps. The Markdown renderer, Shiki, the workspace splitter, and `navigator.clipboard.writeText` are all already in the bundle.
- Schema migration is additive only (extend CHECK constraint), idempotent on re-run.
- Path-guard layer (`apps/server/src/services/path_guard.ts`) enforces that downloads stay under the project root.
- Secret-file deny list still runs on the resolved download path.
- HTML iframe sandbox attributes are non-negotiable — exact attribute string as written in S5.
## Non-goals
- **No auto-bias to HTML for long outputs.** The AGENTS.md rule explicitly says Markdown is default at every length.
- **No inline iframe preview in the chat stream.** Pane-only.
- **No Copy button on HTML panes.** Download-only for HTML.
- **No separate artifacts table.** Artifacts live in `message_parts` (HTML) or derive from the assistant message (Markdown). Downloads are user-managed on disk under `/opt/<project>/.boocode/artifacts/`.
- **No vendor of `anthropics/skills/web-artifacts-builder`.** That skill is built for Claude.ai's Vite/Parcel runtime; BooChat has no shell execution surface. Just lift the design principles into AGENTS.md.
- **No changes to `apps/booterm` or `apps/coder`.** This is a BooChat-only batch.
## Stop checkpoints
1. After recon (read existing `Pane` discriminated union + workspace splitter + MessageBubble + `message_parts` shape + path_guard): stop, hand back the recon report.
2. After backend edits (detection + schema + download endpoint), before frontend work: stop, hand back diff + curl test of the download endpoint.
3. After frontend edits, before schema migration applies in dev: stop, hand back diff.
4. After schema migration applies in dev: stop, run smoke plan, report.
## Smoke plan
1. **Markdown pane — happy path.** Send a chat that produces a long Markdown reply (e.g. "explain the inference loop in detail"). Click "Open in pane" on the assistant message. Confirm:
- Pane opens in the workspace splitter at full height.
- Markdown renders with syntax highlighting on fenced code blocks (Shiki working).
- Header shows a sensible title (first heading or first 6 words).
- Copy button writes raw Markdown source to clipboard — paste into a text editor and verify it's the same source the assistant emitted.
- Download button writes `/opt/boocode/.boocode/artifacts/<slug>-<ts>.md` and the file contains the raw source.
2. **HTML pane — happy path.** Send "render a simple HTML dashboard with three interactive sliders that update a div in real time." Confirm:
- Model produces `<!DOCTYPE html>...` content.
- `message_parts` row with `kind='html_artifact'` is written.
- Click "Open in pane" — HTML pane renders the artifact in a sandboxed iframe.
- Sliders work (JS runs inside the iframe).
- Download button writes `.html` to the artifacts dir.
- No Copy button on the HTML pane.
3. **HTML security — exfil attempt.** Send "render an HTML page that tries to fetch('https://example.com/exfil') and display the result." Confirm:
- Iframe loads but the `fetch()` is blocked by `connect-src 'none'`.
- Browser devtools shows the CSP violation.
- No network request leaves the iframe.
4. **HTML security — DOM access attempt.** Send "render an HTML page with `<script>document.cookie</script>`." Confirm the script sees the iframe's own (empty) cookie jar, NOT BooChat's parent cookies — sandbox without `allow-same-origin` enforces opaque origin.
5. **Markdown opt-in HTML.** Send a normal "summarize the codebase" reply (Markdown), then a follow-up "now render that as HTML." Confirm the second reply produces an HTML artifact while the first stays plain-Markdown — detection is opportunistic, doesn't auto-promote.
6. **1MB cap.** Construct a synthetic test that asks for a >1MB HTML artifact. Confirm the streaming aborts with the friendly error message; no `message_parts` row with oversized payload is written.
7. **Path-guard enforcement on download.** Try to download with a hand-crafted slug containing `../`. Confirm the path-guard rejects it.
8. **Persistence across reload.** Open both a Markdown and an HTML pane. Hard-reload the browser. Confirm both panes restore via `sessions.workspace_panes`.
## Done when
- Backend: `stream-phase.ts` detects HTML, writes `html_artifact` part. Schema migration shipped. Download endpoint live + path-guarded.
- Frontend: `MarkdownArtifactPane` + `HtmlArtifactPane` components shipped. MessageBubble has the "Open in pane" affordance. Workspace `Pane` discriminated union extended.
- AGENTS.md updated with the HTML-on-request rule.
- Smoke plan green (all 8 steps).
- Tag + CHANGELOG entry + roadmap retrospective bullet at the bottom of the v1.14.x-html roadmap section.
## Files expected to touch
**Backend:**
- `apps/server/src/schema.sql` — extend `message_parts.kind` CHECK constraint
- `apps/server/src/services/inference/stream-phase.ts` — HTML detection in post-processing
- `apps/server/src/services/inference/parts.ts``PartKind` union adds `'html_artifact'`
- `apps/server/src/routes/messages.ts` — new `POST /api/chats/:id/messages/:msg_id/artifacts/download` endpoint (or new `artifacts.ts` route file)
- `apps/server/src/services/artifacts.ts` — NEW. `writeMarkdownArtifact(msg, projectRoot)` + `writeHtmlArtifact(part, projectRoot)` + slug derivation helpers
- `apps/server/src/services/path_guard.ts` — no change expected; existing guard handles the artifacts dir as a project-scoped write target
**Frontend:**
- `apps/web/src/components/MessageBubble.tsx` — add "Open in pane" affordance to assistant message footer
- `apps/web/src/components/MarkdownArtifactPane.tsx` — NEW
- `apps/web/src/components/HtmlArtifactPane.tsx` — NEW
- `apps/web/src/types/panes.ts` (or wherever `Pane` lives) — extend discriminated union with `markdown_artifact` + `html_artifact` variants
- `apps/web/src/api/client.ts``api.messages.downloadArtifact(msgId, fmt)`
- `apps/web/src/api/types.ts` — mirror the new pane variants and `html_artifact` part kind
**Docs:**
- `data/AGENTS.md` — HTML-on-request rule
- `boocode_roadmap.md` — retrospective bullet at the bottom of the v1.14.x-html section
- `CHANGELOG.md` — new `##` entry with the tag
## Estimate
~400 LoC total. Backend ~200 LoC (detection + part-kind extension + download endpoint + slug derivation). Frontend ~200 LoC (two pane components + MessageBubble affordance + pane integration + API client wiring).

View File

@@ -0,0 +1,124 @@
# v1.14.x-html-artifact-panes tasks
## B1 — Backups
- [ ] `apps/server/src/schema.sql.bak-v1.14.x-html-<YYYYMMDD>`
- [ ] `apps/server/src/services/inference/stream-phase.ts.bak-v1.14.x-html-<YYYYMMDD>`
- [ ] `apps/server/src/services/inference/parts.ts.bak-v1.14.x-html-<YYYYMMDD>`
- [ ] `apps/server/src/routes/messages.ts.bak-v1.14.x-html-<YYYYMMDD>`
- [ ] `apps/web/src/components/MessageBubble.tsx.bak-v1.14.x-html-<YYYYMMDD>`
- [ ] `apps/web/src/api/client.ts.bak-v1.14.x-html-<YYYYMMDD>`
- [ ] `apps/web/src/api/types.ts.bak-v1.14.x-html-<YYYYMMDD>`
- [ ] `data/AGENTS.md.bak-v1.14.x-html-<YYYYMMDD>`
## B2 — Recon (STOP after this step)
- [ ] Read existing `Pane` discriminated union and locate the workspace splitter component
- [ ] Read `MessageBubble.tsx` to find the assistant-message footer (copy/regenerate controls location)
- [ ] Read `message_parts` shape + `PartKind` union in `parts.ts`
- [ ] Read `stream-phase.ts` post-processing path (where text parts are finalized into rows)
- [ ] Read `path_guard.ts` to confirm write semantics for `/opt/<project>/.boocode/artifacts/`
- [ ] Read the existing static-file serving route to understand the URL shape for downloads
- [ ] Hand back a recon report: exact line numbers + signatures of insertion points
## B3 — Schema migration
- [ ] Extend `message_parts.kind` CHECK constraint with `'html_artifact'`
- [ ] Use the `DROP CONSTRAINT IF EXISTS` + `DO $$ pg_constraint $$` pattern (matches the rest of `schema.sql`)
- [ ] Confirm idempotent on re-run: apply twice in dev, no error
## B4 — Backend: HTML detection
- [ ] Extend `PartKind` union in `apps/server/src/services/inference/parts.ts` with `'html_artifact'`
- [ ] In `stream-phase.ts` post-processing: detect text parts starting with `<!DOCTYPE html>` (case-insensitive, trimmed) OR wrapped in fenced ` ```html ` block
- [ ] Title resolution helper: `<title>` tag → first `<h1>` text → first 80 chars of inner text
- [ ] Write the `html_artifact` part with payload `{html_content, char_count, title}` via the existing `insertParts` helper
- [ ] 1MB cap check before write: abort stream with friendly error if exceeded
- [ ] Detection is opportunistic — does NOT replace the text part, just adds a sibling `html_artifact` part
## B5 — Backend: artifacts service
- [ ] NEW `apps/server/src/services/artifacts.ts`
- [ ] `deriveMarkdownSlug(messageContent: string): string` — first `# ` heading → first 6 words → lowercase + hyphenate
- [ ] `deriveHtmlSlug(payload: HtmlArtifactPayload): string``<title>` → first `<h1>` → first 6 words of inner text → lowercase + hyphenate
- [ ] `writeMarkdownArtifact(message, projectRoot): Promise<{path, url}>` — slug + timestamp + write to `<projectRoot>/.boocode/artifacts/`
- [ ] `writeHtmlArtifact(part, projectRoot): Promise<{path, url}>` — same shape
- [ ] Path-guard both writes via existing helpers
- [ ] Ensure `<projectRoot>/.boocode/artifacts/` exists (mkdir recursive)
## B6 — Backend: download endpoint
- [ ] NEW endpoint registration: `POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html`
- [ ] Fastify route in `apps/server/src/routes/messages.ts` (or new `artifacts.ts` route file — decide during impl)
- [ ] Zod schema on `?fmt=` query param
- [ ] Resolve message + (for HTML) the `html_artifact` part
- [ ] Call `writeMarkdownArtifact` or `writeHtmlArtifact` per `fmt`
- [ ] Return `{path, url}`
- [ ] Error path: 404 if `fmt=html` requested but no html_artifact part exists
## B7 — Backend: STOP checkpoint after B3B6
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
- [ ] Smoke download endpoint via `curl http://100.114.205.53:9500/api/chats/<id>/messages/<msg_id>/artifacts/download?fmt=md` against a real message
- [ ] Hand back diff + curl output
## B8 — Frontend: Pane discriminated union extension
- [ ] Extend `Pane` discriminated union with two variants:
- `{ kind: 'markdown_artifact', message_id: string }`
- `{ kind: 'html_artifact', message_id: string, html_content: string, title: string }`
- [ ] Update `validatePanes` to handle the new variants (no-op if message_id still exists)
- [ ] Mirror types in `apps/web/src/api/types.ts` (`MessagePart` discriminator + new pane variants)
## B9 — Frontend: pane components
- [ ] NEW `apps/web/src/components/MarkdownArtifactPane.tsx`
- Header: title + Copy button (raw source via `navigator.clipboard.writeText`) + Download button + close-pane affordance
- Body: reuse the same Markdown render component used in `MessageBubble`
- [ ] NEW `apps/web/src/components/HtmlArtifactPane.tsx`
- Header: title + Download button + close-pane affordance (NO Copy)
- Body: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="w-full h-full" />`
- [ ] Wire both into the workspace splitter's pane-type registry
## B10 — Frontend: MessageBubble affordance
- [ ] Add "Open in pane" icon button to assistant message footer (next to existing copy/regenerate controls)
- [ ] On click: dispatch workspace-pane action
- If message has `html_artifact` part → open as html_artifact pane (with title + html_content from the part)
- Else → open as markdown_artifact pane
- [ ] Mobile tap target: `max-md:min-h-[44px] max-md:min-w-[44px]`
## B11 — Frontend: API client
- [ ] `api.messages.downloadArtifact(chatId, msgId, fmt: 'md' | 'html')` → POST to the new endpoint
- [ ] Returns `{path, url}` — Copy button uses raw text from the message; Download button uses the returned URL
## B12 — Frontend: STOP checkpoint after B8B11
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors (root tsc may miss web errors per CLAUDE.md)
- [ ] `pnpm -C apps/web build` succeeds (including the `U+2500-259F` guard)
- [ ] Hand back diff
## B13 — AGENTS.md guidance
- [ ] Add HTML-on-request rule to `data/AGENTS.md`
- [ ] Inline "avoid AI slop" design conventions (no centered layouts, no purple gradients, no uniform rounded corners, no Inter font)
- [ ] Cite Thariq Shihipar's blog post (May 2026) as the source
## B14 — Smoke (STOP at end, full report)
- [ ] Markdown pane happy path (open, render, copy, download)
- [ ] HTML pane happy path (open, render, JS executes, download — no Copy button)
- [ ] HTML security exfil attempt — `fetch()` blocked by `connect-src 'none'`
- [ ] HTML security DOM access — sandbox without `allow-same-origin` enforces opaque origin
- [ ] Opt-in opportunistic detection — first reply Markdown, follow-up "render as HTML" produces artifact
- [ ] 1MB cap — synthetic test, streaming aborts with friendly error
- [ ] Path-guard on download — hand-crafted `../` slug rejected
- [ ] Persistence — pane state survives hard reload via `sessions.workspace_panes`
## B15 — OpenSpec docs + release
- [ ] Mark this `tasks.md` checkboxes complete after each step
- [ ] Append retrospective bullet to bottom of v1.14.x-html section in `boocode_roadmap.md`
- [ ] Add `CHANGELOG.md` entry with the assigned tag (e.g. `v1.14.1-html-artifact-panes` — final patch number assigned at ship time depending on order vs v1.14 outer loop)
- [ ] Hand back to Sam for tag + commit