- 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)
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>
- /data/skills mount (host: /opt/skills)
- skill_find, skill_use, skill_resource added to default read-only
tool set; opt-in for agents with explicit tools: whitelist
- AGENTS.md builtin agents drop explicit tools: arrays to inherit
the new default (now includes skill tools)
- POST /api/chats/:id/skill_invoke for slash-command flow
- 19 SKILL.md files seeded at /opt/skills/ across 6 source groups
Adds a singleton, ephemeral 'settings' pane kind to the workspace.
Opened via a new bottom-pinned button in ProjectSidebar (emits an
open_settings_pane event when a session is mounted; navigates to
/settings otherwise). Pane has three sections — Session, Project,
Theme — and a maximize toggle that hides sibling pane columns via
display:none on desktop only. Settings panes don't count toward
MAX_PANES and are filtered out of the localStorage persistence layer
so reload always restores a clean workspace.
Schema (additive):
- projects.default_system_prompt TEXT NOT NULL DEFAULT ''
- projects.default_web_search_enabled BOOLEAN NOT NULL DEFAULT false
- sessions.web_search_enabled BOOLEAN (nullable; null = inherit)
Inference resolves user_prompt = session.system_prompt.trim() ||
project.default_system_prompt.trim() — empty/whitespace at either
layer means "no override". Keeps the columns NOT NULL and matches
the existing inherit semantics.
Server routes:
- GET /api/projects/:id (new; settings pane refetches on
project_updated)
- PATCH /api/projects/:id accepts default_system_prompt,
default_web_search_enabled
- PATCH /api/sessions/:id accepts web_search_enabled (tri-state)
- POST /api/projects/:id/sessions/archive-all + GET
/api/projects/:id/sessions/open-count
- POST /api/sessions/:id/chats/archive-all + GET
/api/sessions/:id/chats/open-count
- PATCH /api/sessions/:id now broadcasts session_updated on every
successful PATCH (was rename-only). Lets SettingsPane open in
another tab pick up edits without a refetch.
Bulk-archive publishes one session_archived / chat_archived frame
per affected id so useSidebar's existing reducer cases handle them
incrementally — no new frame type, no payload widening.
ModelPicker refactored: shared ModelList inside a responsive shell.
Desktop = labeled trigger + DropdownMenu, mobile = icon-only Cpu
button + BottomSheet. Header in Session.tsx drops the pill wrap on
mobile since the new trigger is the visual.
ChatInput gains an icon-only '+' DropdownMenu next to AgentPicker
when sessionId + webSearchEnabled props are provided. One item for
now — Web search — with a checkmark reflecting the stored value
(true), not the effective one. Click PATCHes the override; to
restore inherit-from-project the user opens SettingsPane.
ThemePicker lifted out of pages/Settings.tsx into a reusable
component. The standalone /settings route is now a thin wrapper
that mounts <ThemePicker /> with a Back button on top
(navigate(-1) with fallback to '/'); the SettingsPane Theme tab
renders the same picker bare.
Project section delete-flow removed (button + confirm dialog +
handler). Replaced with "Archive all sessions" using the same
two-step count → confirm → fire pattern as "Archive all chats" in
the Session section. api.projects.remove() stays in the client
because useProjects.ts still uses it.
Hand-rolled Switch primitive in SettingsPane (no shadcn switch in
the project; spec said no new deps). Section nav is plain buttons
(no shadcn Tabs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old hardcoded MAX_TOOL_LOOP_DEPTH=15 replaced by per-agent
max_tool_calls (1-100, AGENTS.md frontmatter) with defaults: 30 for
read-only-only agents, 10 for agents that include any non-read-only
tool, 15 for raw chat. When the loop hits cap, fire one final summary
call with tools disabled, stream the wrap-up into the in-flight
assistant message, then insert a system sentinel with
metadata.kind='cap_hit'. The sentinel renders an amber bubble with a
Continue button (latest sentinel only) that POSTs to a new
/api/chats/:id/continue route to extend. Hard ceiling: 3 cap-hits per
chat (2 continues max) — third sentinel reports can_continue=false.
Error frames carry a machine-readable reason code alongside human
error text. Failed messages persist the reason via
metadata.kind='error' so the bubble renders specifics on reload (WS
error frame is one-shot).
Tool call UI rewired: ToolCallLine renders inline (↳ name args
spinner/check/✗, expand-on-tap for args+result); ToolCallGroup
collapses 3+ consecutive same-tool runs into a compact card.
MessageList owns a three-pass pre-render (flatten + fold tool
results onto matching runs by id + group same-tool runs + number
sentinels). MessageBubble drops tool rendering and adds the
sentinel / error-reason branches. ToolCallCard deleted.
Roadmap follow-up logged: add explicit max_tool_calls: 30 to the 6
agents in /data/AGENTS.md and /opt/boocode/AGENTS.md post-ship for
discoverability (defaults handle behavior identically).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builtins move out of code into /data/AGENTS.md (always-on, mounted ro
into the container); per-project AGENTS.md is now an optional override.
agents.ts merges global + project entries with project-wins-by-name and
caches per-source mtimes (60s TTL). Parser switches to per-block
try/catch and returns AgentsResponse { agents, errors[] } so one
malformed block no longer fails the file. AgentPicker shows a
non-blocking amber chip listing skipped blocks and only fires a gray
toast when zero agents loaded.
WS reconnect UX (useUserEvents + useSessionStream) now silent on the
first disconnect; createWsReconnectToast escalates to gray after 3
failures or 15 s, then to red with a Retry Now action after 60 s.
useSessionStream also gained the exponential-backoff reconnect it was
missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile header is now two rows. Row 1: hamburger | project · branch
indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker |
FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) +
NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch
panes via the sheet. Cross-tab status sync via chat_status frames
published from inference.ts at working/idle/error transitions; StatusDot
component renders amber-pulse/green/red/gray on each pane row and on
desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only
git_status tool to the model, backed by services/git_meta.ts (execFile
+ 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks
as props (hoisted into Session.tsx) so the header pill shares state
with the pane grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six builtin defaults (Code Reviewer, Debugger, Refactorer, Architect,
Security Auditor, Prompt Builder) with no model field so session.model
wins. Project root AGENTS.md parsed on demand with mtime cache; when
present, only its agents are shown. sessions.agent_id resolves per turn
into effective system prompt, temperature, and a tool whitelist applied
in inference. AgentPicker mounts in the ChatInput toolbar; SettingsDrawer
agent surface deferred to Batch 7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a chat is auto-named, also rename the parent session if it is
still on its default 'New session' label. UPDATE is gated by an
atomic WHERE clause so user renames and prior propagations are not
clobbered. Publishes session_renamed via broker.publishUser; useSidebar
already listens.
Closes the gap where sessions auto-created from the sidebar would
stay 'New session' forever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Allows assistant turns up to 15 tool calls in a single chain before
the loop-depth guard trips. Real chats commonly need 6-10 tool calls
(grep -> view_file -> view_file -> grep -> view_file -> answer); the
old cap of 5 was firing on legitimate investigation patterns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile: install git + openssh-client in runtime image; pre-populate
/root/.ssh/known_hosts with the Tailscale ssh-keyscan for
100.114.205.53:2222 (Gitea SSH). Without these, the bootstrap push
step from inside the container fails with "command not found" or
host-key prompts.
- docker-compose.yml: mount ./secrets/boocode_gitea as
/root/.ssh/id_ed25519:ro so the container can authenticate to Gitea
over SSH for the initial push.
- .gitignore: add secrets/ so the keypair never lands in the repo.
- project_bootstrap.ts: rewrite the Gitea-returned ssh_url's hostname
from git.indifferentketchup.com to 100.114.205.53 before adding it
as origin, so the push hits the Tailscale interface that the
known_hosts entry covers.
- CreateProjectModal.tsx: preview label now reads "Folder:
/opt/projects/<name>" to match the new BOOTSTRAP_ROOT (was /opt/).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the previous /opt:/opt:rw bind into two mounts to narrow the
writable scope of the container:
- /opt:/opt:ro — read-only mount for legacy/existing project
add-existing flow. resolveProjectPath still uses
PROJECT_ROOT_WHITELIST (/opt by default) so existing projects under
/opt/<name> (analytics, boolab, boocode itself) continue to resolve
and serve their file-tree via the read-only tools.
- /opt/projects:/opt/projects:rw — writable mount targeted at the
create-new-project bootstrap path.
Picked Option B from the spec (simpler than two scan roots):
PROJECT_ROOT_WHITELIST stays /opt, new BOOTSTRAP_ROOT env var defaults
to /opt/projects and is used by project_bootstrap.ts as the mkdir
target. Bootstrap path-escape check now compares against
BOOTSTRAP_ROOT.
Prereq: host must `mkdir -p /opt/projects` before next container
restart. Documented in CLAUDE.md and .env.example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swallowed-error logging (audit Feature 3):
- file_index.ts:36-37 (git mtime probes): comment — best-effort, project
may not be a git repo.
- useUserEvents.ts:44 / 53 (ws.close on error / unmount): comments —
best-effort, socket may already be closing.
- RightRail.tsx:38 (localStorage write): comment — best-effort, quota or
private mode.
- App.tsx:21 (api.sessions.get for RightRail projectId): replaced silent
catch with console.warn.
- Session.tsx:38, 41 (session fetch + project list for breadcrumb):
replaced silent catches with console.warn.
H1: ProjectSidebar.tsx:189 — dropped the local sessionEvents.emit
({type:'session_renamed'}) after PATCH. Server publishes via
broker.publishUser since v1.4; useUserEvents forwards.
H2: useSessionStream.ts session_renamed case removed (dead — no
server code path publishes session_renamed on the per-session WS
channel; only user channel via broker.publishUser). Also dropped the
session_renamed variant from WsFrame (in apps/web/src/api/types.ts)
to keep the discriminated-union switch exhaustive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds vitest 3.x (pinned to ^3 because vitest 4 requires Vite 6, while the
web app pins Vite 5). Tests live under src/**/__tests__/**.
Three target functions:
- sanitizeFolderName (project_bootstrap.ts): 8 cases covering happy path,
path-traversal stripping, empty-after-sanitize, control chars, truncation
at 64, null bytes, leading/trailing dot/slash stripping.
- resolveProjectPath (projects.ts): 7 cases including symlink-escape via
realpath, outside-whitelist rejection, nonexistent path, AND a flagged
BEHAVIOR GAP: passing the whitelist path itself currently returns success
rather than erroring out (function early-exits the scope check when
real === whitelistReal). Test asserts current behavior with explicit
comment flagging the spec violation — function NOT silently patched.
Function made exportable for testing (single keyword change).
- buildMessagesPayload (inference.ts): 8 cases for compact-marker logic
(no marker, marker present, multiple compacts, tool-message position).
tsconfig.json excludes __tests__ + *.test.ts from emit so dist/ stays clean.
pnpm -C apps/server test => 23 passed in ~340ms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fork: POST /api/chats/:id/fork creates a new chat in the same session,
copies messages up to target (status=complete) with row-offset
clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane
event; Workspace opens it in the active pane. No maybeAutoNameChat on forks.
- Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is
currently streaming. Cascading-forward delete (created_at >= target).
MessageBubble Trash button + confirm Dialog.
- Header: Projects -> Project -> Session breadcrumb, model badge pill,
inline session rename, active file path via new useActivePane() hook.
Server now publishes session_renamed on PATCH /api/sessions/:id;
client-side dup emit removed from Session.tsx.
- Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead
PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill
INSERT removed (CREATE TABLE retained), Tailnet trust comment near
app.listen().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server:
- projects.status + projects.gitea_remote (additive) with CHECK ('open','archived')
- GET /api/projects?status=archived; PATCH /api/projects/:id (rename);
POST /api/projects/:id/archive | unarchive; POST /api/projects/create
- POST /api/projects ON CONFLICT (path) DO UPDATE SET status='open': re-add
of archived path restores existing row (preserves id + FKs); already-open
path returns 409. Detected-repos picker now excludes only status='open'.
- New gitea.ts (createGiteaRepo + GiteaRepoExistsError) and
project_bootstrap.ts (sanitize name, mkdir under PROJECT_ROOT_WHITELIST,
git init -b main + first commit with -c user.name/email per-command, optional
Gitea repo create + remote add + push; all via execFile, no shell).
- 3 new user-stream frames: project_archived, project_unarchived, project_updated.
- sidebar.ts now selects path + gitea_remote and filters status='open'.
- Gitea env added to config.ts (GITEA_BASE_URL, GITEA_USER, GITEA_TOKEN,
GITEA_SSH_HOST).
- docker-compose.yml /opt mount flipped to rw so create-project can mkdir.
- auto_name.ts gate relaxed from `!== 1` to `< 1` (fires on every turn while
chat name is empty, not only the first).
Web:
- ProjectSidebar: project rows use proper Radix ContextMenu; items Rename /
Archive / Open in Gitea. Inline rename, archive confirm dialog.
Removed obsolete handleRemove + DropdownMenu hack.
- Home: Add-existing + Create-new buttons; collapsible Archived Projects
section with Restore.
- New CreateProjectModal: name + live folder preview, commit msg, Private/
Public radio, create-Gitea-remote checkbox, toast on success/warnings.
- New projectUrls.ts giteaUrlFor() — uses gitea_remote when present,
falls back to convention URL.
- 3 new event types in sessionEvents.ts with idempotent useSidebar handlers.
- SidebarProject extended with path + gitea_remote so Open-in-Gitea can
resolve without a separate fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fastify global empty-JSON-body parser fixes archive/unarchive/stop 400s
- Removed redundant local sessionEvents.emit at all 5+2 sites with server-side WS publishers; added dedupe guards in useSidebar/Workspace/Project handlers
- Sidebar session right-click adds Delete (destructive) with confirm Dialog
- Session.tsx navigates away on session_deleted/session_archived for the active session
- SessionLandingPage chat rows show message_count, effective_context_tokens, last_message_preview via LATERAL joins on GET /api/sessions/:id/chats
- Workspace.tsx pane drag-to-reorder using native HTML5 events (no new deps)
- CompactCard: Copy toast, Send-to-chat with target chat name, empty-state in share popover, Re-run button
- auto_name.ts: filter count gate and assistant-fetch by content <> '' so tool-call assistant rows don't trip the once-and-only-once guard
- Adds CLAUDE.md and apps/web/src/lib/format.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session 1:N Chat data model with backfill. Workspace switches to client-side
multi-tab pane management. Right-rail file browser with float-over viewer and
click-drag line selection replaces FileBrowserPane. Adds /compact streaming
summarizer (respects compact markers in context builder), force-send (cancels
in-flight, persists partial as 'cancelled', awaits cancellation completion via
deferred Promise + 5s timeout), message queue, stop generation, chat
auto-rename, session archive/unarchive with Closed Sessions section on repo
landing page. CHECK constraints on sessions.status, messages.role,
messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES /
MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the
api.panes.* client block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop unused Stats type import and its no-op suppression expression
- Comment getProjectFiles concurrent-miss race (benign, accepted)
- Comment findFiles deliberate post-limit counting (differs from grep)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- file_ops.MAX_FIND_RESULTS: 1000 -> 200 to match existing tool cap and
preserve LLM behavior
- tools.find_files now delegates to file_ops.findFiles (parallels how
grep already delegates); drops ~50 LOC of duplicated path resolution
and rg subprocess
- Drop unused basename import in file_ops
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- broker.subscribeUser/publishUser via separate user topics map
- /api/ws/user WS route subscribes to the user channel
- projects/sessions POST/DELETE handlers emit lifecycle frames
- inference 3 terminal-state sites emit session_updated with RETURNING
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema (idempotent):
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();
The column already exists from v1 (DEFAULT NOW()); ALTER is a no-op kept for
self-documentation. Explicit clock_timestamp() bumps now run wherever the
column actually matters — see services/inference.ts and routes/sessions.ts.
Backend updated_at maintenance:
- services/inference.ts: after each terminal status UPDATE on the assistant
message (failure / tool-call complete / clean complete), also bump
sessions.updated_at = clock_timestamp() so the parent session jumps to
the top of recency ordering on every assistant turn.
- routes/sessions.ts PATCH: NOW() → clock_timestamp() for consistency.
New endpoint GET /api/sidebar (routes/sidebar.ts):
{ projects: [{ id, name, recent_sessions[≤6], total_sessions }] }
One outer query for projects ordered added_at DESC; per-project Promise.all
over (recent_sessions LIMIT 6 ORDER BY updated_at DESC) and COUNT(*)::int.
Outer Promise.all parallelizes across projects. Two queries per project; the
composite idx_sessions_project(project_id, updated_at DESC) serves the inner
query. Auth via the global Remote-User hook. types/api.ts gains
SidebarSession / SidebarProject / SidebarResponse; index.ts wires the route.
Frontend foundations:
- api/types.ts mirrors the three sidebar interfaces.
- api/client.ts: api.sidebar.get() → Promise<SidebarResponse>.
- hooks/sessionEvents.ts: five-variant union — added project_created,
project_deleted, session_created, session_deleted. session_renamed
unchanged from Batch 1. Bus internals untouched (still a dumb
Set<Listener>, no validation).
New hooks/useSidebar.ts (module-singleton):
- Module-scope sharedData/sharedError/sharedLoading/initialized/fetchInFlight/
subscribers; a single sessionEvents.subscribe at module-top-level mutates
sharedData via an exhaustive switch over the five events. load() dedupes
parallel calls via fetchInFlight. Hook is a thin subscription layer: any
number of mount points share state and the very first one triggers the
single GET /api/sidebar. Subsequent mounts read cached state synchronously
(no skeleton flash). Public shape: { data, error, loading, retry }.
- Lift to module-scope was driven by the "ONE sidebar request on mount"
spec promise — both ProjectSidebar AND Home consume the hook now, and
they share the singleton.
Frontend UI:
- components/ProjectSidebar.tsx (rewrite, 234 lines): per-project chevron +
folder + name; chevron toggles expand, name navigates /project/:id.
Expanded → ≤5 sessions with MessageSquare + name + muted relTime()
timestamp. "View all (N)" link when total_sessions > 5, routing to
/project/:id. Active session row uses bg-sidebar-accent. Active project
always renders expanded (URL-derived: direct /project/:id or scan of
recent_sessions for /session/:id). Expanded ids persisted in
localStorage['boocode.sidebar.expanded'] with try/catch on both read and
write. Loading shows 4 muted-pulse skeleton blocks; empty + error +
retry button; error toast guarded by ref so it fires once per distinct
message and resets on recovery. Remove path calls api.projects.remove
directly + explicit project_deleted emit (replaced the prior
useProjects() dependency which fired a redundant /api/projects on
mount, violating the one-fetch promise).
- components/AddProjectModal.tsx: captures returned Project and emits
project_created before onAdded() / onOpenChange(false).
- pages/Project.tsx: emits session_created after create(); trash button is
now async with try/catch — emits session_deleted on success,
toast.error on failure.
- pages/Home.tsx: switched from useProjects to useSidebar so loading /
fires exactly one /api/sidebar, with no parallel /api/projects.
- pages/Session.tsx: manual inline rename now emits session_renamed on
the success path so the sidebar updates live without a refresh (also
fixes the regression made visible by Batch 2 — the sidebar caches
session names where the project page used to re-fetch on every visit).
useProjects.ts retains a project_deleted emit inside remove for any future
caller; no live consumer uses it (ProjectSidebar calls api.projects.remove
directly). Acknowledged dead code, to be removed in the next cleanup pass
along with three remaining NOW() → clock_timestamp() consistency flips at
routes/messages.ts:70, routes/messages.ts:127, and services/auto_name.ts:144.
Cross-tab parity for session_created/session_deleted/project_created/
project_deleted is deferred — those events are tab-local in Batch 2 per
spec. session_renamed continues to propagate cross-tab via the existing
WS frame from Batch 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four features land together on this branch:
1. Markdown rendering — assistant messages go through react-markdown +
remark-gfm. Fenced code blocks render via existing CodeBlock (with copy
button); inline `code` is styled inline. User messages stay plain text.
No raw HTML (no rehype-raw).
2. Per-message Copy + Regenerate. New endpoint
POST /api/sessions/:id/messages/:message_id/regenerate validates the
target (404/400/409), atomically deletes the target plus any later
messages in the session, inserts a fresh streaming assistant row, and
enqueues a normal inference run. The DELETE bound uses a SQL subquery
(`created_at >= (SELECT created_at FROM messages WHERE id = $1)`)
instead of a JS round-trip so postgres TIMESTAMPTZ µs precision is
preserved — otherwise sub-ms clock_timestamp() differences between the
user row and the assistant row collapsed to the same JS Date, pulling
the triggering user message into the >= bound. New `messages_deleted`
WS frame so already-connected clients prune the stale tail without
needing a full snapshot resend.
3. tok/s + ctx counter. Five new nullable message columns: tokens_used,
ctx_used, ctx_max, started_at, finished_at. started_at is set right
before the OpenAI call in services/inference.ts (not in the route, not
in the frame handler); finished_at + tokens_used + ctx_used + ctx_max
are committed in the same UPDATE that flips status to 'complete'. The
inference request now opts into stream_options.include_usage so the
final chunk carries usage; defensive parsing also picks up timings.n_ctx
when llama.cpp emits it (currently absent for our llama-swap models, so
ctx_max stays NULL and the UI just shows `<used> ctx`). message_complete
frame extended with tokens_used / ctx_used / ctx_max / started_at /
finished_at / model. Frontend StatsLine in MessageBubble computes tok/s
client-side from the timestamps and renders muted mono text below the
body of completed assistant messages.
4. AI chat naming after the first turn. Backend services/auto_name.ts
runs via setImmediate after the top-level inference resolves; it
checks that there is exactly one completed assistant message and that
the session has not been user-renamed (`name IS NULL OR name = '' OR
name = 'New session'`), then fires a single non-streaming chat
completion with the spec prompt. Qwen3 chat templates emit chain-of-
thought into reasoning_content and burn the entire max_tokens budget
without producing visible output, so the request includes
`chat_template_kwargs: { enable_thinking: false }` and max_tokens=30.
Title is trimmed, quote-stripped, "Title:" prefix dropped, and
truncated to 60 chars before a guarded UPDATE on sessions.name. New
`session_renamed` WS frame propagates to the open session view
directly and to the project's session list via a tiny module-scope
event bus (apps/web/src/hooks/sessionEvents.ts) — kept dumb: one event
type, two methods, no library.
Cleanups: dropped the now-unused splitCodeBlocks export from CodeBlock.tsx
(react-markdown supersedes it), and added a long-form NOTE in auto_name.ts
documenting the enable_thinking + max_tokens pattern for any future Qwen-
family non-streaming utility calls (planned: fork-message, agent-routing,
web-search summarization).
Schema bootstrap remains idempotent (ADD COLUMN IF NOT EXISTS). Auth,
broker, clock_timestamp() conventions, and zod validation all unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>