Opencode pattern (session/overflow.ts): fire compaction at 85% of
ctx_max, replacing the v1.11.0-era `ctx_max - 20_000` formula.
Old formula: usable = ctx_max - 20_000
- ctx=262144 → trigger at 242144 (92.4%) — only 7.6% headroom
- ctx=100000 → trigger at 80000 (80.0%)
- ctx= 32000 → trigger at 12000 (37.5%) — over-eager
- ctx<=20000 → trigger at 0 — never fires
New formula: usable = floor(0.85 * ctx_max)
- ctx=262144 → trigger at 222822 (85.0%) — 15% headroom for summarizer
- ctx=100000 → trigger at 85000 (85.0%)
- ctx= 32000 → trigger at 27200 (85.0%)
- ctx= 8192 → trigger at 6963 (85.0%)
Ratio gives consistent headroom at any context scale. The qwen3.6
daily driver gets ~19k tokens more breathing room before overflow;
small-ctx models no longer degenerate to never-triggering.
usable() is the only consumer of COMPACTION_BUFFER → constant deleted.
New EARLY_TRIGGER_RATIO constant takes its place.
isOverflow() and the maybeFlagForCompaction() call site at
payload.ts:184 are unchanged — formula swap is internal to compaction.ts.
payload.ts comment touched only to drop the stale COMPACTION_BUFFER
reference (PRUNE_TRIGGER_TOKENS stays at 20k as the prune-freed
threshold; independent of the overflow formula).
Tests: 4 new usable() corner cases (262k/100k/8k/zero+negative), plus
5 isOverflow() numbers shifted to match the 85k budget at ctx=100k.
195/195 server tests pass (was 194).
Smoke: ratio math verified by unit tests at all four corners. Live
cap-hit verification deferred — requires accumulating >222k tokens
in a session under qwen3.6-35b-a3b-mxfp4 (was >242k pre-fix); will
surface organically in extended use.
Recon during planning disproved the original v1.13.7 (DB-cache) premise:
buildSystemPrompt already runs over inputs mtime-cached at the file layer
(BOOCHAT.md in system-prompt.ts:25, AGENTS.md global+per-project in
agents.ts:245), and DB scalars are byte-stable until edited. The output
is microsecond pure-string concat with no I/O. Skills aren't in the
prefix; tools live in a separate request body field alpha-sorted by
v1.13.3.
This batch closes the verification gap with instrumentation, not
implementation:
- system-prompt.ts: buildSystemPromptWithFingerprint canonical impl
computes SHA-256 over the assembled prefix, runs a per-session
Map<sessionId, lastHash> observer, emits PrefixFingerprint per call
and PrefixDrift (with field-level changed_inputs) on hash change.
buildSystemPrompt is now a thin shim returning .prompt.
- agents.ts: getAgentsMtimes accessor — cache-read only, no I/O.
- payload.ts: buildMessagesPayload takes optional log argument; when
passed, emits prefix-fingerprint (info) + prefix-drift (warn).
- turn.ts + sentinel-summaries.ts: pass ctx.log at 3 production call
sites; sentinel summaries log too so any drift across cap-hit /
doom-loop paths surfaces.
- system-prompt.test.ts: 4 new tests (byte-identical, no-drift-on-
stable, drift-fires-with-changed-inputs, cross-session-no-drift).
194/194 tests pass (was 190).
Smoke: 5 messages in a fresh session produced 7 prefix-fingerprint
logs (extras from buildMessagesPayload being called from sentinel
summary paths), all with identical prefix_hash and prefix_length=2907,
zero prefix-drift. Prefix is byte-stable in steady-state.
Decision: original system_prompt_cache DB table from the roadmap is
permanently dropped. The v1.12.0 mtime caches at the input layer plus
alpha tool ordering at the request body (v1.13.3) already address the
load-bearing cache-stability surfaces. Instrumentation stays so the
claim can be re-verified at any time.
Five fixes for latent regressions surfaced during the v1.13.x.cosmetic
revert investigation. None alter schema or compaction; all cleanup
against the v1.13.1-A AI SDK migration's hidden surface.
(1) provider.ts — includeUsage: true on createOpenAICompatible.
@ai-sdk/openai-compatible defaults this false, omitting
stream_options.include_usage from the request body; llama-swap never
emitted the usage block, so result.usage.inputTokens/outputTokens
resolved undefined and tokens_used / ctx_used landed NULL in every
assistant row since v1.13.1-A. No historical backfill.
(2) MessageList.tsx — hasText = m.content.trim().length > 0.
AI SDK v6 streaming occasionally emits a leading "\n" text-delta on
tool-call-only turns; the literal newline passed length > 0 and
rendered an empty bubble + ActionRow between every tool call. Trim
catches it without changing semantics for genuine content.
(3) MessageBubble.tsx — same trim on hasContent for the no-tool-calls
path. Defensive symmetry with MessageList.flatten.
(4) payload.ts — buildMessagesPayload skips assistant rows with
status='failed' AND assistant rows with status='complete' + empty
content + no tool_calls. Without this, a trailing empty/failed
assistant + the next attempt's placeholder produced "Cannot have 2
or more assistant messages at the end of the list" rejections from
the OpenAI-compatible upstream after cap-hit + Continue.
(5) budget.ts — BUDGET_NO_AGENT 15 → 30. Every tool in ALL_TOOLS is
read-only today; the 15-cap was forward-looking for write tools that
haven't landed. No-agent mode now matches BUDGET_READ_ONLY.
47 LoC across 5 files. 190/190 server tests pass.
Verified live: new assistant turns populate StatsLine token data;
single-tool-call turns no longer render the stray empty-bubble +
ActionRow between tool calls; Continue after cap-hit no longer hits
the trailing-assistant API rejection.
Audit traced compaction's summary path post-v1.13.1-B read flip:
- Q1: reads from messages_with_parts (view) — clean
- Q2: parts shape correctly threaded through buildHeadPayload — clean
- Q3: reasoning omitted from summary input — FIX NEEDED
v1.13.1-C wired reasoning end-to-end into inference/payload.ts but
missed this read site. Summarizer model couldn't see the reasoning
trail for tool-bearing turns, quietly degrading summary quality for
reasoning-channel models (qwen3.6).
Fix:
- CompactionMessage extended with reasoning_parts field
- SELECT pulls reasoning_parts from messages_with_parts
- buildHeadPayload (now exported for tests) prefixes assistant content
with <reasoning>...</reasoning>\n\n<content>... when reasoning is
present; standalone <reasoning>...</reasoning> for tool-call-only
turns; omits the tag when reasoning is null or empty
4 new render branch tests (190 total).
Smoke deferred: forcing real compaction requires either threshold
pollution or building up a >40k-token chat with reasoning_parts.
Render branches are unit-covered; integration would only re-prove
structural correctness.
- New services/truncate.ts. Tmpfs storage at /tmp/boocode-truncations/
(BOOCODE_TRUNCATION_DIR env var overrides for tests). 12-char base32
opaque ids (~60 bits entropy, "tr_<id>"). Three exports: storeTruncation,
readTruncation, truncateIfNeeded (wrap-or-passthrough helper).
cleanupTruncations does TTL-pass (7 days) + orphan-reap (parts query on
payload->'output'->>'outputPath') in one shot.
- Wired four tools through truncateIfNeeded: view_file (raw full file),
list_dir (full filtered+secret-filtered entries serialized one-per-line),
web_fetch (textRaw pre-slice), codecontext_client (body.result pre-slice).
Each returns the existing sliced view plus an optional outputPath field
when truncation fires.
- New view_truncated_output ToolDef. Resolves opaque id → on-disk content
internally; model never sees the truncation dir. Same start_line /
end_line slicing semantics as view_file. Registered in ALL_TOOLS (alpha
sort places it after view_file automatically) and READ_ONLY_TOOL_NAMES.
- cleanupTruncations piggybacks on the v1.13.3 stuck-row sweeper's 60s
setInterval. No-op when truncation dir is empty.
Not wired (TODO follow-up): grep and find_files. file_ops returns post-cap
results to the tool execute path, so the "full content" isn't recoverable
without a refactor of fileOps.grep / fileOps.findFiles to expose the
uncapped result. web_search is silent-slice (no truncated flag); outside
scope. Five sites of seven covered; the remaining two are the only ones
needing a file_ops change.
Tests: 7 new in truncate.test.ts (roundtrip, unknown id, malformed id,
truncateIfNeeded false/true/over-cap/storage-failure paths). 186 total
(was 179). cleanupTruncations file-system half implicitly via TTL pass;
orphan-reap branch covered by the live container smoke.
Smoke verified end-to-end against the live container:
- view_file with start_line=1, end_line=3 on CLAUDE.md → tool_result part
carried outputPath "tr_cdpn1o04k6ma" + truncated=true.
- /tmp/boocode-truncations/tr_cdpn1o04k6ma exists, 15876 bytes, mode 0o600,
parent dir mode 0o700.
- Follow-up view_truncated_output(id, start_line=50, end_line=55) returned
the actual lines 50-55 of CLAUDE.md (the 808notes/BooCode bullets).
- ALL_TOOLS count=20 (was 19); alpha sort places view_truncated_output
between view_file and watch_changes.
Closes a v1.12 catalog row that was scoped but deferred. The v1.13 parts
table made outputPath ride on the existing tool_result payload with no
schema change beyond the storage helper itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- message_parts.hidden_at timestamptz column (NULL by default) with a
partial index on (message_id) WHERE hidden_at IS NULL for the common
visible-parts filter.
- messages_with_parts view changed from COALESCE(parts, legacy) to
CASE WHEN EXISTS(any parts of kind) THEN visible-parts ELSE legacy.
COALESCE would have leaked hidden parts back via the legacy fallback
when every part was pruned (smoke caught it pre-commit). The CASE
distinguishes "no parts at all → fall back to legacy column for
pre-v1.13.0 history" from "all parts hidden → return null/empty so
the row drops out of the model payload" exactly.
- prune.ts: scans tool_result parts newest-first, protects the last 40k
tokens (PROTECTED_TOKENS), marks older candidates hidden when their
combined estimate clears 20k (PRUNE_TRIGGER_TOKENS — equal to
COMPACTION_BUFFER from v1.11.0, so a successful prune is exactly the
budget the summary path would have freed). Stops at chats.tail_start_id
so it doesn't double-erase across the last summary boundary. Pure
decision helper selectPruneTargets exported separately for unit tests.
- Wired into maybeFlagForCompaction: prune runs synchronously when
overflow is detected; if it freed >= PRUNE_TRIGGER_TOKENS, the
needs_compaction flag is NOT set and the (expensive) summary inference
call is skipped this turn. The next turn's overflow check re-evaluates
from scratch.
- 6 new unit tests in prune.test.ts cover: empty input, protection-only
(no candidates), candidates below trigger, candidates above trigger,
candidates straddling a summary boundary, exactly-protection-tokens.
179 tests total (was 173).
Smoke verified post-rebuild:
- \\d message_parts shows hidden_at + partial index.
- View definition shows AND p.hidden_at IS NULL filters on all three
subselects.
- Synthetic hide-then-restore confirmed the view drops the tool_result
jsonb to null when its only part is hidden, and restores when un-hidden.
- EXPLAIN ANALYZE on the 42-message stress chat: 0.325ms (faster than
v1.13.1-B's 1.018ms — EXISTS short-circuits cleanly for the common
no-parts case).
- Normal turn (plain text prompt) completes unaffected.
Closes a v1.11.0 design item that was scoped but never implemented. With
v1.13's parts table the prune is dramatically cheaper to write — pre-parts
it would have meant editing JSON blobs in-place; now it's a hidden_at
flag and a view subselect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four independent items, all owed from prior dispatches.
- statement_timeout at the database level via:
ALTER DATABASE boocode SET statement_timeout = '30s';
Applied operationally; documented as a comment at the top of schema.sql
(ALTER DATABASE can't run inside a DO block, so it's not idempotent
inside applySchema). Re-apply after a volume reset.
- Tool registry alpha-sorted at module load. llama.cpp's prompt cache
hits on byte-identical prefixes; any reordering of the tool list near
the top of the system prompt would invalidate every cached turn.
Single-source sort at the ALL_TOOLS export so toolJsonSchemas() and
TOOLS_BY_NAME inherit the order automatically. New tools.test.ts
asserts the invariant; total tests 173 (was 172).
- Periodic in-process stuck-row sweeper. Runs every 60s, marks
'streaming' rows older than 5 minutes as 'failed', and publishes
chat_status='idle' on the user channel so the UI dot drops without a
refresh. Closes the mid-session crash UX gap; the v1.12.1 boot sweep
only fires once at startup, so sessions used to stay stuck until next
reboot. setInterval cleaned up via app.addHook('onClose'). Mirrors
handleAbortOrError's publish pattern.
- experimental_repairToolCall wired through AI SDK v6 streamText. Pass-
through implementation: log + return the original toolCall so the
stream keeps going. executeToolPhase's existing error paths (unknown
tool name → 'unknown tool: X' result; zod-reject → 'tool X rejected
— field: required') already surface bad calls to the model; the value
here is preventing the AI SDK from THROWING on parse errors and
killing the whole stream. Owed since v1.13.1-A.
Smoke verified:
- statement_timeout = '30s' confirmed via SHOW.
- Tool path normal flow intact (list_dir prompt → tool_call → result
→ final assistant). No malformed tool calls in the test run; repair
log will surface them when qwen3.6 actually emits one.
- Alpha order verified at runtime via the dist bundle: match: true.
- Sweeper logic not traffic-tested (no stuck rows to find), but the
SQL UPDATE + broker.publishUser pattern is identical to handleAbort
and the boot sweep — synthesis-only verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass 1 — ask_user_input correlation port (messages.ts:478, :549):
- The two correlation queries that backed the elicitation flow used to scan
messages.tool_calls and messages.tool_results JSON columns directly. They
now JOIN message_parts on payload->>'id' (for the caller assistant) and
payload->>'tool_call_id' (for the pending tool row). Semantics preserved:
ORDER BY m.created_at DESC LIMIT 1 still picks the latest issuance, the
already-answered 409 guard now reads payload.output, and the UPDATE +
parts replace inside sql.begin is unchanged from v1.13.0.
- Pre-v1.13.0 history has no parts rows and is unreachable to this lookup
path (404). Acceptable per dispatch decision — no pending elicitation
from before v1.13.0 will still be open. JSON-column fallback can land as
a hotfix if it ever surfaces.
Pass 2 — reasoning_parts wired end-to-end:
- types.ts/StreamResult gains `reasoning: string`. stream-phase.ts accumulates
reasoning-delta text per stream (replacing the v1.13.1-A counter-only
diagnostic) and returns it on the result.
- parts.ts/partsFromAssistantMessage gains an optional `reasoning` param.
When present it emits a kind='reasoning' part at sequence 0, ahead of
the text and tool_call parts.
- error-handler.ts/finalizeCompletion and tool-phase.ts/executeToolPhase
both thread result.reasoning into the dual-write call so reasoning-channel
models (qwen3.6) get persistent reasoning rows.
- payload.ts: loadContext SELECT pulls reasoning_parts from the v1.13.1-B
view; OpenAiMessage gains an optional `reasoning` field; buildMessagesPayload
collapses reasoning_parts into a single string per assistant message.
- stream-phase.ts/toModelMessages converts assistant messages with reasoning
into an AI SDK ModelMessage content array starting with a ReasoningPart,
matching the @ai-sdk/provider-utils AssistantContent union. Reasoning
models can now replay prior reasoning context across tool-call boundaries.
- types/api.ts and apps/web/src/api/types.ts Message interface gain
reasoning_parts (optional, nullable). Frontend doesn't render this yet —
field reserved for a v1.14 UI surface.
Tests: 2 new in parts.test.ts cover reasoning-at-sequence-0 with and
without text content. 172 tests pass (170 prior + 2 new).
Smoke verified against the live container:
- A reasoning-prompt ("walk through 17 × 23 step by step") produced one
message with kind='reasoning' (361 chars) at sequence 0 and kind='text'
(429 chars) at sequence 1. Adapter log confirmed reasoning capture.
- The new correlation SQL was validated against existing tool_call /
tool_result parts: returns the expected message_id + payload shape with
pending state correctly identified via payload.output IS NULL.
- ask_user_input end-to-end through the UI is Sam's smoke — the Prompt
Builder agent does not always trigger ask_user_input for these prompts,
so synthetic verification via SQL substituted for traffic-driven cover.
Annotation: the v1.13.1-A abort-throw site in stream-phase.ts got a
one-liner comment ("AI SDK v6 fullStream returns normally on abort; check
signal explicitly.") to prevent a future refactor removing it.
v1.13.2 drops the dual-write + the JSON columns + collapses the view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- schema.sql: new messages_with_parts view. tool_calls aggregates parts
with kind='tool_call' as a jsonb array of {id, name, args}; tool_results
picks the single sequence=0 part with kind='tool_result' as a jsonb
{tool_call_id, output, truncated, error?}. COALESCE against the legacy
jsonb columns means pre-v1.13.0 history (no parts rows) still reads
correctly via the fallback, and fresh inserts (where parts dual-write
follows the row INSERT) hit the legacy columns until the parts land.
- reasoning_parts column added to the view but not selected by any caller
yet — v1.13.1-C extends the Message type and pulls it into the model
payload alongside the type extension.
- Read sites switched to FROM messages_with_parts:
- routes/chats.ts:427 (chat history GET)
- routes/messages.ts:95 (session history GET)
- routes/ws.ts:27 (WS snapshot on session connect, resume path)
- services/inference/payload.ts (loadContext for model assembly)
- services/compaction.ts (compaction's payload assembly)
- chats.ts:394 (discard_stale UPDATE RETURNING) unchanged — UPDATEs target
messages directly and the returned shape is for a freshly-modified row
where the legacy column is dual-written and correct.
- messages.ts:478/549 (ask_user_input correlation) intentionally not
migrated — those query a different shape, ported in v1.13.1-C.
- Writes still target `messages` directly; the view is read-only.
Smoke verified against the live container:
- Equivalence: 5/5 messages with both legacy column and parts row return
identical tool_calls jsonb between FROM messages and FROM messages_with_parts.
- Perf: EXPLAIN ANALYZE on the 42-message stress chat returns in ~1ms
(50ms threshold). Bitmap Index Scan on message_parts_msg_seq_idx
carries the parts lookups.
- API contract: GET /api/chats/:id/messages returns identical
{id, name, args} tool_calls and {tool_call_id, output, truncated, error}
tool_results shapes to frontend consumers — no UI changes needed.
- Inference path: sent a view_file prompt; assistant turn 1 emitted the
tool_call, tool message captured the result, follow-up assistant turn
read the result back via loadContext (now view-backed) and answered
correctly. End-to-end loop intact.
v1.13.2 drops the dual-write + the JSON columns + simplifies the view
to just SELECT FROM message_parts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add ai@^6 and @ai-sdk/openai-compatible@^2 to apps/server.
- New services/inference/provider.ts: createOpenAICompatible against
llama-swap (baseURL threaded from config.LLAMA_SWAP_URL, cached per
baseURL). No apiKey — Authelia + Tailscale gate llama-swap, not keys.
- streamCompletion rewritten as an adapter over streamText. AI SDK
fullStream parts (text-delta, tool-call, finish, error) map back to
the legacy {content?, tool_calls?, finishReason} StreamResult shape
that executeStreamPhase already consumes. No layer above
streamCompletion changes.
- toModelMessages converts BooCode's OpenAI-shaped history to AI SDK
ModelMessage[]; tool messages need toolName which we look up by
scanning earlier assistant tool_calls for the matching id.
- buildAiTools wraps BooCode's JSON-schema tool defs via
tool({ inputSchema: jsonSchema(parameters) }) with NO execute —
BooCode dispatches tools in tool-phase.ts, not the AI SDK loop.
- XML fallback parser preserved as-is — qwen3.6 still emits XML tool
calls in text content that the structured tool-call layer misses.
- reasoning-delta parts dropped with a debug-level counter — captured
properly in v1.13.1-C.
- Abort path: streamText({ abortSignal }) wires ctx.signal through, but
AI SDK v6 swallows the abort (fullStream iterator exits cleanly
rather than throwing). Post-iteration `if (signal?.aborted) throw` so
handleAbortOrError owns the row and writes status='cancelled'. Caught
by smoke D; would have shipped as status='complete' on stop otherwise.
- Usage frame reads result.usage (inputTokens / outputTokens v6 names)
AFTER stream drain. Single trailing publish through the existing 500ms
throttle. Known regression: ChatThroughput's live mid-stream tick
(v1.12.2) is gone — it now shows a single value at stream end.
TODO(v1.13.1-followup): interpolate outputTokens during streaming
via a delta-cadence counter (e.g. part.text.length/4 token proxy)
and publish every 500ms; reconcile against result.usage at finish.
- Write-path dual-write from v1.13.0 unaffected.
Read path stays on JSON columns. v1.13.1-B flips reads to message_parts.
Smoke verified end-to-end against running container:
- A. Plain text: status='complete', 1 text part.
- B. Single tool prompt → multi-tool chain (4 calls): every assistant
with tool_calls has 2 parts (text+tool_call), every tool row has
1 part (tool_result).
- C. Multi-step covered by B's chain.
- D. Stop mid-stream: status='cancelled' written via handleAbortOrError
after the post-iteration abort throw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a granular message_parts table (one row per text/tool_call/tool_result
chunk) without changing any read path. Old messages.content / tool_calls /
tool_results columns remain authoritative for v1.13.0; this dispatch is
write-only mirroring so the AI SDK migration in v1.13.1 can flip read
authority without a backfill window.
Schema:
CREATE TABLE message_parts (id, message_id FK ON DELETE CASCADE,
sequence int, kind text CHECK (text|tool_call|tool_result|reasoning|step_start),
payload jsonb, created_at, UNIQUE (message_id, sequence))
New module services/inference/parts.ts with two pure derive helpers
(partsFromAssistantMessage, partsFromToolMessage) and insertParts that
fan-outs a multi-row INSERT via postgres-js.
Wired dual-write at every site that writes tool_calls or tool_results:
- tool-phase.ts: assistant finalize UPDATE, executed-tool UPDATE,
ask_user_input sentinel UPDATE
- messages.ts answer flow: DELETE pending tool_result part + INSERT
answered one inside the existing sql.begin
- skills.ts: synthetic assistant + tool INSERTs both inside existing tx
- chats.ts fork: CTE clones parts via ROW_NUMBER pairing (source→dest
message id mapping in one statement, no N+1)
- error-handler.ts finalizeCompletion: text part for plain text-only
assistant turns
Deviation: tool-phase.ts finalize UPDATEs and finalizeCompletion text-part
write are not wrapped in fresh sql.begin transactions. Safe in v1.13.0
because JSON columns are authoritative for reads. v1.13.1 must wrap these
sites before flipping read authority — TODO comments added at each
unwrapped site referencing v1.13.1.
Tests: 8 new unit tests for the derive helpers in
services/__tests__/parts.test.ts. Existing 162 tests untouched. 170 total.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sentinel-summaries.ts: runCapHitSummary, insertCapHitSentinel,
runDoomLoopSummary, insertDoomLoopSentinel
- inference.ts → inference/turn.ts: residue is runAssistantTurn,
runInference, createInferenceRunner orchestration only
- inference/index.ts: re-export shim preserves the public surface
(createInferenceRunner, runInference, runAssistantTurn,
detectDoomLoop, DOOM_LOOP_THRESHOLD, buildMessagesPayload, plus
type-side InferenceContext/InferenceFrame/StreamResult/TurnArgs/
FramePublisher)
- src/index.ts + auto_name.ts + the two vitest test files updated to
import from ./services/inference/index.js explicitly (NodeNext ESM
doesn't honor directory-index resolution)
Final tally: 11 files under services/inference/, the largest being
sentinel-summaries.ts at 523 LoC (two near-clone summary paths kept
side-by-side until a third sentinel justifies factoring out a shared
runWrapUpSummary). turn.ts is now 326 LoC, the next-largest is
stream-phase.ts at 380. Public import surface unchanged.
tool-phase.ts → turn.ts back-edge for runAssistantTurn remains
(cycle is safe; resolved at call time).
Prepares the file structure for v1.13 AI SDK migration — streamText
swap targets stream-phase.ts only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- stream-phase.ts: streamCompletion, executeStreamPhase (plus sseLines,
StreamOptions, ChatCompletionDelta/Chunk as private helpers)
- tool-phase.ts: executeToolPhase + private executeToolCall
- types.ts: shared StreamPhaseState + DB_FLUSH_INTERVAL_MS so the
summary functions still in inference.ts can reference them without
pulling from a phase file
Cycle: executeToolPhase recurses into runAssistantTurn, which stays in
inference.ts. Resolved by direct value back-edge — tool-phase.ts does
`import { runAssistantTurn } from '../inference.js'` and runAssistantTurn
is now exported. Safe because the dereference happens inside an async
function body, after both modules have fully evaluated. No
callback-through-args fallback needed.
inference.ts shrinks from ~1401 to ~828 LoC. Final Dispatch D moves the
sentinel summaries out and renames the residue to inference/turn.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- payload.ts: buildMessagesPayload (re-exported), loadContext,
maybeFlagForCompaction
- error-handler.ts: handleAbortOrError, finalizeCompletion
Both new files type-import InferenceContext/StreamResult/TurnArgs from
inference.ts; ESM elides type imports so there's no runtime cycle.
handleAbortOrError turned out not to call the summary functions, so
no back-edge needed.
inference.ts shrinks from ~1676 to ~1401 LoC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure file moves. No behavior change. inference.ts retains createInferenceRunner
public surface; new files are internal to services/inference/.
- budget.ts: resolveToolBudget
- sentinels.ts: detectDoomLoop (re-exported through inference.ts),
isCapHitSentinel, isDoomLoopSentinel, isAnySentinel
- xml-parser.ts: parseXmlToolCall, partialXmlOpenerStart
First of four refactor batches preparing inference.ts for the v1.13
AI SDK migration. inference.ts goes from 1780 LoC to ~1620.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an assistant message sits status='streaming' with no token activity
for 60+ seconds, the chat shows a banner above the input offering Retry
or Discard. Both clear the stale row via a new backend endpoint
POST /api/chats/:id/discard_stale that updates status='failed' and
publishes chat_status='idle'.
Closes the UX gap that caused the 2026-05-21 debugging spiral —
slow streams and dead streams now look different to the user.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChatThroughput renders inline beside StatusDot while streaming or
tool_running. Subscribes to existing usage frames via sessionEvents.
Hides when status drops to idle/error or data is older than 10s.
Addresses the 2026-05-21 spike's UX gap where slow streams looked
identical to dead streams — now there's a live token velocity readout
that immediately distinguishes the two.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- handleAbortOrError now writes status='cancelled' on user stop; rows
no longer stuck 'streaming' forever
- Drop stale messages_status_check constraint (only messages_status_chk
remains, allowing 'cancelled' via TS MESSAGE_STATUSES)
- Remove detectSameNameLoop and DOOM_LOOP_SAME_NAME_THRESHOLD (added
during 2026-05-21 debugging spike, never fired in any real run,
existing detectDoomLoop covers actual failure modes)
- Remove 12 ctx.log.info diagnostic markers added during the same
spike (verbose for production)
- Bundles workspace pane sync + status indicator overhaul +
startup hung-row sweep landed earlier in v1.12.1 work
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Status indicator (StatusDot): drops the flat amber pulse for a richer set
of states — orbiting amber for streaming, spinning sky ring for tool_running,
static violet for waiting_for_input, plus the existing idle/error. Backend
chat_status frame widens from 'working|idle|error' to discriminate streaming
vs tool execution vs paused for user input.
Workspace pane sync: pane layout moves from per-device localStorage to
server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace
broadcasts session_workspace_updated on the user channel for cross-device live
sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop.
Legacy localStorage seeds the server on first hydrate, then is deleted.
Deprecated session_panes table dropped.
Resilience: startup sweep marks any stale 'streaming' message older than
5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart.
useWorkspacePanes gains validatePanes() to prune dead chatId references from
saved pane state when the chat list lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add detectSameNameLoop (threshold 5) to catch over-verification hangs
where tool args vary but the model is stuck on one tool. Add 12 structured
log points across the inference state machine (runAssistantTurn,
executeToolPhase, runDoomLoopSummary) to diagnose the deterministic hang
surfaced in v1.12.0 smoke testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed /opt/boocode/AGENTS.md (per-project override) — the project's
agents now resolve from the global /data/AGENTS.md only. Eliminates the
two-files-must-stay-in-sync footgun that surfaced during B.3
verification.
Fix: agents.ts ALL_TOOL_NAMES was a hardcoded 9-item whitelist that
silently filtered any unknown tool name from agent.tools arrays. This
caused web_search/web_fetch (v1.11.8) and the 8 codecontext tools to be
dropped at parse time. Replaced with ALL_TOOLS.map(t => t.name) for
single source of truth. Pre-existing exposure was dormant since no
builtin agent listed web_search; surfaced by adding codecontext.
New /opt/boocode/codecontext/ directory holding the codecontext sidecar
that BooCode's tool wrappers (track B.2) will talk to. No BooCode-side
changes yet — this commit lands the sidecar standalone.
- Dockerfile: multi-stage golang:1.24-alpine → alpine:3.20. Clones
codecontext at v3.2.1 from github.com/nmakod/codecontext (cgo build for
tree-sitter bindings), builds the shim alongside (CGO_ENABLED=0).
- shim.go: stdlib-only Go HTTP server wrapping codecontext's stdio MCP
child. Newline-delimited JSON framing per the MCP transport spec
(NOT LSP-style Content-Length). 8 POST /v1/* endpoints, one per MCP
tool, plus GET /health. Child supervised via child.Wait() goroutine
that os.Exit's on death so the container's restart: unless-stopped
policy fires (Signal(0) on a zombie returns nil and is not a liveness
check — discovered during kill-restart testing).
- go.mod: no third-party deps; future Go security advisories don't apply.
docker-compose service: joins boocode_net (no host port), mounts
/opt:/opt:ro (BooCode projects live at /opt/<slug>, not exclusively
under /opt/projects), healthcheck on /health.
Verified: build clean, healthcheck reports healthy ~15s after up,
multi-project queries return valid markdown, target_dir swap works on
subtree paths. Kill-restart cycle completes in ~200ms with one failed
health poll observed (no misleading "ok" during the gap). Memory: 24.6
MiB after 5 search_symbols calls, 5.6 MiB after 30 min idle — codecontext
releases the per-call graph between target_dir swaps, so the shim doesn't
hold the indexed state.
Adds two new tools registered through the existing ALL_TOOLS registry:
- web_search hits SearXNG's JSON API (Fathom, internal Tailscale URL,
no auth) and returns top results
- web_fetch retrieves a URL's text content, gated by isPublicUrl
(url_guard.ts) which blocks loopback / RFC1918 / Tailscale CGNAT /
link-local / .local / .internal / non-http schemes
Both tools are opt-in via the existing session.web_search_enabled flag
(plumbed in v1.9, activated here). Default off. UI labels updated to
"Enable web search and fetch" / "Web search and fetch" since fetch joins
the same store. Counts against the v1.8.2 per-turn budget; covered by
the v1.11.6 doom-loop guard.
Native Node 20 fetch — no new prod dep. HTML stripping via regex (script
and style content elided wholesale). 5MB body cap, 15s fetch timeout,
8000-char default output, 32000-char cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports continue.dev's DEFAULT_SECURITY_IGNORE_FILETYPES + ignored-dir lists
into apps/server/src/services/secret_guard.ts plus a small BooCode
additions block (id_rsa*, *credentials*, .netrc, *.kdbx). Tiny glob-to-
regex matcher; no new prod dep.
view_file hard-refuses via SecretBlockedError. list_dir / grep /
find_files filter their results and surface a pathguard_note string
field with the hidden count — never list the offending paths back.
Named secret_guard.ts (not safety/pathGuard.ts) to avoid collision with
the existing path_guard.ts which already exports a pathGuard() function.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was picking the alphabetically-first agent from AGENTS.md ("Code
Reviewer") which felt presumptuous. New sessions now create with
agent_id=null; user picks from the AgentPicker if they want one.
Removes resolveDefaultAgent helper + the getAgentsForProject import
since this was the only caller. The project SELECT no longer needs
the path column either.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ContextBar relocated from a dedicated row above MessageList to inline with
the agent-picker row, filling the space to the right of the picker + plus
button. Always-visible (zero-state when no assistant message has run yet)
via chat.model_context_limit, which GET /api/sessions/:id/chats now
populates from a single getModelContext lookup per session.
ChatContextPopover above the input is removed entirely along with its
useChatContextStats hook (no remaining callers). Color tiers and the
auto-compaction threshold tooltip unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- llama-server does not emit n_ctx in timings (confirmed empirically);
dead code at inference.ts:479 and compaction.ts:300 never fired
- New model-context.ts: cached fetch of /upstream/<model>/props
with positive-cache (no TTL) and 60s negative-cache
- Wired into all 4 ctx_max write sites: 3 in inference.ts
(executeToolPhase, finalizeCompletion, runCapHitSummary) and
1 in compaction.ts (summary row INSERT)
- AbortController 3s timeout, lenient parsing with sensible defaults
- 12 new vitest cases for the cache module (59 total)
- 7 historical assistant rows backfilled manually (see notes)
Walks chat messages newest-first for the latest ctx_used/ctx_max pair.
Color tiers fire against (max - 20k compaction reserve) so the bar warns
amber/orange/red at the same boundaries auto-compaction triggers.
"Context" → "Ctx" at <640px, (NN%) drops at <380px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces single onNewChat handler with onAddPane(kind). Terminal pane
header gets matching + dropdown. Context menu "New chat" stays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Some local models (qwen3-coder via llama-swap) emit tool calls as inline XML
inside delta.content rather than structured delta.tool_calls. streamCompletion
now buffers delta.content, extracts complete <tool_call>...</tool_call> blocks
via parseXmlToolCall, and pushes synthetic entries (id prefix xml_call_) into
the existing toolCallsBuffer. Native JSON path unchanged — both coexist.
Partial openers are held back so a tool tag never leaks to the chat mid-tag.
Unclosed XML at end-of-stream is flushed as plain content (no silent drops).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FitAddon's proposeDimensions() always subtracts a phantom scrollbar width even
when CSS hides the scrollbar — losing one column of usable width. fitFull()
divides host clientWidth/clientHeight by the renderer's reported cell size
directly. Also POSTs the resized cols/rows back to /api/term/.../resize on
initial mount and after fonts.ready so bash/opencode get the correct PTY
size before the user types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>