Compare commits

..

28 Commits

Author SHA1 Message Date
a72f7954b4 feat(web,coder): add analytics + results pages for token usage and run history
New /analytics route: token usage dashboard with aggregate summary,
per-session breakdown, context window stats, and per-category token
distribution. Data served from existing agent_sessions + tool_cost_stats.

New /results route: browsable archive of orchestrator flow runs and
arena battles. Two-tab layout (Analysis Runs / Arena Battles) using
existing API endpoints (no new backend).

Sidebar gains Results (ScrollText icon) and Token Analytics (BarChart3
icon) nav buttons above Settings.
2026-06-07 22:16:25 +00:00
31d8efe66a feat(web): enhanced file panel — side-by-side diff, hide whitespace, inline review
Adds DiffSplitView component for side-by-side diff mode, whitespace-only
change filtering, inline review comments with thread/gutter cell UI, diff
preferences persistence, and write-file API support for in-browser editing.

Backend: hideWhitespace param on git diff endpoint, write_file route.
2026-06-07 22:16:20 +00:00
c935687725 chore(openspec): drop 9 superseded proposals + 11 stub archive files
Drop 9 batch proposals that are superseded by the boocode-lift-analysis
(boocontext-audit, conductor upgrades, self-healing/verify-gate skills):
add-3tier-memory, import-llm-evaluator, import-pregel-engine, plugin-platform,
conductor-evolution, code-intelligence-upgrade, dev-workflow, ui-overhaul,
agent-reliability.

Delete 11 stub archive files (49-66B each, 'Status: Shipped. Archived.' only)
that provide zero documentation value over the existing CHANGELOG.md + git tags.
2026-06-07 22:15:38 +00:00
0d6e9a2413 feat(coder): complete orchestrator advanced patterns
- Approval gate steps pause and await human resolution
- appendStepEvent wired into markStep, failRun, dispatchAgentStep
- Trigger rule unit tests (6 variants)
- New parallel-research flow with one_success trigger
2026-06-07 21:55:47 +00:00
6344105877 feat(server): memory v2 tests and search_memory tool 2026-06-07 21:55:47 +00:00
028c08b4cd docs: add openspec proposals for memory v2 and orchestrator flow patterns 2026-06-07 21:34:35 +00:00
fb52eb3efa feat(coder): orchestrator advanced flow patterns
- TriggerRule type (all_success/one_success/all_done) for parallel deps
- Variable substitution ($stepId.output.field) in agent step prompts
- Approval gate step kind (pauses flow via permission frames)
- flow_step_events table for append-only event-sourced step log
- evaluateTriggerRule pure function in flow-runner-decisions
2026-06-07 21:34:30 +00:00
648a59a563 feat(server): memory v2 — BM25 + local embedding hybrid search
- Bm25Ranker: Okapi BM25 scoring (pure TS, no deps)
- Embedding module: ONNX-based local embeddings via onnxruntime-node
- Hybrid recall: BM25 (30%) + cosine similarity (70%) weighted merge
- Falls back to keyword-only via MEMORY_SEARCH=keyword env var
- extract_memory agent tool for persisting memory entries
2026-06-07 21:34:25 +00:00
7f59f30f2d docs: update code review doc with v2.8 fork-lifts lift sources
- Added 10 new lift source entries (boocontext, TSA, type-inject,
  morph-fast-apply, tokenscope, DCP, qwen-code memory/LSP,
  oh-my-openagent, paseo protocol) under v2.8 fork-lifts section
- Added 9 new rows to the lift catalog table
- Added decisions log entry for v2.8.0-fork-lifts batch
- Bumped last-updated to 2026-06-07
2026-06-07 18:44:12 +00:00
f436021bf9 feat: deferred items — arena token API + UI, ToolShim docs
- Arena API: token_breakdown selected in contestant query
- ArenaPane: token category breakdown bar (s/u/a/t/r) in expanded contestant view
- apps/server/CLAUDE.md: document tool-shim and loop-detectors
2026-06-07 18:41:26 +00:00
bef6bef504 docs: update changelog, roadmap, current focus, and coder CLAUDE.md
- CHANGELOG: v2.8.0-fork-lifts entry covering all 8 integrations
- Roadmap: update shipped header through v2.8.0, bump last-updated date
- CURRENT.md: reflect fork-lifts as last-shipped batch
- apps/coder/CLAUDE.md: document edit-guards behavior and API
2026-06-07 18:05:55 +00:00
87923cb07b feat(coder): add flow-artifacts write helper and boocontext MCP template 2026-06-07 18:05:49 +00:00
c6ecd984c5 feat(coder): add TokenScope analyzer and DB persistence module
- analyzeMessages classifies message parts into system/user/assistant/tools/reasoning
- persistTaskBreakdown writes JSONB to tasks table
- Backfills the token-analysis/ module (contract committed earlier)
- 6 unit tests covering classification, tool calls, reasoning tokens
2026-06-07 18:05:35 +00:00
2a83f61070 feat(coder): add import-drop detection to edit safety guards
- checkDroppedImports detects removed import/require lines in edits
- Runs alongside truncation guard in pending_changes.ts
- Supports ESM imports, CJS require, type imports, side-effect imports
2026-06-07 18:05:30 +00:00
44874f0097 feat: fork lifts phases 3-9 — LSP, DCP, memory, boocontext, protocol, plugins, reliability 2026-06-07 17:58:30 +00:00
1b70d41996 feat(server): add inference reliability - tool-shim and loop detectors
- ToolShim recovers XML/JSON tool calls from plain-text model output
- detectContentRepeat catches same-content loops
- detectToolLoop catches repeated tool invocations
- detectDoomLoop combines both detectors
2026-06-07 17:57:58 +00:00
b64941ad4b feat(coder): add plugin hook host
- Typed hook registry with registerHook/emitHook/clearHooks
- Hooks: tool.execute.before/after, turn.start/end, task.terminal
- SUL patterns only (oh-my-openagent: architecture study, no code copy)
2026-06-07 17:57:53 +00:00
cdc782e044 feat(core): add subagent protocol enhancements
- AgentCapabilitiesSchema with supportsStreaming/Reasoning/Background flags
- supportsStreaming and supportsReasoningStream fields in ProviderSnapshotEntry
- new_task tool: background mode flag for non-blocking subtask dispatch
2026-06-07 17:57:49 +00:00
02bb355a09 feat(server): add institutional memory recall
- File-based memory under .boocode/memory/ (project/user/reference topics)
- Hierarchical 4-scope scan: global → home → project → session
- Keyword/tag relevance matching for query-based recall
- Injected as <boocode-memory> block in system prompt at assembly
- v1 recall-only (extract/dream deferred to v2)
2026-06-07 17:57:44 +00:00
b8b2666fdc feat(server): add DCP clean-room context pruning
- Deduplication: removes consecutive identical tool_call+tool_result pairs
- Purge-errors: removes failed/empty tool results
- Transform orchestrator runs strategies in sequence pre-payload
- Wired into turn.ts before buildMessagesPayload
- Clean-room reimplementation (AGPL reference: behavior only)
2026-06-07 17:57:39 +00:00
ee749d8698 feat(coder): add LSP code intelligence tools
- lsp/ module: types, config, JSON-RPC client, server-manager, operations
- lsp_diagnostics: TypeScript/JavaScript diagnostics for a file
- lsp_goto_definition: find symbol definition at position
- lsp_find_references: find all references to a symbol
- Registered as READ_TOOLS in tool index
2026-06-07 17:57:35 +00:00
bc83475a3d feat(server): add boocontext deep analysis tools and synthesis pipeline
- get_symbol_details: type signature, definition location, usage count
- get_call_graph: callers, callees, transitive references
- get_blast_radius added to SYNTHESIS_TOOLS
2026-06-07 17:57:29 +00:00
214cc32ac2 feat(codecontext): upgrade sidecar to boocontext MCP aggregator
- Multi-stage Dockerfile builds boocontext (Node) + HTTP shim (Go)
- shim.go supports CODECONTEXT_CHILD env var for configurable MCP child
- Adds routes for get_symbol_details, get_call_graph, get_blast_radius
- docker-compose.yml adds env vars for child MCP paths
2026-06-07 17:57:24 +00:00
6b7c2bab1e feat(coder): persist token breakdown in arena decisions and schema 2026-06-07 17:57:19 +00:00
373ba86e5d feat(coder): add edit safety guards against truncation 2026-06-07 17:57:15 +00:00
9106334e70 feat(contracts): add TokenBreakdownSchema and ContestantShape.token_breakdown 2026-06-07 17:57:11 +00:00
cce685b1a7 fix(coder): harden edit-apply pipeline against block duplication
Root cause: two proven corruption mechanisms — (M1) non-idempotent apply
stamped the same block N times when a quantized model re-emitted the same
edit_file call or a turn was retried; (M2) Levenshtein tier 4 was fail-open
with no uniqueness guard, silently splicing into the wrong location.

Fixes applied at every layer of the pipeline:

Matcher (fuzzy-match.ts): raise SIMILARITY_THRESHOLD 0.66 → 0.85; add
AMBIGUITY_EPSILON uniqueness guard — two windows within 0.05 of the top
score → ambiguous, not a guess; add block-anchor gate (≥3-line needles
require first+last line exact match before a window is scored).

Edit planner (pending_changes.ts): extract planEdit() as a pure function;
idempotency guards detect already-applied states (anchored insert re-stamp,
old-gone-but-new-present); findPendingDuplicate() collapses identical
pending rows at queue time so M1 never reaches applyOne.

Atomic writes (pending_changes.ts): temp-file + rename on the same
filesystem so a crash can't leave a half-written source file; realpath()
first so symlinks survive the rename.

Per-file mutex (pending_changes.ts): withFileLock() serializes concurrent
read-modify-write on the same path via a chained-Promise Map.

EOL preservation (pending_changes.ts): normalize CRLF → LF for matching,
restore native line ending on write so Windows-style files stay clean.

Context isolation (inference_context.ts): replace module-level singleton
with AsyncLocalStorage so concurrent inference runs (arena parallel
dispatch, dispatcher poll racing a user message) each get their own
scoped context with no clobbering.

Tests: plan-edit.test.ts (pure planEdit unit tests), extended fuzzy-match
and pending_changes_integration suites, ALS isolation test that proves
overlapping runs get correct session IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 01:44:37 +00:00
dbf1662982 docs: add v2.7.20-arena-pane changelog entry 2026-06-06 23:34:58 +00:00
236 changed files with 10527 additions and 305 deletions

View File

@@ -2,6 +2,32 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.8.0-fork-lifts — 2026-06-07
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.
**boocontext sidecar (Phase 3):** Upgrades the `codecontext` container from the old Go MCP server to the boocontext Node.js MCP aggregator. Multi-stage Dockerfile builds boocontext from `/opt/forks/boocontext` alongside the HTTP shim. `shim.go` gains `CODECONTEXT_CHILD` env-var support and three new HTTP routes for symbols, callgraph, and blast radius. Three TypeScript tool wrappers (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered on the server, with blast radius added to the synthesis pipeline. Docker-compose env vars configure child MCP paths (tree-sitter-analyzer, type-inject).
**LSP integration (Phase 4):** Six-file `lsp/` module in the coder with config, JSON-RPC stdio client, lazy server-manager (per-project pool, 5-min idle shutdown), and operations (diagnostics, goto-definition, find-references). Three read-only agent tools registered — `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`. TypeScript/JavaScript only in v1.
**DCP clean-room (Phase 5):** Seven-file `dcp/` module in the server inference pipeline. Consecutive identical tool_call+tool_result pairs are deduplicated; failed/empty tool results are purged via configurable window. Orchestrated by `transformMessages()` running before `buildMessagesPayload` in `turn.ts`. Clean-room reimplementation — AGPL source was referenced for behavior only. 10 unit tests.
**Institutional memory (Phase 6):** Eight-file `memory/` module with file-based recall. Hierarchical 4-scope scan (global → home → project → session) under `.boocode/memory/`. Keyword/tag relevance matching at prompt assembly. Injected as a `<boocode-memory>` block in the system prompt. v1 recall-only — extract/dream deferred.
**Subagent protocol (Phase 7):** `AgentCapabilitiesSchema` in contracts with `supportsStreaming`, `supportsReasoningStream`, `supportsBackgroundExecution` flags. `ProviderSnapshotEntry` gains the two streaming capability fields. `new_task` tool gets a `background` mode flag for non-blocking dispatch. Flow-runner already supported per-step model override.
**Plugin host (Phase 8):** Typed hook registry in `plugins/host.ts` with `registerHook`/`emitHook` for five lifecycle events: `tool.execute.before`, `tool.execute.after`, `turn.start`, `turn.end`, `task.terminal`. Patterns-only from oh-my-openagent (SUL — no code copy).
**Inference reliability (Phase 9):** `tool-shim.ts` recovers XML/JSON tool calls from plain-text model output (e.g. Qwen inline format). `loop-detectors.ts` catches content-repeat and tool-loop patterns. Existing doom-loop detection remains — detectors are additive.
**Edit safety guards (Wave 1):** `edit-guards.ts` rejects catastrophic truncation (>60% chars AND >50% lines). `edit-guards-imports.ts` detects dropped import statements. Both run in `pending_changes.ts` immediately before `writeFileAtomic`.
**TokenScope (Wave 2):** `TokenBreakdownSchema` in contracts with system/user/assistant/tools/reasoning categories. `token-analysis/` module with analyzer and DB persistence. `ContestantShape.token_breakdown` field and `token_breakdown` JSONB column on `contestants`/`tasks` tables. Arena `computeBenchmark` accepts and returns token breakdown.
**Build:** Server 649 ✅ Coder 471 ✅ Contracts ✅ — all green.
Adds the **Arena** pane for running the same prompt against 26 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
## v2.7.18-permission-modes — 2026-06-05
Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`.

View File

@@ -1,9 +1,9 @@
# Current focus
Last updated: 2026-06-05
Last updated: 2026-06-07
- **Last shipped:** `v2.7.18-permission-modes` (2026-06-05) — unified Plan/Ask/Bypass permission picker in the BooCoder composer (incl. native-BooCode auto-apply on Bypass).
- **Last shipped:** `v2.8.0-fork-lifts` (2026-06-07) — eight fork-lift integrations from `/opt/forks`: boocontext sidecar, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards and TokenScope analyzer/persist module.
- **Branch:** `main`
- **In progress:** nothing committed — dogfooding the Orchestrator to surface the next real backlog. Claude Agent-SDK backend enabled (`CLAUDE_SDK_BACKEND`). Optional/exploratory: verify-gate ensembler over pending changes.
- **In progress:** nothing committed — all phases 3-9 of fork-lifts-mit epic are shipped. Optional/exploratory: verify-gate ensembler over pending changes; web Arena token UI display.
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.

View File

@@ -37,3 +37,10 @@
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
## Edit safety guards (v2.8)
- **`services/edit-guards.ts`** — `validateEditResult(original, updated, filePath)` runs in `pending_changes.ts` immediately before `writeFileAtomic`. Rejects catastrophic truncation (>60% char loss AND >50% line loss). Throws a `formatGuardError` message that percolates to the agent as a visible error.
- **`services/edit-guards-imports.ts`** — `checkDroppedImports(original, updated, filePath)` detects removed import/require lines. Called alongside the truncation guard.
- Both guards run on the `/apply` path only (not on queue). Re-queued identical edits re-validate at apply time.
- Guard functions are pure — no DB or filesystem access. Easy to unit-test.

View File

@@ -24,6 +24,7 @@ import {
} from './planning.js';
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
import { codeReview } from './code-review.js';
import { parallelResearch } from './parallel-research.js';
const spines: Spine[] = [
// analysis / research
@@ -53,7 +54,7 @@ const spines: Spine[] = [
stakeholderSummary,
];
const bespoke: Flow[] = [codeReview];
const bespoke: Flow[] = [codeReview, parallelResearch];
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];

View File

@@ -0,0 +1,59 @@
import type { Flow, Step, StepContext } from '../types.js';
const q = (ctx: StepContext) => String(ctx.input.question);
/**
* Parallel research flow — dispatches 3 research agents simultaneously,
* then synthesizes the result on the first one to complete.
*/
export const parallelResearch: Flow = {
name: 'parallel-research',
description: 'Research from 3 angles in parallel, synthesize results on first completion',
steps: [
{
id: 'angle-web',
kind: 'agent',
agent: 'research-analyst',
run: (ctx) =>
`Research the following question from a web / prior-art perspective:\n\n${q(ctx)}`,
},
{
id: 'angle-code',
kind: 'agent',
agent: 'codebase-explorer',
deps: [],
run: (ctx) =>
`Research the following question from a codebase analysis perspective:\n\n${q(ctx)}`,
},
{
id: 'angle-security',
kind: 'agent',
agent: 'adversarial-security-analyst',
deps: [],
run: (ctx) =>
`Research the following question from a security perspective:\n\n${q(ctx)}`,
},
{
id: 'synthesize',
kind: 'code',
deps: ['angle-web', 'angle-code', 'angle-security'],
trigger_rule: 'one_success',
run: (ctx) => {
const web = ctx.results['angle-web'];
const code = ctx.results['angle-code'];
const security = ctx.results['angle-security'];
const parts = [
'# Parallel Research Synthesis',
'',
web ? `## Web Angle\n${web}` : '## Web Angle\n*(not yet completed)*',
code ? `## Code Angle\n${code}` : '## Code Angle\n*(not yet completed)*',
security ? `## Security Angle\n${security}` : '## Security Angle\n*(not yet completed)*',
];
return parts.join('\n\n');
},
},
],
render: (ctx) => {
return ctx.results['synthesize'] ?? 'No synthesis produced.';
},
};

View File

@@ -38,7 +38,9 @@ export interface StepContext {
readonly model?: string;
}
export type StepKind = 'agent' | 'code';
export type StepKind = 'agent' | 'code' | 'approval';
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
export interface Step {
/** unique id within the flow; other steps depend on it by this id */
@@ -46,6 +48,8 @@ export interface Step {
kind: StepKind;
/** ids that must complete (or skip) before this step runs */
deps?: string[];
/** how dependency satisfaction is evaluated (default: all_success) */
trigger_rule?: TriggerRule;
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */
agent?: string;
/**

View File

@@ -13,7 +13,7 @@ import type { WsFrame } from '@boocode/contracts/ws-frames';
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
import { WRITE_TOOLS } from './services/tools/index.js';
import { adaptWriteTool } from './services/tools/adapter.js';
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
import { runWithInferenceContext } from './services/tools/inference_context.js';
// Routes
import { registerMessageRoutes } from './routes/messages.js';
import { registerSkillRoutes } from './routes/skills.js';
@@ -174,22 +174,27 @@ async function main() {
}
);
// Wrap the inference runner to set/clear the write-tool context around each run.
// The inference runner calls enqueue() which fires asynchronously — we hook
// into the enqueue to set context before the run starts.
// Wrap the inference runner to bind the write-tool context around each run.
// enqueue() starts its async loop synchronously, so wrapping the call in
// runWithInferenceContext propagates the per-run context (sql, sessionId, the
// Plan/Ask/Bypass gate) through every awaited tool execution — and concurrent
// runs (a user message racing a dispatcher-polled native task) each get their
// own, instead of clobbering a shared global.
const inferenceApi = {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
// Set the inference context so write tools can access sql + sessionId.
// The context persists for the duration of the inference run. Since
// BooCoder is single-user and runs one inference at a time per session,
// this module-level state is safe.
setInferenceContext({ sql, sessionId, taskId: null });
inference.enqueue(sessionId, chatId, assistantId, user);
enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => {
runWithInferenceContext({ sql, sessionId, taskId: null, permissionMode }, () => {
inference.enqueue(sessionId, chatId, assistantId, user);
});
},
cancel: async (sessionId: string, chatId: string) => {
const result = await inference.cancel(sessionId, chatId);
clearInferenceContext();
return result;
// No context to clear — AsyncLocalStorage scopes it to each run's own chain.
return inference.cancel(sessionId, chatId);
},
hasActive: (chatId: string) => inference.hasActive(chatId),
};

View File

@@ -0,0 +1,42 @@
export type HookName =
| 'tool.execute.before'
| 'tool.execute.after'
| 'turn.start'
| 'turn.end'
| 'task.terminal';
export interface ToolHookContext {
tool: string;
args: Record<string, unknown>;
projectRoot: string;
sessionId: string;
}
export interface ToolResultContext extends ToolHookContext {
result: unknown;
}
export type PluginHook = (ctx: any) => Promise<any>;
const hooks = new Map<HookName, PluginHook[]>();
export function registerHook(name: HookName, fn: PluginHook): void {
const list = hooks.get(name) || [];
list.push(fn);
hooks.set(name, list);
}
export async function emitHook(name: HookName, ctx: any): Promise<any> {
const list = hooks.get(name);
if (!list) return ctx;
let current = ctx;
for (const fn of list) {
const result = await fn(current);
if (result !== undefined) current = result;
}
return current;
}
export function clearHooks(): void {
hooks.clear();
}

View File

@@ -0,0 +1,78 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions.
// v1 — global view only (no per-project or per-user filtering).
export interface AnalyticsSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
session_count: number;
}
export interface SessionAnalyticsRow {
session_id: string;
session_name: string;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
last_active_at: string | null;
}
export interface TokenBreakdownAgg {
category: string;
total_tokens: number;
}
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/analytics/summary — aggregate totals across all agent_sessions.
app.get('/api/analytics/summary', async () => {
const [row] = await sql<AnalyticsSummary[]>`
SELECT
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
COUNT(DISTINCT c.session_id)::INT AS session_count
FROM agent_sessions a
JOIN chats c ON c.id = a.chat_id
`;
return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 };
});
// GET /api/analytics/sessions — per-session token/cost breakdown.
app.get('/api/analytics/sessions', async () => {
const rows = await sql<SessionAnalyticsRow[]>`
SELECT
c.session_id AS session_id,
s.name AS session_name,
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
MAX(a.last_active_at) AS last_active_at
FROM agent_sessions a
JOIN chats c ON c.id = a.chat_id
JOIN sessions s ON s.id = c.session_id
GROUP BY c.session_id, s.name
ORDER BY MAX(a.last_active_at) DESC NULLS LAST
`;
return { sessions: rows };
});
// GET /api/analytics/token-breakdown — aggregate token_breakdown categories
// across all tasks that carry the JSONB field.
app.get('/api/analytics/token-breakdown', async () => {
const rows = await sql<{ category: string; total_tokens: number }[]>`
SELECT
key AS category,
SUM((value->>0)::BIGINT)::BIGINT AS total_tokens
FROM tasks,
LATERAL jsonb_each(token_breakdown)
WHERE token_breakdown IS NOT NULL
AND jsonb_typeof(token_breakdown) = 'object'
GROUP BY key
ORDER BY total_tokens DESC
`;
return { categories: rows };
});
}

View File

@@ -205,7 +205,7 @@ export function registerArenaRoutes(
const contestants = await sql`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
status, duration_ms, tokens_per_sec, cost_tokens, result_path, error,
status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
created_at, updated_at
FROM contestants
WHERE battle_id = ${id}

View File

@@ -4,7 +4,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import { resolveChatId } from './chat-resolve.js';
import { applyAll } from '../services/pending_changes.js';
import { asPermissionMode } from '../services/tools/types.js';
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
@@ -44,7 +44,13 @@ const SendBody = z.object({
});
interface InferenceApi {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean;
}
@@ -246,36 +252,16 @@ export function registerMessageRoutes(
RETURNING id
`;
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
// Bypass permission mode (native BooCode): auto-apply staged edits to disk
// once the turn settles. `enqueue` registers synchronously, so hasActive is
// true immediately; poll until it clears, apply, then re-publish
// message_complete so the DiffPanel reflects the now-applied (non-pending)
// state. Best-effort — failures stay in the pending queue for manual apply.
if (mode_id === 'bypass') {
const projectId = sessionRows[0]!.project_id;
const assistantId = assistantMsg!.id;
void (async () => {
try {
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${projectId}`;
if (!proj?.path) return;
for (let i = 0; i < 1200 && inference.hasActive(chatId); i++) {
await new Promise((r) => setTimeout(r, 1000));
}
const applied = await applyAll(sql, sessionId, proj.path);
if (applied.length > 0) {
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as unknown as WsFrame);
}
} catch {
/* best-effort auto-apply — leave staged changes for manual apply */
}
})();
}
// Native BooCode permission gate (plan/ask/bypass) — threaded into the
// write-tool context so create/edit/delete and apply_pending honor it.
// Plan = read-only, Ask = stage to the queue (agent can't self-apply),
// Bypass = apply each write immediately. Other mode ids (e.g. an external
// fallback's native mode) leave the gate undefined = legacy behavior.
req.log.info(
{ provider, mode_id, permissionMode: asPermissionMode(mode_id), chatId },
'native enqueue — permission gate',
);
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default', asPermissionMode(mode_id));
reply.code(202);
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };

View File

@@ -423,3 +423,18 @@ CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
-- Cross-examination listing per battle.
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
-- TokenScope: per-category token breakdown on arena contestants and tasks.
ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
-- Orchestrator flow step events (append-only event log for resume/replay).
CREATE TABLE IF NOT EXISTS flow_step_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES flow_runs(id),
step_id VARCHAR(64) NOT NULL,
event VARCHAR(32) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);

View File

@@ -162,6 +162,24 @@ describe('computeBenchmark', () => {
expect(bench.durationMs).toBe(0);
expect(bench.tokensPerSec).toBeNull();
});
it('includes token breakdown when provided', () => {
const breakdown = {
system: 10,
user: 20,
assistant: 30,
tools: 40,
reasoning: 5,
total: 105,
};
const bench = computeBenchmark(t0, t1, 500, 'local', breakdown);
expect(bench.tokenBreakdown).toEqual(breakdown);
});
it('defaults token breakdown to null when omitted', () => {
const bench = computeBenchmark(t0, t1, 500, 'local');
expect(bench.tokenBreakdown).toBeNull();
});
});
// ─── sanitizeSlug ────────────────────────────────────────────────────────────

View File

@@ -161,6 +161,52 @@ describe('locateMatch — strategy 4: Levenshtein', () => {
});
});
describe('locateMatch — strategy 4: fail-closed on ambiguity (corruption guard)', () => {
it('refuses (ambiguous) when two equally-similar anchored blocks both clear the bar', () => {
// The repetitive-file case that duplicated blocks: two blocks share the same
// first+last anchor lines and their middle lines are EQUALLY similar to the
// (drifted) needle. Tier 4 must refuse rather than splice over one of them.
const content = [
'const x = {',
' total = aa;',
'};',
'const x = {',
' total = bb;',
'};',
].join('\n');
const needle = ['const x = {', ' total = ab;', '};'].join('\n');
const result = locateMatch(content, needle);
expect(result.kind).toBe('ambiguous');
});
it('refuses a below-threshold near-miss that the old 0.66 floor would have spliced', () => {
// ~0.7 similar: under the raised 0.85 floor this is now not_found, so the
// caller surfaces a correctable error instead of corrupting the file.
const content = 'const grandTotalAmount = a + b;\n';
const needle = 'const totalValue = a + b;';
const result = locateMatch(content, needle);
expect(result).toEqual({ kind: 'not_found' });
});
it('still matches a single genuine high-similarity drift uniquely', () => {
const content = 'const total = sum + tax;\n';
const needle = 'const totals = sum + tax;'; // one-char typo, ~0.96
const result = locateMatch(content, needle);
expect(result.kind).toBe('fuzzy');
const { start, end } = span(result);
expect(content.slice(start, end)).toBe('const total = sum + tax;');
});
it('requires an exact first+last line anchor for multi-line needles', () => {
// First line drifted too far to anchor → no window is scored → not_found,
// even though the middle lines are identical.
const content = ['function compute() {', ' return a + b;', ' return done;', '}'].join('\n');
const needle = ['totally different opener', ' return a + b;', '}'].join('\n');
const result = locateMatch(content, needle);
expect(result).toEqual({ kind: 'not_found' });
});
});
describe('locateMatch — edge cases', () => {
it('returns not_found for an empty needle', () => {
expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' });

View File

@@ -83,6 +83,53 @@ describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () =>
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
});
it('re-emitted identical edits dedupe at queue and never duplicate on apply', async () => {
// Regression: the 2-3x block-stamping corruption. An anchored insert queued
// three times (a local model re-emitting the same tool call) must collapse to
// ONE pending row and apply exactly once.
await queueCreate(sql, testSessionId, null, 'dup.js', '<script>\nrender();\n', projectRoot)
.then((c) => applyOne(sql, c.id, projectRoot));
const oldStr = '<script>';
const newStr = '<script>\nconst recordFormats = ["gif"];';
const a = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const b = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const c = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
// All three calls return the SAME pending row (deduped).
expect(b.id).toBe(a.id);
expect(c.id).toBe(a.id);
await applyOne(sql, a.id, projectRoot);
let content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
// Even a fresh, separately-queued identical edit re-applied is a no-op, not a stamp.
const again = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
const res = await applyOne(sql, again.id, projectRoot);
expect(res.success).toBe(true);
content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
});
it('preserves CRLF line endings on edit', async () => {
await queueCreate(sql, testSessionId, null, 'crlf.txt', 'line one\r\nline two\r\nline three\r\n', projectRoot)
.then((c) => applyOne(sql, c.id, projectRoot));
const edit = await queueEdit(sql, testSessionId, null, 'crlf.txt', 'line two', 'line TWO', projectRoot);
const res = await applyOne(sql, edit.id, projectRoot);
expect(res.success).toBe(true);
const content = await readFile(resolve(testDir, 'crlf.txt'), 'utf8');
expect(content).toBe('line one\r\nline TWO\r\nline three\r\n');
});
it('refuses an edit that matches multiple locations instead of corrupting', async () => {
await queueCreate(sql, testSessionId, null, 'ambig.js', 'x=1;\ny=2;\nx=1;\n', projectRoot)
.then((ch) => applyOne(sql, ch.id, projectRoot));
const edit = await queueEdit(sql, testSessionId, null, 'ambig.js', 'x=1;', 'x=9;', projectRoot);
const res = await applyOne(sql, edit.id, projectRoot);
expect(res.success).toBe(false);
expect(res.error).toMatch(/matches 2 locations/);
});
it('rewindOne → verify reverted', async () => {
// Setup: create and apply a file
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { planEdit } from '../pending_changes.js';
// planEdit is the pure core of applyOne's edit splice. These tests pin the
// idempotency guards that stop the "block stamped 2-3x" corruption: applying the
// same queued edit more than once must be a no-op, never a duplicate.
describe('planEdit — normal application', () => {
it('applies a unique exact edit', () => {
const content = 'a\nfoo\nb\n';
const plan = planEdit(content, 'foo', 'bar');
expect(plan).toEqual({ kind: 'apply', updated: 'a\nbar\nb\n' });
});
it('reports ambiguous when old_string occurs more than once', () => {
const content = 'foo\nx\nfoo\n';
const plan = planEdit(content, 'foo', 'bar');
expect(plan).toEqual({ kind: 'ambiguous', count: 2 });
});
it('reports not_found when old_string is absent and new is not present', () => {
const content = 'alpha\nbeta\n';
const plan = planEdit(content, 'gamma that is clearly nowhere', 'delta');
expect(plan).toEqual({ kind: 'not_found' });
});
});
describe('planEdit — idempotency (the corruption guard)', () => {
it('treats a re-applied anchored insert as already-applied (no duplicate)', () => {
// The exact mechanism that tripled `const recordFormats` in settings.html:
// an anchored insert (old=anchor, new=anchor+block) where the anchor still
// matches uniquely after the first apply.
const oldStr = '<script>';
const newStr = '<script>\nconst recordFormats = ["gif","mp4"];';
const before = '<script>\nfunction render() {}\n</script>\n';
const first = planEdit(before, oldStr, newStr);
expect(first.kind).toBe('apply');
const after = first.kind === 'apply' ? first.updated : '';
expect((after.match(/const recordFormats/g) || []).length).toBe(1);
// Re-applying the identical edit to the already-edited content is a no-op.
const second = planEdit(after, oldStr, newStr);
expect(second).toEqual({ kind: 'noop', reason: 'already-applied' });
});
it('treats an edit whose old_string is gone but new_string is present as already-applied', () => {
const content = 'const total = sum + tax;\n';
const plan = planEdit(content, 'const subtotal = sum;', 'const total = sum + tax;');
expect(plan).toEqual({ kind: 'noop', reason: 'already-applied' });
});
it('treats a no-change splice as a noop', () => {
const content = 'a\nfoo\nb\n';
const plan = planEdit(content, 'foo', 'foo');
expect(plan).toEqual({ kind: 'noop', reason: 'identical' });
});
it('does not duplicate across three repeated applications', () => {
const oldStr = 'function f() {';
const newStr = 'function f() {\n const x = 1;';
let content = 'function f() {\n return x;\n}\n';
for (let i = 0; i < 3; i++) {
const plan = planEdit(content, oldStr, newStr);
if (plan.kind === 'apply') content = plan.updated;
}
expect((content.match(/const x = 1;/g) || []).length).toBe(1);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { evaluateTriggerRule } from '../flow-runner-decisions.js';
describe('evaluateTriggerRule', () => {
it('all_success requires all deps done', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a', 'b']), new Set(), new Set())).toBe(true);
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set())).toBe(false);
});
it('one_success fires on first completion', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(), 'one_success')).toBe(true);
expect(evaluateTriggerRule(['a', 'b'], new Set(), new Set(), new Set(), 'one_success')).toBe(false);
});
it('all_done includes skipped deps', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(['b']), new Set(), 'all_done')).toBe(true);
});
it('all_success treats excluded deps as satisfied', () => {
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(['b']))).toBe(true);
});
it('defaults to all_success', () => {
expect(evaluateTriggerRule(['a'], new Set(['a']), new Set(), new Set())).toBe(true);
expect(evaluateTriggerRule(['a'], new Set(), new Set(), new Set())).toBe(false);
});
it('returns true for empty deps', () => {
expect(evaluateTriggerRule([], new Set(), new Set(), new Set())).toBe(true);
});
});

View File

@@ -9,7 +9,7 @@
* A contestant's status lifecycle:
* queued → running → done | error
*/
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
// ─── Lane classification ──────────────────────────────────────────────────────
@@ -73,6 +73,7 @@ export function isBattleComplete(contestants: readonly { status: string }[]): bo
export interface Benchmark {
durationMs: number;
tokensPerSec: number | null;
tokenBreakdown: TokenBreakdown | null;
}
/**
@@ -86,13 +87,14 @@ export function computeBenchmark(
endedAt: Date,
costTokens: number | null,
lane: ContestantLane,
tokenBreakdown: TokenBreakdown | null = null,
): Benchmark {
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
const tokensPerSec =
lane === 'local' && costTokens !== null && durationMs > 0
? (costTokens / durationMs) * 1000
: null;
return { durationMs, tokensPerSec };
return { durationMs, tokensPerSec, tokenBreakdown };
}
// ─── Slug / path helpers ──────────────────────────────────────────────────────

View File

@@ -4,7 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { applyAll } from './pending_changes.js';
import { asPermissionMode } from './tools/types.js';
import { createCheckpoint } from './checkpoints.js';
import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js';
@@ -32,7 +32,13 @@ import {
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
enqueue: (
sessionId: string,
chatId: string,
assistantId: string,
user: string,
permissionMode?: 'plan' | 'ask' | 'bypass',
) => void;
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
hasActive: (chatId: string) => boolean;
}
@@ -358,8 +364,9 @@ export function createDispatcher(deps: Deps): {
`;
const assistantId = assistantMsg!.id;
// Enqueue inference
inference.enqueue(sessionId, chatId, assistantId, 'default');
// Enqueue inference — pass the native permission gate (plan/ask/bypass)
// through to the write-tool context. Non-unified mode ids → undefined.
inference.enqueue(sessionId, chatId, assistantId, 'default', asPermissionMode(task.mode_id));
// Wait for inference to complete (poll message status)
const finalStatus = await waitForCompletion(assistantId);
@@ -392,22 +399,6 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId}
`;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
// Bypass permission mode: auto-apply the staged edits to disk after the
// turn. Ask/Plan leave them in the pending-changes queue for review.
if (task.mode_id === 'bypass') {
try {
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${task.project_id}`;
if (proj?.path) {
const applied = await applyAll(sql, sessionId, proj.path);
log.info({ taskId, applied: applied.length }, 'dispatcher: native bypass auto-applied pending changes');
}
} catch (applyErr) {
log.warn(
{ taskId, err: applyErr instanceof Error ? applyErr.message : String(applyErr) },
'dispatcher: native bypass auto-apply failed',
);
}
}
} else {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}

View File

@@ -0,0 +1,47 @@
// edit-guards-imports — detects dropped imports in edited files.
// Ported from opencode-morph-fast-apply (MIT).
export interface ImportCheckResult {
ok: boolean;
missingImports: string[];
reason?: string;
}
const IMPORT_PATTERNS = [
/^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]\s*;?$/m,
/^import\s+['"][^'"]+['"]\s*;?$/m,
/^export\s+.*\s+from\s+['"][^'"]+['"]\s*;?$/m,
/^require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?$/m,
/^import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?$/m,
];
function extractImportLines(content: string): string[] {
return content.split('\n').filter((line) =>
IMPORT_PATTERNS.some((p) => p.test(line.trim())),
);
}
export function checkDroppedImports(
original: string,
updated: string,
filePath: string,
): ImportCheckResult {
const originalImports = extractImportLines(original);
const updatedImports = extractImportLines(updated);
if (originalImports.length === 0) {
return { ok: true, missingImports: [] };
}
const missing = originalImports.filter((imp) => !updatedImports.includes(imp));
if (missing.length > 0 && originalImports.length > 0) {
return {
ok: false,
missingImports: missing,
reason: `Edit would drop ${missing.length} import(s) from ${filePath}`,
};
}
return { ok: true, missingImports: [] };
}

View File

@@ -0,0 +1,42 @@
// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage,
// and accidental import deletion during native edit_file application.
// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved.
export interface GuardResult {
ok: boolean;
reason?: string;
charLoss?: number;
lineLoss?: number;
}
const TRUNCATION_CHAR_THRESHOLD = 0.6;
const TRUNCATION_LINE_THRESHOLD = 0.5;
export function validateEditResult(
original: string,
updated: string,
filePath: string,
): GuardResult {
// Check for catastrophic content truncation
if (original.length > 0 && updated.length > 0) {
const charLoss = 1 - updated.length / original.length;
const originalLines = original.split('\n').length;
const updatedLines = updated.split('\n').length;
const lineLoss = 1 - updatedLines / originalLines;
if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) {
return {
ok: false,
reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`,
charLoss,
lineLoss,
};
}
}
return { ok: true };
}
export function formatGuardError(guard: GuardResult, filePath: string): string {
return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`;
}

View File

@@ -0,0 +1,23 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
const ARTIFACTS_ROOT = 'data/flow-artifacts';
export function getArtifactPath(flowRunId: string, stepId: string): string {
return join(ARTIFACTS_ROOT, flowRunId, `${stepId}.md`);
}
export async function writeFlowArtifact(
flowRunId: string,
stepId: string,
content: string,
): Promise<string> {
const dir = join(ARTIFACTS_ROOT, flowRunId);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const path = getArtifactPath(flowRunId, stepId);
await writeFile(path, content, 'utf8');
return path;
}

View File

@@ -22,7 +22,7 @@
* "Settled" = done skipped excluded. Only settled deps unblock a step;
* an inFlight dep does NOT (the runner waits for its terminal callback).
*/
import type { Flow, Step, StepContext } from '../conductor/types.js';
import type { Flow, Step, StepContext, TriggerRule } from '../conductor/types.js';
export interface SchedulerState {
/** step ids that completed successfully (results available) */
@@ -62,7 +62,7 @@ export function readySteps(flow: Flow, state: SchedulerState): Step[] {
!state.skipped.has(s.id) &&
!state.inFlight.has(s.id) &&
!state.excluded.has(s.id) &&
(s.deps ?? []).every((d) => isSatisfied(state, d)),
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
);
}
@@ -167,6 +167,32 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
return agent === 'qwen' && modeId === 'plan';
}
/**
* Evaluate a trigger rule against dependency results.
* - all_success: every dep must be done (not skipped/failed)
* - one_success: at least one dep must be done
* - all_done: every dep must be settled regardless of outcome
*/
export function evaluateTriggerRule(
deps: string[],
done: ReadonlySet<string>,
skipped: ReadonlySet<string>,
excluded: ReadonlySet<string>,
rule?: TriggerRule,
): boolean {
if (deps.length === 0) return true;
const satisfied = new Set([...done, ...skipped, ...excluded]);
switch (rule ?? 'all_success') {
case 'all_success':
return deps.every((d) => done.has(d) || skipped.has(d) || excluded.has(d));
case 'one_success':
return deps.some((d) => done.has(d));
case 'all_done':
return deps.every((d) => satisfied.has(d));
}
}
/**
* Reconcile every step of an in-flight run for startup resume. Returns one
* decision per step. Pure — no IO.

View File

@@ -346,6 +346,20 @@ export function createFlowRunner(deps: Deps): FlowRunner {
continue; // re-evaluate — code output can unblock the next wave
}
// Approval gate steps: pause and wait for human decision.
const approvalReady = toRun.filter((s) => s.kind === 'approval');
if (approvalReady.length > 0) {
for (const s of approvalReady) {
await sql`
UPDATE flow_steps SET status = 'blocked', updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${s.id}
`;
await appendStepEvent(sql, runId, s.id, 'paused', { reason: 'awaiting approval' });
publishStep(runId, s.id, 'blocked');
}
return;
}
// Only agent steps remain ready → dispatch the whole parallel wave, then wait.
for (const s of toRun) {
await dispatchAgentStep(runId, run.project_id, model, s, ctx);
@@ -378,7 +392,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
// flow's step.run already bakes in the evidence/YAGNI contracts.
const persona = step.agent ? await loadPersona(step.agent) : '';
const taskPrompt = await step.run(ctx);
const fullPrompt = persona ? `${persona}\n\n---\n\n${taskPrompt}` : taskPrompt;
const resolvedPrompt = resolveVariables(taskPrompt, ctx.results);
const fullPrompt = persona ? `${persona}\n\n---\n\n${resolvedPrompt}` : resolvedPrompt;
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
@@ -392,6 +407,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.id}
`;
await appendStepEvent(sql, runId, step.id, 'started', { taskId: task!.id });
}
/**
@@ -438,6 +454,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
WHERE run_id = ${runId} AND step_id = ${stepId}
`;
}
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
}
// ─── run completion ─────────────────────────────────────────────────────────
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
if (updated.count === 0) return;
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error });
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
}
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep(
runId: string,
stepId: string,
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled',
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void {
publishUser({
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// ─── Event log ───────────────────────────────────────────────────────────────
async function appendStepEvent(
sql: Sql,
runId: string,
stepId: string,
event: string,
payload?: Record<string, unknown>,
): Promise<void> {
await sql`
INSERT INTO flow_step_events (run_id, step_id, event, payload)
VALUES (${runId}, ${stepId}, ${event}, ${payload ? sql.json(payload as never) : null})
`;
}
// ─── Variable substitution ───────────────────────────────────────────────────
const VAR_PATTERN = /\$(\w+)\.output(?:\.(\w+(?:\.\w+)*))?/g;
export function resolveVariables(prompt: string, results: Record<string, string>): string {
return prompt.replace(VAR_PATTERN, (match, stepId, fieldPath) => {
const output = results[stepId];
if (!output) return match;
if (!fieldPath) return output;
try {
const lines = output.split('\n');
for (const line of lines) {
const parsed = line.match(new RegExp(`^${fieldPath}:\\s*(.+)$`, 'i'));
if (parsed) return parsed[1]!.trim();
}
} catch {
// fall through
}
return match;
});
}

View File

@@ -21,7 +21,16 @@
// punctuation to ASCII on both sides; the match is
// mapped back to original offsets.
// 4. levenshtein — best line-window by normalized edit-distance
// similarity; accepted only at >= SIMILARITY_THRESHOLD.
// similarity; accepted only at >= SIMILARITY_THRESHOLD,
// anchored on an exact first+last line for multi-line
// needles, and REFUSED (ambiguous) when a second window
// scores within AMBIGUITY_EPSILON of the best. Like the
// exact/whitespace tiers, this tier fails CLOSED — it
// never splices over a merely-plausible guess, because a
// wrong-window splice corrupts the file (it leaves the
// real target intact and duplicates it). This mirrors
// opencode/cline/qwen, whose fuzzy tiers all keep the
// unique-match requirement rather than picking a winner.
//
// Pure and dependency-free (Levenshtein is the standard iterative two-row DP),
// reimplemented from the general technique — no vendored source.
@@ -31,8 +40,31 @@ export type MatchResult =
| { kind: 'ambiguous'; count: number }
| { kind: 'not_found' };
/** Levenshtein similarity floor for the final fuzzy fallback (strategy 4). */
export const SIMILARITY_THRESHOLD = 0.66;
/**
* Levenshtein similarity floor for the final fuzzy fallback (strategy 4).
* 0.66 was far too low — at two-thirds similarity a structurally-wrong window
* (e.g. one of three near-identical form blocks) clears the bar and gets spliced
* over, leaving the real target intact and duplicated. Competent agents anchor
* far tighter (opencode's BlockAnchor needs an exact anchor; cline needs exact
* first+last lines). 0.85 keeps genuine quantized-model drift (a typo, an indent
* shift) while refusing a different block.
*/
export const SIMILARITY_THRESHOLD = 0.85;
/**
* If a second candidate window scores within this of the best, the match is
* ambiguous and tier 4 refuses rather than guessing — the same fail-closed
* stance the exact and whitespace tiers take on multiple hits. Repetitive files
* (the duplicate-block corruption case) produce near-tied windows; this is what
* turns that into a clean "add more context" error instead of a wrong splice.
*/
export const AMBIGUITY_EPSILON = 0.05;
/** Multi-line needles at or above this length must anchor on an exact (after
* trim + unicode-fold) first AND last line before similarity is even scored —
* the cline/opencode block-anchor rule. Below it, threshold + uniqueness alone
* guard the match. */
const ANCHOR_MIN_LINES = 3;
export function locateMatch(content: string, needle: string): MatchResult {
// Empty needle has no meaningful match.
@@ -252,20 +284,39 @@ function locateByLevenshtein(content: string, needle: string): MatchResult | nul
const needleJoined = needleLines.map((l) => l.trim()).join('\n');
let best = -1;
let bestSpan: { start: number; end: number } | null = null;
// Block-anchor gate for multi-line needles: the first and last lines must match
// exactly (after trim + unicode-fold) or the window is not even scored. This
// stops a high interior-similarity from dragging a structurally-wrong window
// over the threshold — the failure that duplicates blocks in repetitive files.
const anchored = n >= ANCHOR_MIN_LINES;
const needleFirst = canonicalize(needleLines[0]!.trim());
const needleLast = canonicalize(needleLines[n - 1]!.trim());
const scored: Array<{ score: number; start: number; end: number }> = [];
for (let i = 0; i + n <= contentLines.length; i++) {
const window = contentLines.slice(i, i + n);
const windowJoined = window.map((l) => l.text.trim()).join('\n');
const score = similarity(windowJoined, needleJoined);
if (score > best) {
best = score;
bestSpan = { start: window[0]!.start, end: window[n - 1]!.end };
if (anchored) {
const winFirst = canonicalize(window[0]!.text.trim());
const winLast = canonicalize(window[n - 1]!.text.trim());
if (winFirst !== needleFirst || winLast !== needleLast) continue;
}
const windowJoined = window.map((l) => l.text.trim()).join('\n');
scored.push({
score: similarity(windowJoined, needleJoined),
start: window[0]!.start,
end: window[n - 1]!.end,
});
}
if (bestSpan && best >= SIMILARITY_THRESHOLD) {
return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end };
}
return null;
if (scored.length === 0) return null;
scored.sort((a, b) => b.score - a.score);
const best = scored[0]!;
if (best.score < SIMILARITY_THRESHOLD) return null;
// Uniqueness guard: refuse when a second window is within epsilon of the best.
// Fail closed (ambiguous) rather than silently splicing one of several lookalikes.
const tied = scored.filter((s) => s.score >= best.score - AMBIGUITY_EPSILON);
if (tied.length > 1) return { kind: 'ambiguous', count: tied.length };
return { kind: 'fuzzy', start: best.start, end: best.end };
}

View File

@@ -0,0 +1,75 @@
import { createInterface } from 'node:readline';
import type { Readable, Writable } from 'node:stream';
interface RpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params?: unknown;
}
interface RpcResponse {
jsonrpc: '2.0';
id: number;
result?: unknown;
error?: { code: number; message: string };
}
export class LspClient {
private nextId = 1;
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
private buffer = '';
constructor(
private stdin: Writable,
private stdout: Readable,
) {
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
rl.on('line', (line) => this.handleLine(line));
}
private handleLine(line: string): void {
this.buffer += line + '\n';
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
if (!match || !match[1]) return;
const len = parseInt(match[1], 10);
const headerEnd = match.index! + match[0].length;
const body = this.buffer.slice(headerEnd, headerEnd + len);
if (body.length < len) return;
this.buffer = this.buffer.slice(headerEnd + len);
try {
const msg: RpcResponse = JSON.parse(body);
const cb = this.pending.get(msg.id);
if (cb) {
this.pending.delete(msg.id);
cb.resolve(msg);
}
} catch {
// Malformed JSON, ignore
}
}
async request(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
const body = JSON.stringify(req);
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
return new Promise((resolve, reject) => {
this.pending.set(id, {
resolve: (resp) => {
if (resp.error) reject(new Error(resp.error.message));
else resolve(resp.result);
},
reject,
});
this.stdin.write(header + body);
});
}
async notify(method: string, params?: unknown): Promise<void> {
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
this.stdin.write(header + body);
}
}

View File

@@ -0,0 +1,19 @@
export interface LspServerConfig {
command: string;
args: string[];
rootPatterns: string[];
}
const TS_CONFIG: LspServerConfig = {
command: 'typescript-language-server',
args: ['--stdio'],
rootPatterns: ['package.json', 'tsconfig.json'],
};
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
export function getServerConfig(filePath: string): LspServerConfig | null {
const ext = filePath.split('.').pop()?.toLowerCase();
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
return null;
}

View File

@@ -0,0 +1,86 @@
import type { LspClient } from './client.js';
import type { Diagnostic, Location } from './types.js';
function fileUri(filePath: string): string {
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
}
export async function openDocument(
client: LspClient,
filePath: string,
content: string,
version: number = 1,
): Promise<void> {
const uri = fileUri(filePath);
await client.notify('textDocument/didOpen', {
textDocument: { uri, languageId: 'typescript', version, text: content },
});
}
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
await client.notify('textDocument/didClose', {
textDocument: { uri: fileUri(filePath) },
});
}
export async function getDiagnostics(
client: LspClient,
filePath: string,
content: string,
): Promise<Diagnostic[]> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/diagnostic', {
textDocument: { uri },
});
await closeDocument(client, filePath);
const diagnostics: Diagnostic[] = [];
if (result?.diagnostics) {
for (const d of result.diagnostics) {
diagnostics.push({
range: d.range,
severity: d.severity ?? 1,
message: d.message,
source: d.source,
});
}
}
return diagnostics;
}
export async function gotoDefinition(
client: LspClient,
filePath: string,
content: string,
line: number,
character: number,
): Promise<Location | null> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/definition', {
textDocument: { uri },
position: { line, character },
});
await closeDocument(client, filePath);
if (!result) return null;
const loc = Array.isArray(result) ? result[0] : result;
return loc ? { uri: loc.uri, range: loc.range } : null;
}
export async function findReferences(
client: LspClient,
filePath: string,
content: string,
line: number,
character: number,
): Promise<Location[]> {
const uri = fileUri(filePath);
await openDocument(client, filePath, content);
const result: any = await client.request('textDocument/references', {
textDocument: { uri },
position: { line, character },
context: { includeDeclaration: true },
});
await closeDocument(client, filePath);
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
}

View File

@@ -0,0 +1,119 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { LspClient } from './client.js';
import { getServerConfig } from './config.js';
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const SWEEP_INTERVAL_MS = 30_000;
interface LspInstance {
client: LspClient;
proc: ChildProcess;
lastUsed: number;
timer: ReturnType<typeof setTimeout>;
}
export class LspServerManager {
private instances = new Map<string, LspInstance>();
private sweepTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.startSweeper();
}
private startSweeper(): void {
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
this.sweepTimer.unref?.();
}
private findProjectRoot(filePath: string): string | null {
let dir = filePath;
const config = getServerConfig(filePath);
if (!config) return null;
while (true) {
for (const pattern of config.rootPatterns) {
if (existsSync(join(dir, pattern))) return dir;
}
const parent = join(dir, '..');
if (parent === dir) return dir;
dir = parent;
}
}
async getClient(filePath: string): Promise<LspClient | null> {
const config = getServerConfig(filePath);
if (!config) return null;
const projectRoot = this.findProjectRoot(filePath);
if (!projectRoot) return null;
const existing = this.instances.get(projectRoot);
if (existing) {
existing.lastUsed = Date.now();
clearTimeout(existing.timer);
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
existing.timer.unref?.();
return existing.client;
}
return this.spawn(projectRoot, config.command, config.args);
}
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
const client = new LspClient(proc.stdin!, proc.stdout!);
await client.request('initialize', {
processId: process.pid,
rootUri: `file://${projectRoot}`,
capabilities: {
textDocument: {
diagnostic: { dynamicRegistration: false },
definition: { dynamicRegistration: false },
references: { dynamicRegistration: false },
},
},
});
await client.notify('initialized', {});
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
timer.unref?.();
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
proc.on('exit', () => this.instances.delete(projectRoot));
return client;
}
private kill(projectRoot: string): void {
const inst = this.instances.get(projectRoot);
if (!inst) return;
this.instances.delete(projectRoot);
inst.proc.kill('SIGTERM');
setTimeout(() => {
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
}, 5000);
}
private sweep(): void {
const now = Date.now();
for (const [root, inst] of this.instances) {
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
this.kill(root);
}
}
}
shutdown(): void {
if (this.sweepTimer) clearInterval(this.sweepTimer);
for (const root of [...this.instances.keys()]) {
this.kill(root);
}
}
getActiveCount(): number {
return this.instances.size;
}
}
export const lspManager = new LspServerManager();

View File

@@ -0,0 +1,28 @@
export interface Position {
line: number;
character: number;
}
export interface Range {
start: Position;
end: Position;
}
export interface Location {
uri: string;
range: Range;
}
export interface Diagnostic {
range: Range;
severity: number;
message: string;
source?: string;
}
export interface TextDocumentItem {
uri: string;
languageId: string;
version: number;
text: string;
}

View File

@@ -1,9 +1,120 @@
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
import { dirname, join, basename } from 'node:path';
import { randomBytes } from 'node:crypto';
import type { Sql } from '../db.js';
import { resolveWritePath } from './write_guard.js';
import { locateMatch } from './fuzzy-match.js';
/**
* Write a file atomically: stage to a sibling temp file, then rename over the
* target. rename(2) on the same filesystem is atomic, so a crash mid-write can
* never leave a half-written (truncated/corrupt) source file — readers see
* either the old content or the complete new content. The temp lives in the same
* directory to guarantee a same-filesystem rename.
*
* Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target;
* a bare rename would REPLACE the link with a regular file. We realpath an
* existing target first so the rename lands on the real file and the link
* survives — preserving the prior follow-through behavior. A missing target
* (create, or a broken link) just writes the literal path.
*/
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
let target = filePath;
try {
target = await realpath(filePath);
} catch {
// ENOENT (new file) or broken link — write the literal path.
}
const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`);
await writeFile(tmp, content, 'utf8');
try {
await rename(tmp, target);
} catch (err) {
await unlink(tmp).catch(() => {});
throw err;
}
}
/** Detect a file's dominant line ending so an edit can preserve it. */
function detectEol(text: string): '\r\n' | '\n' {
return text.includes('\r\n') ? '\r\n' : '\n';
}
/**
* Serialize the read-modify-write of a single file so two concurrent applies
* (e.g. two chat tabs sharing one worktree, or a Bypass write racing an
* apply_pending) can't lose an update. In-process keying is sufficient —
* BooCoder is a single Fastify process. One Map entry per distinct path.
*/
const fileLocks = new Map<string, Promise<void>>();
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const prev = fileLocks.get(filePath) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((r) => { release = r; });
fileLocks.set(filePath, prev.then(() => current));
await prev.catch(() => {});
try {
return await fn();
} finally {
release();
}
}
// --- Edit-apply planning (pure, unit-tested) ---------------------------------
/**
* Decision for applying one queued edit to a file's current content. Pulled out
* of `applyOne` so the splice — the part that actually corrupted files — is pure
* and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep
* their matchers fail-closed and idempotent.
*/
export type EditPlan =
| { kind: 'apply'; updated: string }
| { kind: 'noop'; reason: 'identical' | 'already-applied' }
| { kind: 'ambiguous'; count: number }
| { kind: 'not_found' };
/**
* Decide how (or whether) to apply an `old → new` edit to `content`.
*
* Idempotency is the whole point here: a queued edit can legitimately be
* re-applied (a local model re-emits the same tool call; a turn is retried; the
* same change sits in the queue twice). A naive splice stamps the new text again
* each time — the 23× block duplication. Two guards make re-application a no-op:
*
* - already-applied (anchored insert): when `new` is `old` + an appended block
* (`old="anchor"`, `new="anchor\n<block>"`), `old` still matches uniquely after
* the first apply, so a second apply would duplicate `<block>`. If the full
* `new` text is already present at the match site, the edit is already applied.
* - already-applied (old gone): if `old` can't be located but `new` is already
* in the file, the change landed on a prior pass — treat as a no-op, not an error.
* - identical: the splice would not change the file.
*
* Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a
* correctable error instead of writing a guess.
*/
export function planEdit(content: string, oldStr: string, newStr: string): EditPlan {
const match = locateMatch(content, oldStr);
if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count };
if (match.kind === 'not_found') {
if (newStr.length > 0 && content.includes(newStr)) {
return { kind: 'noop', reason: 'already-applied' };
}
return { kind: 'not_found' };
}
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
// No-change splice first (covers old === new), then the anchored re-stamp guard:
// the full replacement already sits at the match site (re-emitted anchored insert).
if (updated === content) return { kind: 'noop', reason: 'identical' };
if (content.slice(match.start, match.start + newStr.length) === newStr) {
return { kind: 'noop', reason: 'already-applied' };
}
return { kind: 'apply', updated };
}
// --- Types -------------------------------------------------------------------
export interface PendingChange {
@@ -47,6 +158,13 @@ export async function queueEdit(
const resolved = resolveWritePath(projectRoot, filePath);
const diff = JSON.stringify({ old: oldString, new: newString });
// Idempotent queue: collapse an identical edit that is still pending. Local
// quantized models re-emit the same edit_file call within a turn, and a retried
// turn re-queues — each duplicate row would apply and stamp another copy. One
// pending row per (session, file, operation, diff) is enough.
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff);
if (existing) return existing;
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
@@ -55,6 +173,28 @@ export async function queueEdit(
return row!;
}
/** Return an identical still-pending change for this (session, file, op, diff),
* or undefined. Used to keep the queue idempotent against re-emitted edits. */
async function findPendingDuplicate(
sql: Sql,
sessionId: string,
resolvedPath: string,
operation: 'create' | 'edit' | 'delete',
diff: string,
): Promise<PendingChange | undefined> {
const [row] = await sql<PendingChange[]>`
SELECT * FROM pending_changes
WHERE session_id = ${sessionId}
AND file_path = ${resolvedPath}
AND operation = ${operation}
AND diff = ${diff}
AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
`;
return row;
}
export async function queueCreate(
sql: Sql,
sessionId: string,
@@ -68,6 +208,9 @@ export async function queueCreate(
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
if (existing) return existing;
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
@@ -87,6 +230,9 @@ export async function queueDelete(
): Promise<PendingChange> {
const resolved = resolveWritePath(projectRoot, filePath);
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
if (existing) return existing;
const [row] = await sql<PendingChange[]>`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
@@ -110,48 +256,60 @@ export async function applyOne(
}
try {
// Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path);
return await withFileLock(change.file_path, async () => {
// Re-validate path in case projectRoot has shifted
resolveWritePath(projectRoot, change.file_path);
switch (change.operation) {
case 'create': {
await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8');
break;
}
case 'edit': {
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const content = await readFile(change.file_path, 'utf8');
const match = locateMatch(content, oldStr);
if (match.kind === 'ambiguous') {
throw new Error(
`old_string matches ${match.count} locations — add surrounding context to disambiguate`,
);
switch (change.operation) {
case 'create': {
await mkdir(dirname(change.file_path), { recursive: true });
await writeFileAtomic(change.file_path, change.diff);
break;
}
if (match.kind === 'not_found') {
throw new Error(
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
);
case 'edit': {
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
const raw = await readFile(change.file_path, 'utf8');
// Normalize to LF for matching, then write back in the file's native EOL
// so an LF-emitting model doesn't leave a CRLF file with mixed endings.
const eol = detectEol(raw);
const toLf = (t: string) => t.replaceAll('\r\n', '\n');
const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr));
if (plan.kind === 'ambiguous') {
throw new Error(
`old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
);
}
if (plan.kind === 'not_found') {
throw new Error(
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
);
}
if (plan.kind === 'apply') {
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
await writeFileAtomic(change.file_path, out);
} else {
// noop: the edit is already applied (re-emitted / retried) or a no-change.
// Mark it applied without rewriting so it can't stamp a duplicate.
console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`);
}
break;
}
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
await writeFile(change.file_path, updated, 'utf8');
break;
}
case 'delete': {
// Stash current content in diff for potential rewind
try {
const existing = await readFile(change.file_path, 'utf8');
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
} catch {
// File may already be gone — proceed with status update
case 'delete': {
// Stash current content in diff for potential rewind
try {
const existing = await readFile(change.file_path, 'utf8');
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
} catch {
// File may already be gone — proceed with status update
}
await unlink(change.file_path);
break;
}
await unlink(change.file_path);
break;
}
}
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
@@ -220,13 +378,13 @@ export async function rewindOne(
);
}
const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end);
await writeFile(change.file_path, reverted, 'utf8');
await writeFileAtomic(change.file_path, reverted);
break;
}
case 'delete': {
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
await mkdir(dirname(change.file_path), { recursive: true });
await writeFile(change.file_path, change.diff, 'utf8');
await writeFileAtomic(change.file_path, change.diff);
break;
}
}

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { analyzeMessages } from '../analyzer.js';
describe('analyzeMessages', () => {
it('classifies user messages', () => {
const breakdown = analyzeMessages([{ role: 'user', content: 'hello world' }]);
expect(breakdown.user).toBeGreaterThan(0);
expect(breakdown.total).toBe(breakdown.user);
});
it('counts tool calls', () => {
const parts = [
{ role: 'assistant', content: 'using grep', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: '{"files":[]}', tool_call_id: '1' },
];
const breakdown = analyzeMessages(parts);
expect(breakdown.tools).toBeGreaterThan(0);
expect(breakdown.assistant).toBeGreaterThan(0);
});
it('separates reasoning tokens', () => {
const parts = [
{ role: 'assistant', content: 'short answer', reasoning_parts: [{ text: 'long chain of thought reasoning here' }] },
];
const breakdown = analyzeMessages(parts);
expect(breakdown.reasoning).toBeGreaterThan(0);
expect(breakdown.assistant).toBeLessThan(breakdown.reasoning);
});
});

View File

@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
describe('persistTaskBreakdown', () => {
it('exports functions', async () => {
const mod = await import('../persist.js');
expect(typeof mod.persistTaskBreakdown).toBe('function');
expect(typeof mod.getTaskBreakdown).toBe('function');
expect(typeof mod.analyzeAndPersistTaskBreakdown).toBe('function');
});
});

View File

@@ -0,0 +1,60 @@
// TokenScope analyzer — classifies message parts into category breakdown.
// Ported from opencode-tokenscope (MIT).
export interface TokenBreakdown {
system: number;
user: number;
assistant: number;
tools: number;
reasoning: number;
total: number;
}
const CHARS_PER_TOKEN = 4;
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
export function analyzeMessages(parts: any[]): TokenBreakdown {
const breakdown: TokenBreakdown = { system: 0, user: 0, assistant: 0, tools: 0, reasoning: 0, total: 0 };
for (const part of parts) {
const role = part.role ?? '';
const content = part.content ?? '';
const tokens = estimateTokens(content);
switch (role) {
case 'system':
breakdown.system += tokens;
break;
case 'user':
breakdown.user += tokens;
break;
case 'assistant':
breakdown.assistant += tokens;
if (part.tool_calls) {
for (const tc of part.tool_calls) {
breakdown.tools += estimateTokens(JSON.stringify(tc));
}
}
break;
case 'tool':
breakdown.tools += tokens;
break;
default:
breakdown.assistant += tokens;
}
if (part.reasoning_parts) {
for (const rp of part.reasoning_parts) {
const rTokens = estimateTokens(rp.text ?? '');
breakdown.reasoning += rTokens;
breakdown.assistant -= rTokens;
}
}
}
breakdown.total = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning;
return breakdown;
}

View File

@@ -0,0 +1,35 @@
// TokenScope persistence — writes breakdown to task records.
import type { Sql } from '../../db.js';
import type { TokenBreakdown } from './analyzer.js';
export async function persistTaskBreakdown(
sql: Sql,
taskId: string,
breakdown: TokenBreakdown,
): Promise<void> {
await sql`
UPDATE tasks SET token_breakdown = ${sql.json(breakdown as never)}
WHERE id = ${taskId}
`;
}
export async function getTaskBreakdown(
sql: Sql,
taskId: string,
): Promise<TokenBreakdown | null> {
const rows = await sql<{ token_breakdown: any }[]>`
SELECT token_breakdown FROM tasks WHERE id = ${taskId}
`;
return rows[0]?.token_breakdown ?? null;
}
export async function analyzeAndPersistTaskBreakdown(
sql: Sql,
taskId: string,
parts: any[],
): Promise<TokenBreakdown> {
const { analyzeMessages } = await import('./analyzer.js');
const breakdown = analyzeMessages(parts);
await persistTaskBreakdown(sql, taskId, breakdown);
return breakdown;
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { runWithInferenceContext, getInferenceContext } from '../inference_context.js';
import type { Sql } from '../../../db.js';
const fakeSql = {} as unknown as Sql;
describe('inference context (AsyncLocalStorage isolation)', () => {
it('throws when read outside a run', () => {
expect(() => getInferenceContext()).toThrow(/outside inference context/);
});
it('keeps each run its own context across overlapping awaits', async () => {
// The race the global `let current` had: run B starts (and would overwrite a
// shared global) while run A is awaiting. After A resumes it must still read
// its OWN sessionId, not B's.
const run = (id: string, delay: number) =>
runWithInferenceContext({ sql: fakeSql, sessionId: id, taskId: null }, async () => {
await new Promise((r) => setTimeout(r, delay));
return getInferenceContext().sessionId;
});
const [a, b] = await Promise.all([run('A', 20), run('B', 5)]);
expect(a).toBe('A');
expect(b).toBe('B');
});
it('carries permissionMode and taskId per run', async () => {
const result = await runWithInferenceContext(
{ sql: fakeSql, sessionId: 's1', taskId: 't1', permissionMode: 'bypass' },
async () => {
await Promise.resolve();
const ctx = getInferenceContext();
return { taskId: ctx.taskId, mode: ctx.permissionMode };
},
);
expect(result).toEqual({ taskId: 't1', mode: 'bypass' });
});
});

View File

@@ -26,6 +26,15 @@ export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
},
},
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
// Under Ask (and Plan) the human approves via the Pending Changes panel — the
// agent must not auto-apply. Bypass and legacy (undefined) may apply.
if (context.permissionMode === 'ask' || context.permissionMode === 'plan') {
return {
status: 'denied',
message:
'Permission mode is Ask — staged changes must be approved by the user in the Pending Changes panel, not applied by the agent.',
};
}
const results = await applyAll(context.sql, context.sessionId, projectRoot);
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueCreate } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const CreateFileInput = z.object({
file_path: z.string().min(1),
@@ -32,6 +33,7 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
},
},
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('create_file');
const change = await queueCreate(
context.sql,
context.sessionId,
@@ -40,12 +42,11 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
input.content,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'create',
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
};
return finalizeWrite(
context,
projectRoot,
change,
`File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
);
},
};

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueDelete } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const DeleteFileInput = z.object({
file_path: z.string().min(1),
@@ -30,6 +31,7 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
},
},
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('delete_file');
const change = await queueDelete(
context.sql,
context.sessionId,
@@ -37,12 +39,11 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
input.file_path,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'delete',
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
};
return finalizeWrite(
context,
projectRoot,
change,
`File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
);
},
};

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { ToolDef, ToolContext } from './types.js';
import { queueEdit } from '../pending_changes.js';
import { denyReadOnly, finalizeWrite } from './write-gate.js';
const EditFileInput = z.object({
file_path: z.string().min(1),
@@ -34,6 +35,7 @@ export const editFileTool: ToolDef<EditFileInputT> = {
},
},
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
if (context.permissionMode === 'plan') return denyReadOnly('edit_file');
const change = await queueEdit(
context.sql,
context.sessionId,
@@ -43,12 +45,11 @@ export const editFileTool: ToolDef<EditFileInputT> = {
input.new_string,
projectRoot,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: 'edit',
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
};
return finalizeWrite(
context,
projectRoot,
change,
`Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
);
},
};

View File

@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
import { newTaskTool } from './new_task.js';
import { listTasksTool } from './list_tasks.js';
import { checkTaskStatusTool } from './check_task_status.js';
import { lspDiagnosticsTool } from './lsp_diagnostics.js';
import { lspGotoDefinitionTool } from './lsp_goto_definition.js';
import { lspFindReferencesTool } from './lsp_find_references.js';
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
checkTaskStatusTool,
];
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };
// Read-only agent tools for code intelligence.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const READ_TOOLS: readonly ToolDef<any>[] = [
lspDiagnosticsTool,
lspGotoDefinitionTool,
lspFindReferencesTool,
];
export {
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
newTaskTool, listTasksTool, checkTaskStatusTool,
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
};

View File

@@ -1,36 +1,49 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Sql } from '../../db.js';
import type { PermissionMode } from './types.js';
/**
* Module-level inference context for write tools.
* Per-run inference context for write tools.
*
* Set via `setInferenceContext()` before each inference run starts.
* Write tools read it via `getInferenceContext()` during execute.
* Same pattern as BooChat's `loadConfig()` singleton — tools need
* ambient state that can't be threaded through the tool-phase execute
* signature (which is `execute(input, projectRoot, extraRoots?)`).
* Write tools need ambient state (sql, sessionId, the permission gate) that the
* BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
* carry. This used to be a single module-level `let current` — but the inference
* runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
* message racing a dispatcher-polled native task; two chat tabs streaming) would
* clobber each other's context, and `cancel()` cleared it for ALL in-flight runs.
*
* AsyncLocalStorage gives each run its own context: `enqueue()` starts its async
* loop synchronously inside `runWithInferenceContext`, so the store propagates
* through every awaited tool execution in that run — and only that run.
*/
export interface InferenceContext {
sql: Sql;
sessionId: string;
taskId: string | null;
/** Native-BooCode permission gate, set per run from the request/task mode. */
permissionMode?: PermissionMode;
}
let current: InferenceContext | null = null;
const storage = new AsyncLocalStorage<InferenceContext>();
export function setInferenceContext(ctx: InferenceContext): void {
current = ctx;
}
export function clearInferenceContext(): void {
current = null;
/**
* Bind `ctx` for the duration of the (possibly detached) async chain `fn` starts.
* The inference runner kicks off its loop synchronously within this call, so all
* downstream `await`s — including write-tool `execute` via the adapter — read the
* same store. Concurrent runs each get their own; nothing is shared or cleared
* out from under an in-flight run.
*/
export function runWithInferenceContext<T>(ctx: InferenceContext, fn: () => T): T {
return storage.run(ctx, fn);
}
export function getInferenceContext(): InferenceContext {
if (!current) {
const ctx = storage.getStore();
if (!ctx) {
throw new Error(
'Write tool called outside inference context — setInferenceContext() was not called before this run',
'Write tool called outside inference context — runWithInferenceContext() did not wrap this run',
);
}
return current;
return ctx;
}

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { getDiagnostics } from '../lsp/operations.js';
const LspDiagnosticsInput = z.object({
file_path: z.string().describe('Path to the file to check for diagnostics'),
});
type InputT = z.infer<typeof LspDiagnosticsInput>;
export const lspDiagnosticsTool: ToolDef<InputT> = {
name: 'lsp_diagnostics',
description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.',
inputSchema: LspDiagnosticsInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_diagnostics',
description: 'Get TypeScript/JavaScript diagnostics for a file',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file' },
},
required: ['file_path'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type for LSP diagnostics' };
const diagnostics = await getDiagnostics(client, resolved, content);
if (diagnostics.length === 0) return { result: 'No diagnostics found.' };
const lines = diagnostics.map((d) => {
const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown';
return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`;
});
return { result: lines.join('\n') };
},
};

View File

@@ -0,0 +1,49 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { findReferences } from '../lsp/operations.js';
const LspFindReferencesInput = z.object({
file_path: z.string().describe('Path to the source file'),
line: z.number().int().nonnegative().describe('0-based line number'),
character: z.number().int().nonnegative().describe('0-based character offset'),
});
type InputT = z.infer<typeof LspFindReferencesInput>;
export const lspFindReferencesTool: ToolDef<InputT> = {
name: 'lsp_find_references',
description: 'Find all references to a symbol at a given position in a file.',
inputSchema: LspFindReferencesInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_find_references',
description: 'Find all references to symbol at position',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
line: { type: 'number' },
character: { type: 'number' },
},
required: ['file_path', 'line', 'character'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type' };
const refs = await findReferences(client, resolved, content, input.line, input.character);
if (refs.length === 0) return { result: 'No references found.' };
const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`);
return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` };
},
};

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import type { ToolDef, ToolContext } from './types.js';
import { resolveWritePath } from '../write_guard.js';
import { lspManager } from '../lsp/server-manager.js';
import { gotoDefinition } from '../lsp/operations.js';
const LspGotoDefinitionInput = z.object({
file_path: z.string().describe('Path to the source file'),
line: z.number().int().nonnegative().describe('0-based line number'),
character: z.number().int().nonnegative().describe('0-based character offset'),
});
type InputT = z.infer<typeof LspGotoDefinitionInput>;
export const lspGotoDefinitionTool: ToolDef<InputT> = {
name: 'lsp_goto_definition',
description: 'Find the definition of a symbol at a given position in a file.',
inputSchema: LspGotoDefinitionInput,
jsonSchema: {
type: 'function',
function: {
name: 'lsp_goto_definition',
description: 'Find definition of symbol at position',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string' },
line: { type: 'number' },
character: { type: 'number' },
},
required: ['file_path', 'line', 'character'],
},
},
},
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
const resolved = await resolveWritePath(projectRoot, input.file_path);
const content = await readFile(resolved, 'utf8');
const client = await lspManager.getClient(resolved);
if (!client) return { error: 'Unsupported file type' };
const loc = await gotoDefinition(client, resolved, content, input.line, input.character);
if (!loc) return { result: 'No definition found.' };
return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` };
},
};

View File

@@ -6,6 +6,7 @@ const NewTaskInput = z.object({
input: z.string().min(1).describe('Task description for the child subtask'),
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
model: z.string().optional().describe('Optional: model override for the subtask'),
background: z.boolean().optional().describe('If true, return immediately without blocking on completion'),
});
type NewTaskInputT = z.infer<typeof NewTaskInput>;
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
input: { type: 'string', description: 'Task description for the child subtask' },
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
model: { type: 'string', description: 'Optional: model override for the subtask' },
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
},
required: ['input'],
},
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
return { error: 'Cannot determine project_id from current session' };
}
const isBg = input.background === true;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
`;
return {
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
message: isBg
? `Background subtask created (id: ${task!.id}). It will continue independently.`
: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
task_id: task!.id,
state: task!.state,
background: isBg,
};
},
};

View File

@@ -1,6 +1,22 @@
import type { z } from 'zod';
import type { Sql } from '../../db.js';
/**
* Unified permission ladder for native BooCode inference. Gates the write tools:
* plan — read-only: create/edit/delete are denied (no staging).
* ask — stage to the pending-changes queue; `apply_pending` is denied so the
* agent cannot self-apply (the human approves via the Diff panel).
* bypass — apply each write immediately (no queue, no approval).
* Undefined preserves the historical behavior (stage + `apply_pending` allowed).
*/
export type PermissionMode = 'plan' | 'ask' | 'bypass';
/** Narrow a raw task/request mode id to a unified PermissionMode, else undefined
* (e.g. an external agent's native mode id, or null). */
export function asPermissionMode(id: string | null | undefined): PermissionMode | undefined {
return id === 'plan' || id === 'ask' || id === 'bypass' ? id : undefined;
}
export interface ToolJsonSchema {
type: 'function';
function: {
@@ -21,6 +37,8 @@ export interface ToolContext {
sql: Sql;
sessionId: string;
taskId: string | null;
/** Native-BooCode permission gate for write tools (undefined = legacy behavior). */
permissionMode?: PermissionMode;
}
export interface ToolDef<TInput> {

View File

@@ -0,0 +1,53 @@
/**
* Permission-gate helpers for native BooCode write tools. The gate comes from
* the per-run inference context (`ToolContext.permissionMode`):
* plan — deny the write (read-only); nothing is staged.
* bypass — apply the staged change immediately (no queue, no approval).
* ask / undefined — leave it in the pending-changes queue for review.
*/
import type { ToolContext } from './types.js';
import { applyOne } from '../pending_changes.js';
/** Result returned when a write is denied under Plan (read-only) mode. */
export function denyReadOnly(operation: string): unknown {
return {
status: 'denied',
operation,
message: `Read-only (Plan) permission mode — ${operation} is not permitted. Switch to Ask or Bypass to make changes.`,
};
}
/** Finalize a just-staged change per the permission gate: apply now under Bypass,
* otherwise return it as queued for the human to approve. */
export async function finalizeWrite(
context: ToolContext,
projectRoot: string,
change: { id: string; file_path: string; operation: string },
queuedHint: string,
): Promise<unknown> {
if (context.permissionMode === 'bypass') {
const res = await applyOne(context.sql, change.id, projectRoot);
console.log(
`[write-gate] bypass apply ${change.operation} ${change.file_path} -> ${res.success ? 'applied' : 'FAILED: ' + (res.error ?? '?')}`,
);
return {
status: res.success ? 'applied' : 'failed',
change_id: change.id,
file_path: change.file_path,
operation: change.operation,
message: res.success
? `${change.operation} applied to ${change.file_path}.`
: `Apply failed for ${change.file_path}: ${res.error ?? 'unknown error'}. Left in the pending queue.`,
};
}
console.log(
`[write-gate] ${context.permissionMode ?? 'legacy'} queued ${change.operation} ${change.file_path}`,
);
return {
status: 'queued',
change_id: change.id,
file_path: change.file_path,
operation: change.operation,
message: queuedHint,
};
}

View File

@@ -17,6 +17,8 @@
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
- **`services/inference/tool-shim.ts`** — Recovers structured tool calls from plain-text model output. Some models (notably Qwen) emit `<tool_call><name>...</name><arguments>...</arguments></tool_call>` inline text instead of structured JSON. `extractToolCalls(text)` parses both XML and JSON inline formats. `hasToolCallMarkup(text)` is a fast pre-check. Used as a fallback in the stream phase when structured `tool_calls` parse fails. Does NOT require `FAST_MODEL` — operates on the existing turn's output text.
- **`services/inference/loop-detectors.ts`** — Six detectors that catch repetitive model behavior: `detectContentRepeat` (same content N times), `detectToolLoop` (same tool called consecutively). `detectDoomLoop` combines both. These are additive to the existing `sentinels.ts` doom-loop detection.
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
// token-analyzer-ui: context window utilization and token breakdown data.
// v1 — global aggregates only.
export interface ContextWindowStats {
avg_ctx_used: number | null;
avg_ctx_max: number | null;
avg_utilization_pct: number | null;
message_count: number;
}
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/analytics/context — average context window utilization across
// completed assistant messages that carry ctx_used/ctx_max.
app.get('/api/analytics/context', async () => {
const [row] = await sql<ContextWindowStats[]>`
SELECT
AVG(ctx_used)::DOUBLE PRECISION AS avg_ctx_used,
AVG(ctx_max)::DOUBLE PRECISION AS avg_ctx_max,
AVG(ctx_used::float / NULLIF(ctx_max, 0))::DOUBLE PRECISION AS avg_utilization_pct,
COUNT(*)::INT AS message_count
FROM messages
WHERE role = 'assistant'
AND status = 'complete'
AND ctx_used IS NOT NULL
AND ctx_max IS NOT NULL
AND ctx_max > 0
`;
return row ?? { avg_ctx_used: null, avg_ctx_max: null, avg_utilization_pct: null, message_count: 0 };
});
}

View File

@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { realpath, stat, readdir, access } from 'node:fs/promises';
import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises';
import { basename, resolve, sep } from 'node:path';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
@@ -473,7 +473,7 @@ export function registerProjectRoutes(
// Always includes auto_mode (the dirty-state-derived mode) so the client can
// show a suggestion when a pinned mode diverges from what would be auto-selected.
// Returns { git_repo: false } when the path is not a git repository.
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
'/api/projects/:id/git/diff',
async (req, reply) => {
const { id } = req.params;
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
rawMode === 'uncommitted' ? 'uncommitted' :
auto_mode; // no mode param → auto-select (FIX 1)
const result = await getGitDiff(projectRoot, mode);
const ignoreWhitespace = req.query.whitespace === '1';
const result = await getGitDiff(projectRoot, mode, ignoreWhitespace);
if (result === null) {
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
}
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
).min(1),
});
const WriteFileBody = z.object({
path: z.string().min(1),
content: z.string(),
});
// POST /api/projects/:id/git/stage — stage whole files
app.post<{ Params: { id: string } }>(
'/api/projects/:id/git/stage',
@@ -637,6 +643,38 @@ export function registerProjectRoutes(
},
);
// POST /api/projects/:id/write_file — write a file atomically
app.post<{ Params: { id: string } }>(
'/api/projects/:id/write_file',
async (req, reply) => {
const body = WriteFileBody.safeParse(req.body);
if (!body.success) { reply.code(400); return { error: body.error.message }; }
const { id } = req.params;
const projectPath = await selectProjectPath(sql, id);
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
let root: string;
try { root = await resolveProjectRoot(projectPath); }
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path);
// Validate path stays within project root
const realTarget = await realpath(target).catch(() => target);
if (!realTarget.startsWith(root + sep) && realTarget !== root) {
reply.code(403);
return { error: 'path escapes project root' };
}
const tmp = target + '.tmp';
try {
await writeFile(tmp, body.data.content, 'utf-8');
await rename(tmp, target);
return { ok: true };
} catch (err) {
// Clean up tmp on failure
await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {});
throw err;
}
},
);
// GET /api/projects/:id/files
app.get<{ Params: { id: string } }>(
'/api/projects/:id/files',

View File

@@ -271,7 +271,9 @@ function buildNumstatMap(
async function getUncommittedDiff(
gitRoot: string,
inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
: runGit(['diff', '--cached', '--name-status'], gitRoot),
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
: runGit(['diff', '--cached'], gitRoot),
? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
: runGit(['diff', ...ws, '--cached'], gitRoot),
]);
const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -347,11 +349,13 @@ async function getCommittedDiff(
base: string,
label: string,
inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
runGit(['diff', base, 'HEAD'], gitRoot),
runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
]);
const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -383,23 +387,23 @@ async function getCommittedDiff(
* the directory is not a git repository. On a null committed-mode base, falls
* back to uncommitted and labels the result accordingly.
*/
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> {
export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise<GitDiffResult | null> {
const gitRoot = await resolveGitRoot(cwd);
if (!gitRoot) return null;
const inProgress = await detectInProgress(gitRoot);
if (mode === 'uncommitted') {
return getUncommittedDiff(gitRoot, inProgress);
return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
}
const { base, label } = await resolveCommittedBase(gitRoot);
if (!base) {
// Fall back to uncommitted with a descriptive label
const result = await getUncommittedDiff(gitRoot, inProgress);
const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
return { ...result, base_label: label };
}
return getCommittedDiff(gitRoot, base, label, inProgress);
return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
}
// ── Phase 2: Write helpers ─────────────────────────────────────────────────

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { deduplicate } from '../strategies/deduplication.js';
import type { DcpMessage } from '../messages.js';
describe('deduplicate', () => {
it('removes consecutive identical tool_call+tool_result pairs', () => {
const messages: DcpMessage[] = [
{ role: 'user', content: 'search for x' },
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '1' },
// Duplicate pair
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '2' },
];
const { messages: result, stats } = deduplicate(messages);
expect(result).toHaveLength(3); // user + first pair
expect(stats.removedCount).toBe(2);
});
it('preserves non-duplicate content', () => {
const messages: DcpMessage[] = [
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result1', tool_call_id: '1' },
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result
];
const { messages: result, stats } = deduplicate(messages);
expect(result).toHaveLength(4);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { toDcpMessages, fromDcpMessages } from '../messages.js';
describe('toDcpMessages', () => {
it('converts user messages', () => {
const result = toDcpMessages([{ role: 'user', content: 'hello' }]);
expect(result[0].role).toBe('user');
expect(result[0].content).toBe('hello');
});
it('marks Error: content as isError', () => {
const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]);
expect(result[0].isError).toBe(true);
});
});
describe('fromDcpMessages', () => {
it('round-trips messages', () => {
const original = [{ role: 'user', content: 'hello' }];
expect(fromDcpMessages(toDcpMessages(original))).toEqual(original);
});
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { purgeErrors } from '../strategies/purge-errors.js';
import type { DcpMessage } from '../messages.js';
describe('purgeErrors', () => {
it('removes tool results where content starts with Error:', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' },
{ role: 'tool', content: '{"files":[]}', tool_call_id: '2' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(1);
expect(stats.removedCount).toBe(1);
});
it('removes empty tool results', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: '', tool_call_id: '1' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(0);
expect(stats.removedCount).toBe(1);
});
it('preserves valid tool results', () => {
const messages: DcpMessage[] = [
{ role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' },
];
const { messages: result, stats } = purgeErrors(messages);
expect(result).toHaveLength(1);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { transformMessages } from '../transform.js';
import type { DcpMessage } from '../messages.js';
describe('transformMessages', () => {
it('applies dedup then purge in order', () => {
const input: DcpMessage[] = [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result', tool_call_id: '1' },
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
{ role: 'tool', content: 'result', tool_call_id: '2' }, // Dup
];
const { messages, stats } = transformMessages('test-chat', input);
expect(stats.removedCount).toBeGreaterThan(0);
expect(messages.length).toBeLessThan(input.length);
});
it('handles empty input', () => {
const { messages, stats } = transformMessages('empty', []);
expect(messages).toHaveLength(0);
expect(stats.removedCount).toBe(0);
});
});

View File

@@ -0,0 +1,4 @@
export { transformMessages } from './transform.js';
export type { DcpMessage } from './messages.js';
export { toDcpMessages, fromDcpMessages } from './messages.js';
export { getDcpState, clearDcpState } from './state.js';

View File

@@ -0,0 +1,34 @@
// DCP message shape adapter.
// Converts between BooCode MessagePart[] and the DCP internal shape.
// Clean-room implementation — no AGPL source copied.
export interface DcpMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
tool_call_id?: string;
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
isError?: boolean;
}
export function toDcpMessages(parts: any[]): DcpMessage[] {
return parts.map((p: any) => {
const msg: DcpMessage = { role: p.role, content: p.content ?? '' };
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
if (p.tool_calls) msg.tool_calls = p.tool_calls;
if (p.isError) msg.isError = true;
if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) {
msg.isError = true;
}
return msg;
});
}
export function fromDcpMessages(msgs: DcpMessage[]): any[] {
return msgs.map((m) => ({
role: m.role,
content: m.content,
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.isError ? { isError: true } : {}),
}));
}

View File

@@ -0,0 +1,27 @@
// Per-chat session state for DCP.
// Tracks last transform timestamp and message count to avoid re-processing.
interface ChatDcpState {
lastTransformAt: number;
lastMessageCount: number;
}
const chatStates = new Map<string, ChatDcpState>();
export function getDcpState(chatId: string): ChatDcpState | undefined {
return chatStates.get(chatId);
}
export function setDcpState(chatId: string, messageCount: number): void {
chatStates.set(chatId, { lastTransformAt: Date.now(), lastMessageCount: messageCount });
}
export function clearDcpState(chatId: string): void {
chatStates.delete(chatId);
}
export function shouldTransform(chatId: string, messageCount: number): boolean {
const state = chatStates.get(chatId);
if (!state) return true;
return state.lastMessageCount !== messageCount;
}

View File

@@ -0,0 +1,50 @@
import type { DcpMessage } from '../messages.js';
export function deduplicate(messages: DcpMessage[]): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
const result: DcpMessage[] = [];
let removedCount = 0;
let freedTokens = 0;
let i = 0;
while (i < messages.length) {
const current: DcpMessage = messages[i]!;
const next = messages[i + 1];
if (
current.role === 'assistant' &&
current.tool_calls &&
next &&
next.role === 'tool' &&
next.tool_call_id === current.tool_calls[0]?.id
) {
const nextNext = messages[i + 2];
const nextNextNext = messages[i + 3];
if (
nextNext &&
nextNext.role === 'assistant' &&
nextNext.tool_calls &&
nextNextNext &&
nextNextNext.role === 'tool' &&
nextNextNext.tool_call_id === nextNext.tool_calls[0]?.id &&
nextNext.tool_calls[0]?.name === current.tool_calls[0]?.name &&
nextNext.tool_calls[0]?.arguments === current.tool_calls[0]?.arguments &&
nextNextNext.content === next.content
) {
result.push(current, next);
i += 4;
removedCount += 2;
freedTokens += Math.ceil(nextNext.content.length / 4);
freedTokens += Math.ceil(current.content.length / 4);
} else {
result.push(current);
i++;
}
} else {
result.push(current);
i++;
}
}
return { messages: result, stats: { removedCount, freedTokens } };
}

View File

@@ -0,0 +1,34 @@
// Purge-errors strategy — removes failed/empty tool_result entries.
// Clean-room implementation.
import type { DcpMessage } from '../messages.js';
const ERROR_PREFIXES = ['Error:', 'error:', 'Error: '];
const DEFAULT_WINDOW = 5;
export function purgeErrors(
messages: DcpMessage[],
windowSize: number = DEFAULT_WINDOW,
): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
const result: DcpMessage[] = [];
let removedCount = 0;
let freedTokens = 0;
for (const msg of messages) {
if (msg.role === 'tool') {
const shouldRemove =
msg.isError ||
ERROR_PREFIXES.some((p) => msg.content.startsWith(p)) ||
msg.content.trim() === '';
if (shouldRemove) {
removedCount++;
freedTokens += Math.ceil(msg.content.length / 4);
continue; // Skip this message
}
}
result.push(msg);
}
return { messages: result, stats: { removedCount, freedTokens } };
}

View File

@@ -0,0 +1,52 @@
// Transform orchestrator — runs DCP strategies in sequence.
// Clean-room implementation.
import type { DcpMessage } from './messages.js';
import { deduplicate } from './strategies/deduplication.js';
import { purgeErrors } from './strategies/purge-errors.js';
import { getDcpState, setDcpState, shouldTransform } from './state.js';
export interface TransformStats {
removedCount: number;
freedTokens: number;
dedupRemoved: number;
purgeRemoved: number;
}
export interface TransformResult {
messages: DcpMessage[];
stats: TransformStats;
}
export function transformMessages(chatId: string, messages: DcpMessage[]): TransformResult {
if (!shouldTransform(chatId, messages.length)) {
return { messages, stats: { removedCount: 0, freedTokens: 0, dedupRemoved: 0, purgeRemoved: 0 } };
}
let m = messages;
// Step 1: Deduplicate
const dedupResult = deduplicate(m);
m = dedupResult.messages;
const dedupRemoved = dedupResult.stats.removedCount;
// Step 2: Purge errors
const purgeResult = purgeErrors(m);
m = purgeResult.messages;
const purgeRemoved = purgeResult.stats.removedCount;
const totalRemoved = dedupRemoved + purgeRemoved;
const totalFreed = dedupResult.stats.freedTokens + purgeResult.stats.freedTokens;
setDcpState(chatId, messages.length);
return {
messages: m,
stats: {
removedCount: totalRemoved,
freedTokens: totalFreed,
dedupRemoved,
purgeRemoved,
},
};
}

View File

@@ -0,0 +1,68 @@
// Loop detectors — detects repetitive patterns in assistant output
// that indicate a model is stuck in a loop.
export interface LoopDetectionResult {
isLoop: boolean;
reason?: string;
confidence: number; // 0-1
}
const REPEATED_PHRASE_MIN_COUNT = 4;
const REPEATED_TOOL_MIN_COUNT = 3;
export function detectContentRepeat(messages: string[]): LoopDetectionResult {
if (messages.length < REPEATED_PHRASE_MIN_COUNT) {
return { isLoop: false, confidence: 0 };
}
const recent = messages.slice(-REPEATED_PHRASE_MIN_COUNT);
const unique = new Set(recent);
if (unique.size === 1) {
return {
isLoop: true,
reason: `Same content repeated ${REPEATED_PHRASE_MIN_COUNT} times`,
confidence: 0.9,
};
}
if (unique.size <= 2 && recent.length >= 4) {
return {
isLoop: true,
reason: 'Content oscillating between two variants',
confidence: 0.7,
};
}
return { isLoop: false, confidence: 0 };
}
export function detectToolLoop(toolNames: string[]): LoopDetectionResult {
if (toolNames.length < REPEATED_TOOL_MIN_COUNT) return { isLoop: false, confidence: 0 };
const recent = toolNames.slice(-REPEATED_TOOL_MIN_COUNT);
const unique = new Set(recent);
if (unique.size === 1) {
return {
isLoop: true,
reason: `Same tool "${recent[0]}" called ${REPEATED_TOOL_MIN_COUNT} times consecutively`,
confidence: 0.85,
};
}
return { isLoop: false, confidence: 0 };
}
export function detectDoomLoop(
messages: string[],
toolNames: string[],
): LoopDetectionResult {
const contentResult = detectContentRepeat(messages);
if (contentResult.isLoop) return contentResult;
const toolResult = detectToolLoop(toolNames);
if (toolResult.isLoop) return toolResult;
return { isLoop: false, confidence: 0 };
}

View File

@@ -0,0 +1,45 @@
// ToolShim — recovers structured tool calls from plain-text model output.
// When the model emits tool calls as plain text instead of structured JSON,
// this shim attempts to parse and recover them.
export interface ParsedToolCall {
id: string;
name: string;
arguments: string;
}
const TOOL_CALL_PATTERN = /<tool_call>\s*<name>(.+?)<\/name>\s*<arguments>(.+?)<\/arguments>\s*<\/tool_call>/gs;
const JSON_TOOL_PATTERN = /\{\s*"name":\s*"([^"]+)",\s*"arguments":\s*({.+?})\s*\}/gs;
export function extractToolCalls(text: string): ParsedToolCall[] {
const calls: ParsedToolCall[] = [];
let match: RegExpExecArray | null;
// Try XML-style tool calls (common in Qwen output)
const xmlRegex = new RegExp(TOOL_CALL_PATTERN);
while ((match = xmlRegex.exec(text)) !== null) {
calls.push({
id: `call_${calls.length}`,
name: match[1]!.trim(),
arguments: match[2]!.trim(),
});
}
if (calls.length > 0) return calls;
// Try JSON-style tool calls
const jsonRegex = new RegExp(JSON_TOOL_PATTERN);
while ((match = jsonRegex.exec(text)) !== null) {
calls.push({
id: `call_${calls.length}`,
name: match[1]!.trim(),
arguments: match[2]!.trim(),
});
}
return calls;
}
export function hasToolCallMarkup(text: string): boolean {
return TOOL_CALL_PATTERN.test(text) || JSON_TOOL_PATTERN.test(text);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
// Embedding module — ONNX-based local embeddings.
// Falls back gracefully when the model file is not available.
let model: any = null;
let ortModule: any = null;
export function isEmbeddingAvailable(): boolean {
return model !== null;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const dynamicRequire = typeof require !== 'undefined' ? require : null;
export async function initEmbeddings(modelPath?: string): Promise<boolean> {
try {
if (dynamicRequire) {
try { ortModule = dynamicRequire('onnxruntime-node'); } catch { ortModule = null; }
}
if (!ortModule) {
try { ortModule = await import('onnxruntime-node' as any); } catch { ortModule = null; }
}
if (!ortModule) return false;
const path = modelPath ?? process.env['EMBEDDING_MODEL_PATH'] ?? '';
if (!path) return false;
model = await ortModule.InferenceSession.create(path);
return true;
} catch {
model = null;
return false;
}
}
export async function embed(texts: string[]): Promise<number[][] | null> {
if (!model) return null;
try {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const ort: { Tensor: new (...args: unknown[]) => unknown } | null = ortModule || null;
if (!ort) return null;
const input = new ort.Tensor('string', texts, [texts.length]);
const feeds: Record<string, any> = {};
feeds[model.inputNames[0]] = input;
const results = await model.run(feeds);
const output = results[model.outputNames[0]];
if (!output || !output.data) return null;
const dim = output.dims?.[1] ?? 384;
const data = output.data as Float32Array;
const vectors: number[][] = [];
for (let i = 0; i < texts.length; i++) {
vectors.push(Array.from(data.slice(i * dim, (i + 1) * dim)));
}
return vectors;
} catch {
return null;
}
}

View File

@@ -0,0 +1,54 @@
export interface MemoryEntry {
id: string;
topic: string;
title: string;
content: string;
tags: string[];
}
export function parseMemoryEntries(fileName: string, markdown: string): MemoryEntry[] {
const entries: MemoryEntry[] = [];
const lines = markdown.split('\n');
let currentEntry: Partial<MemoryEntry> | null = null;
let currentContent: string[] = [];
for (const line of lines) {
const headingMatch = line.match(/^##\s+(.+):\s+(.+)$/);
if (headingMatch && headingMatch[1] && headingMatch[2]) {
if (currentEntry && currentEntry.title) {
entries.push({
id: `${fileName}-${entries.length}`,
topic: currentEntry.topic ?? '',
title: currentEntry.title,
content: currentContent.join('\n').trim(),
tags: currentEntry.tags ?? [],
});
}
currentEntry = { topic: headingMatch[1].trim(), title: headingMatch[2].trim(), tags: [] };
currentContent = [];
continue;
}
const tagsMatch = line.match(/^>\s*tags:\s*(.+)$/i);
if (tagsMatch && tagsMatch[1] && currentEntry) {
currentEntry.tags = tagsMatch[1].split(',').map((t) => t.trim());
continue;
}
if (currentEntry) {
currentContent.push(line);
}
}
if (currentEntry && currentEntry.title) {
entries.push({
id: `${fileName}-${entries.length}`,
topic: currentEntry.topic ?? '',
title: currentEntry.title,
content: currentContent.join('\n').trim(),
tags: currentEntry.tags ?? [],
});
}
return entries;
}

View File

@@ -0,0 +1,6 @@
export { loadMemoryForSession } from './recall.js';
export { formatMemoryBlock } from './prompt.js';
export { scanMemoryScopes } from './scan.js';
export { parseMemoryEntries } from './entries.js';
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
export type { MemoryEntry } from './entries.js';

View File

@@ -0,0 +1,17 @@
import { join } from 'node:path';
import { mkdir } from 'node:fs/promises';
const TOPICS = ['project', 'user', 'reference'] as const;
export type MemoryTopic = (typeof TOPICS)[number];
export function getMemoryRoot(projectRoot: string): string {
return join(projectRoot, '.boocode', 'memory');
}
export function getTopicDir(root: string, topic: MemoryTopic): string {
return join(root, topic);
}
export async function ensureMemoryScaffold(root: string): Promise<void> {
await Promise.all(TOPICS.map((t) => mkdir(join(root, t), { recursive: true })));
}

View File

@@ -0,0 +1,5 @@
export function formatMemoryBlock(entries: string[]): string {
if (entries.length === 0) return '';
const body = entries.map((e) => `- ${e}`).join('\n');
return `<boocode-memory>\n${body}\n</boocode-memory>`;
}

View File

@@ -0,0 +1,100 @@
import type { MemoryEntry } from './entries.js';
import { scanProjectMemory } from './scan.js';
import { Bm25Ranker } from './bm25.js';
import { embed, isEmbeddingAvailable } from './embeddings.js';
const SEARCH_MODE = process.env['MEMORY_SEARCH'] ?? 'hybrid';
function extractKeywords(query: string): string[] {
return query
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter((w) => w.length > 2);
}
export function rankByRelevance(query: string, entries: MemoryEntry[]): MemoryEntry[] {
const keywords = extractKeywords(query);
if (keywords.length === 0) return entries.slice(0, 5);
const scored = entries.map((entry) => {
let score = 0;
const searchText = `${entry.title} ${entry.content} ${entry.tags.join(' ')}`.toLowerCase();
for (const kw of keywords) {
if (entry.title.toLowerCase().includes(kw)) score += 3;
if (entry.tags.some((t) => t.toLowerCase().includes(kw))) score += 2;
if (entry.content.toLowerCase().includes(kw)) score += 1;
}
return { entry, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 10)
.map((s) => s.entry);
}
export async function rankByHybrid(
query: string,
entries: MemoryEntry[],
): Promise<MemoryEntry[]> {
if (entries.length === 0) return [];
const texts = entries.map((e) => `${e.title} ${e.content} ${e.tags.join(' ')}`);
const bm25 = new Bm25Ranker();
bm25.fit(texts);
const bm25Scores = texts.map((_, i) => bm25.score(query, i));
const maxBm25 = Math.max(...bm25Scores, 1);
const normBm25 = bm25Scores.map((s) => s / maxBm25);
let cosineScores: number[] = [];
if (isEmbeddingAvailable()) {
const vectors = await embed([query, ...texts]);
if (vectors) {
const queryVec = vectors[0]!;
cosineScores = texts.map((_, i) => {
const vec = vectors[i + 1];
if (!vec) return 0;
let dot = 0, nA = 0, nB = 0;
for (let j = 0; j < queryVec.length; j++) {
dot += queryVec[j]! * vec[j]!;
nA += queryVec[j]! * queryVec[j]!;
nB += vec[j]! * vec[j]!;
}
const denom = Math.sqrt(nA) * Math.sqrt(nB);
return denom === 0 ? 0 : dot / denom;
});
}
}
const scored = entries.map((entry, i) => {
const combined = (normBm25[i]! * 0.3) + ((cosineScores[i] ?? 0) * 0.7);
return { entry, score: combined };
});
return scored
.filter((s) => s.score >= 0.15)
.sort((a, b) => b.score - a.score)
.slice(0, 10)
.map((s) => s.entry);
}
export async function loadMemoryForSession(
projectRoot: string,
_sessionId?: string,
query?: string,
): Promise<string[]> {
const entries = await scanProjectMemory(projectRoot);
if (entries.length === 0) return [];
const relevant = query
? SEARCH_MODE === 'keyword'
? rankByRelevance(query, entries)
: await rankByHybrid(query, entries)
: entries.slice(0, 5);
return relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
}
export { initEmbeddings } from './embeddings.js';

View File

@@ -0,0 +1,72 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { readFile, readdir } from 'node:fs/promises';
import type { MemoryEntry } from './entries.js';
import { parseMemoryEntries } from './entries.js';
import { getMemoryRoot } from './paths.js';
export interface MemoryScope {
projectRoot: string;
sessionDir?: string;
homeDir?: string;
}
async function scanDirectory(dir: string): Promise<MemoryEntry[]> {
const entries: MemoryEntry[] = [];
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
if (file.isFile() && file.name.endsWith('.md')) {
const content = await readFile(join(dir, file.name), 'utf8');
entries.push(...parseMemoryEntries(file.name, content));
}
}
} catch {
// Directory doesn't exist
}
return entries;
}
const MEMORY_TOPICS = ['project', 'user', 'reference'] as const;
async function scanTopicDirs(root: string): Promise<MemoryEntry[]> {
const entries: MemoryEntry[] = [];
for (const topic of MEMORY_TOPICS) {
entries.push(...(await scanDirectory(join(root, topic))));
}
return entries;
}
export async function scanMemoryScopes(scope: MemoryScope): Promise<MemoryEntry[]> {
const allEntries: MemoryEntry[] = [];
// 1. Global (~/.boocode/memory/) - lowest priority
allEntries.push(...(await scanTopicDirs(getMemoryRoot(homedir()))));
// 2. Home ($HOME/.boocode/memory)
const homeDir = scope.homeDir ?? homedir();
const homeRoot = getMemoryRoot(homeDir);
if (homeRoot !== getMemoryRoot(homedir())) {
allEntries.push(...(await scanTopicDirs(homeRoot)));
}
// 3. Project (.boocode/memory/ under project root)
allEntries.push(...(await scanTopicDirs(getMemoryRoot(scope.projectRoot))));
// 4. Session (.boocode/sessions/<id>/memory.md) - highest priority
if (scope.sessionDir) {
try {
const sessionFile = join(scope.sessionDir, 'memory.md');
const content = await readFile(sessionFile, 'utf8');
allEntries.push(...parseMemoryEntries('session-memory', content));
} catch {
// No session memory file
}
}
return allEntries;
}
export async function scanProjectMemory(projectRoot: string): Promise<MemoryEntry[]> {
return scanMemoryScopes({ projectRoot });
}

View File

@@ -0,0 +1,35 @@
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { MemoryTopic } from './paths.js';
import { getTopicDir } from './paths.js';
export async function readTopicFiles(root: string, topic: MemoryTopic): Promise<Map<string, string>> {
const dir = getTopicDir(root, topic);
const files = new Map<string, string>();
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
const content = await readFile(join(dir, entry.name), 'utf8');
files.set(entry.name, content);
}
}
} catch {
// Directory doesn't exist yet
}
return files;
}
export async function writeEntry(
root: string,
topic: MemoryTopic,
title: string,
content: string,
tags: string[],
): Promise<void> {
const dir = getTopicDir(root, topic);
const tagLine = tags.length > 0 ? `> tags: ${tags.join(', ')}\n\n` : '\n';
const entry = `## ${topic}: ${title}\n${tagLine}${content}\n`;
const filename = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.md';
await writeFile(join(dir, filename), entry, 'utf8');
}

View File

@@ -35,6 +35,7 @@ export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([
'get_codebase_overview',
'get_framework_analysis',
'get_semantic_neighborhoods',
'get_blast_radius',
]);
const TOP_N_FILES = 5;

View File

@@ -22,6 +22,8 @@ import { readFile, stat } from 'node:fs/promises';
import type { Agent, Project, Session } from '../types/api.js';
import { getAgentsMtimes } from './agents.js';
import { resolveRoute } from './inference/provider.js';
import { loadMemoryForSession } from './memory/recall.js';
import { formatMemoryBlock } from './memory/prompt.js';
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
@@ -164,7 +166,11 @@ export async function buildSystemPromptWithFingerprint(
let out = BASE_SYSTEM_PROMPT(project.path);
const guidance = await getContainerGuidance();
if (guidance) {
out += `\n\n--- Container guidance ---\n${guidance}\n--- end container guidance ---\n`;
out += '\n\n--- Container guidance ---\n' + guidance + '\n--- end container guidance ---\n';
}
const memory = await loadMemoryForSession(project.path, session.id).catch(() => []);
if (memory.length > 0) {
out += '\n\n' + formatMemoryBlock(memory);
}
if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim();

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { makeCodecontextTool } from './factory.js';
export const GetCallGraphInput = z.object({
symbol: z.string().describe('Symbol name to analyze'),
depth: z.number().int().min(1).max(5).optional().describe('Max traversal depth (default 2)'),
});
export type GetCallGraphInputT = z.infer<typeof GetCallGraphInput>;
const DESCRIPTION =
'Returns a call graph for a function or method: callers, callees, and transitive references. ' +
'Use to understand how a symbol is invoked and what it depends on.';
const { toolDef: getCallGraph, execute: executeGetCallGraph } =
makeCodecontextTool<GetCallGraphInputT>({
name: 'get_call_graph',
schema: GetCallGraphInput,
description: DESCRIPTION,
jsonParameters: {
type: 'object',
properties: {
symbol: { type: 'string', description: 'Symbol name to analyze' },
depth: { type: 'number', description: 'Max traversal depth (default 2)' },
},
required: ['symbol'],
additionalProperties: false,
},
mapArgs: (input) => ({ symbol: input.symbol, depth: input.depth ?? 2 }),
});
export { getCallGraph, executeGetCallGraph };

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { makeCodecontextTool } from './factory.js';
export const GetSymbolDetailsInput = z.object({
symbol: z.string().describe('Symbol name to resolve'),
file_path: z.string().optional().describe('Optional file path to narrow search'),
});
export type GetSymbolDetailsInputT = z.infer<typeof GetSymbolDetailsInput>;
const DESCRIPTION =
'Returns type signature, definition location, and usage count for a named symbol. ' +
'Use after get_codebase_overview to dive deeper into specific functions, classes, or variables.';
const { toolDef: getSymbolDetails, execute: executeGetSymbolDetails } =
makeCodecontextTool<GetSymbolDetailsInputT>({
name: 'get_symbol_details',
schema: GetSymbolDetailsInput,
description: DESCRIPTION,
jsonParameters: {
type: 'object',
properties: {
symbol: { type: 'string', description: 'Symbol name to resolve' },
file_path: { type: 'string', description: 'Optional file path to narrow search' },
},
required: ['symbol'],
additionalProperties: false,
},
mapArgs: (input) => ({ symbol: input.symbol, file_path: input.file_path }),
});
export { getSymbolDetails, executeGetSymbolDetails };

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
import type { ToolDef } from '../tools/types.js';
import { ensureMemoryScaffold, getMemoryRoot } from '../memory/paths.js';
import { writeEntry } from '../memory/store.js';
const ExtractMemoryInput = z.object({
topic: z.enum(['project', 'user', 'reference']).describe('Memory topic category'),
title: z.string().min(1).max(200).describe('Entry title (will be normalized to filename)'),
content: z.string().min(1).describe('Memory content body'),
tags: z.array(z.string()).optional().describe('Optional tags for search'),
});
type InputT = z.infer<typeof ExtractMemoryInput>;
export const extractMemoryTool: ToolDef<InputT> = {
name: 'extract_memory',
description: 'Persist a memory entry to .boocode/memory/ for cross-session recall. Use for project conventions, user preferences, and architectural decisions.',
inputSchema: ExtractMemoryInput,
jsonSchema: {
type: 'function',
function: {
name: 'extract_memory',
description: 'Persist a memory entry for cross-session recall',
parameters: {
type: 'object',
properties: {
topic: { type: 'string', enum: ['project', 'user', 'reference'] },
title: { type: 'string', description: 'Entry title' },
content: { type: 'string', description: 'Memory content' },
tags: { type: 'array', items: { type: 'string' }, description: 'Search tags' },
},
required: ['topic', 'title', 'content'],
},
},
},
async execute(input: InputT, projectRoot: string): Promise<unknown> {
const root = getMemoryRoot(projectRoot);
await ensureMemoryScaffold(root);
await writeEntry(root, input.topic, input.title, input.content, input.tags ?? []);
return {
result: `Memory entry "${input.title}" saved to .boocode/memory/${input.topic}/`,
};
},
};

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
import type { ToolDef } from '../tools/types.js';
import { scanProjectMemory } from '../memory/scan.js';
import { rankByHybrid } from '../memory/recall.js';
const SearchMemoryInput = z.object({
query: z.string().min(1).describe('Search query to match against memory entries'),
});
type InputT = z.infer<typeof SearchMemoryInput>;
export const searchMemoryTool: ToolDef<InputT> = {
name: 'search_memory',
description: 'Search the .boocode/memory/ store for relevant entries. Returns ranked results matching the query. Use before asking about project conventions or preferences.',
inputSchema: SearchMemoryInput,
jsonSchema: {
type: 'function',
function: {
name: 'search_memory',
description: 'Search memory store for relevant entries',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
},
},
async execute(input: InputT, projectRoot: string): Promise<unknown> {
const entries = await scanProjectMemory(projectRoot);
if (entries.length === 0) return { result: 'No memory entries found.' };
const relevant = await rankByHybrid(input.query, entries);
if (relevant.length === 0) return { result: 'No matching memory entries.' };
const lines = relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
return { result: `Found ${relevant.length} entry(ies):\n${lines.join('\n')}` };
},
};

View File

@@ -7,6 +7,8 @@ import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session';
import { Settings } from '@/pages/Settings';
import { Analytics } from '@/pages/Analytics';
import { Results } from '@/pages/Results';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
@@ -95,6 +97,8 @@ function AppShell() {
<Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/results" element={<Results />} />
</Routes>
</main>
<MobileRightRailBackdrop />

View File

@@ -30,6 +30,10 @@ import type {
BattleShape,
ContestantShape,
CrossExaminationShape,
AnalyticsSummary,
SessionAnalyticsRow,
ContextWindowStats,
TokenBreakdownAgg,
} from './types';
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
@@ -159,12 +163,13 @@ export const api = {
request<{ files: string[] }>(`/api/projects/${id}/files`),
git: (id: string) =>
request<GitMeta>(`/api/projects/${id}/git`),
gitDiff: (id: string, mode: GitDiffMode | null) =>
request<GitDiffResult>(
mode !== null
? `/api/projects/${id}/git/diff?mode=${mode}`
: `/api/projects/${id}/git/diff`,
),
gitDiff: (id: string, mode: GitDiffMode | null, hideWhitespace?: boolean) => {
const params: string[] = [];
if (mode !== null) params.push(`mode=${mode}`);
if (hideWhitespace) params.push('whitespace=1');
const qs = params.length > 0 ? `?${params.join('&')}` : '';
return request<GitDiffResult>(`/api/projects/${id}/git/diff${qs}`);
},
gitStage: (id: string, files: string[]) =>
request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, {
method: 'POST',
@@ -185,6 +190,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ files }),
}),
writeFile: (id: string, filePath: string, content: string) =>
request<{ ok: boolean }>(`/api/projects/${id}/write_file`, {
method: 'POST',
body: JSON.stringify({ path: filePath, content }),
}),
},
sessions: {
@@ -590,6 +600,14 @@ export const api = {
costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'),
},
// token-analyzer-ui: analytics aggregate endpoints.
analytics: {
summary: () => request<AnalyticsSummary>('/api/coder/analytics/summary'),
sessions: () => request<{ sessions: SessionAnalyticsRow[] }>('/api/coder/analytics/sessions'),
context: () => request<ContextWindowStats>('/api/analytics/context'),
tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>

View File

@@ -627,3 +627,32 @@ export type WsFrame =
analysis_ready?: boolean;
cross_exam_id?: string;
};
// token-analyzer-ui: aggregate token/cost analytics types.
export interface AnalyticsSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
session_count: number;
}
export interface SessionAnalyticsRow {
session_id: string;
session_name: string;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
last_active_at: string | null;
}
export interface ContextWindowStats {
avg_ctx_used: number | null;
avg_ctx_max: number | null;
avg_utilization_pct: number | null;
message_count: number;
}
export interface TokenBreakdownAgg {
category: string;
total_tokens: number;
}

View File

@@ -0,0 +1,206 @@
import { useMemo, useRef, useEffect, useState } from 'react';
import { codeToHtml } from 'shiki';
import type { GitDiffFile } from '@/api/types';
import { parseDiff, buildSplitRows, reconstructNewContent, type SplitRow } from '@/utils/diff-layout';
import { inferLanguage } from '@/lib/attachments';
import { cn } from '@/lib/utils';
interface DiffSplitViewProps {
file: GitDiffFile;
wrapLines?: boolean;
}
/** Side-by-side split diff renderer. Left = deletions, right = additions. */
export function DiffSplitView({ file, wrapLines = false }: DiffSplitViewProps) {
// ── Edge cases (rendered before hooks) ──────────────────────────────────
if (file.is_binary) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>;
}
if (file.is_too_large) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>;
}
if (file.change_type === 'untracked' && !file.diff_body) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">Untracked file</p>;
}
if (!file.diff_body) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">No diff content</p>;
}
return <DiffSplitViewInner file={file} wrapLines={wrapLines} />;
}
/**
* Inner component — assumes file.diff_body is non-null.
* Separated so the early-return edge cases above don't violate rules of hooks.
*/
function DiffSplitViewInner({ file, wrapLines }: { file: GitDiffFile; wrapLines: boolean }) {
// ── Parse diff ───────────────────────────────────────────────────────────
const parsed = useMemo(() => parseDiff(file.diff_body!), [file.diff_body]);
const parsedFile = parsed[0];
const rows = useMemo(() => {
if (!parsedFile) return [] as SplitRow[];
return buildSplitRows(parsedFile);
}, [parsedFile]);
const newContent = useMemo(() => {
if (!parsedFile) return '';
return reconstructNewContent(parsedFile.hunks);
}, [parsedFile]);
// ── Syntax highlighting ──────────────────────────────────────────────────
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
const [highlighting, setHighlighting] = useState(false);
const highlightKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!newContent) return;
if (highlightKeyRef.current === newContent) return;
highlightKeyRef.current = newContent;
let cancelled = false;
setHighlighting(true);
setHighlightedLines(null);
const lang = inferLanguage(file.path) ?? 'plaintext';
void codeToHtml(newContent, { lang, theme: 'github-dark' })
.then((html) => {
if (cancelled) return;
const container = document.createElement('div');
// eslint-disable-next-line no-unsanitized/property
container.innerHTML = html;
const codeEl = container.querySelector('code');
if (codeEl) {
const lineSpans = codeEl.querySelectorAll('.line');
setHighlightedLines(Array.from(lineSpans, (span) => span.innerHTML));
} else {
setHighlightedLines(null);
}
})
.catch(() => {
if (!cancelled) setHighlightedLines(null);
})
.finally(() => {
if (!cancelled) setHighlighting(false);
});
return () => { cancelled = true; };
}, [newContent, file.path]);
// ── Build new-line-number → highlighted-HTML map ───────────────────────
// Walk the hunks counting only add/context lines (which form the new file)
// and map each 1-based new-line-number to its highlighted HTML string.
const newLineHtmlMap = useMemo(() => {
if (!highlightedLines || !parsedFile) return new Map<number, string>();
const map = new Map<number, string>();
let idx = 0;
for (const hunk of parsedFile.hunks) {
let newLineNo = hunk.newStart;
for (const line of hunk.lines) {
if (line.type === 'header') continue;
if (line.type === 'add' || line.type === 'context') {
if (idx < highlightedLines.length) {
map.set(newLineNo, highlightedLines[idx]!);
}
idx++;
newLineNo++;
}
}
}
return map;
}, [highlightedLines, parsedFile]);
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className={cn('text-[11px] font-mono overflow-x-auto', wrapLines && 'break-all')}>
{highlighting && (
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting</p>
)}
<table className="w-full border-collapse">
<colgroup>
<col className="w-[40px]" />
<col />
<col className="w-px" />
<col className="w-[40px]" />
<col />
</colgroup>
<tbody>
{rows.map((row, idx) => {
if (row.kind === 'header') {
return (
<tr key={`h-${idx}`} className="bg-muted/30">
<td
colSpan={5}
className="text-muted-foreground text-[11px] px-2 py-0.5 select-none"
>
{row.content}
</td>
</tr>
);
}
const left = row.left;
const right = row.right;
const leftBg = left?.type === 'remove' ? 'bg-red-950/30' : '';
const rightBg = right?.type === 'add' ? 'bg-green-950/30' : '';
const leftHtml = left?.lineNumber != null ? newLineHtmlMap.get(left.lineNumber) : undefined;
const rightHtml = right?.lineNumber != null ? newLineHtmlMap.get(right.lineNumber) : undefined;
return (
<tr key={`p-${idx}`} className="hover:bg-muted/10">
<td className={cn(leftBg, 'border-r border-border/20 align-top')}>
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
{left?.lineNumber ?? ''}
</span>
</td>
<td className={cn(leftBg, 'align-top')}>
<div
className={cn(
'pl-2 text-[11px]',
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
)}
>
{left ? (
leftHtml ? (
// eslint-disable-next-line no-unsanitized/property
<span dangerouslySetInnerHTML={{ __html: leftHtml }} />
) : (
<span>{left.content}</span>
)
) : null}
</div>
</td>
<td className="border-l border-border/30 w-px p-0" />
<td className={cn(rightBg, 'border-r border-border/20 align-top')}>
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
{right?.lineNumber ?? ''}
</span>
</td>
<td className={cn(rightBg, 'align-top')}>
<div
className={cn(
'pl-2 text-[11px]',
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
)}
>
{right ? (
rightHtml ? (
// eslint-disable-next-line no-unsanitized/property
<span dangerouslySetInnerHTML={{ __html: rightHtml }} />
) : (
<span>{right.content}</span>
)
) : null}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,8 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, Columns2, GitBranch, ListChevronsDownUp, ListChevronsUpDown, AlignJustify, Pilcrow, RefreshCw, Trash2, WrapText } from 'lucide-react';
import { codeToHtml } from 'shiki';
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
import { cn } from '@/lib/utils';
import { DiffSplitView } from './DiffSplitView';
import { InlineReviewGutterCell } from './InlineReviewGutterCell';
import { InlineReviewEditor } from './InlineReviewEditor';
import { InlineReviewThread } from './InlineReviewThread';
import { useDiffComments } from '@/stores/useDiffCommentStore';
interface WriteProps {
mutating: boolean;
@@ -18,12 +23,19 @@ interface Props extends WriteProps {
loading: boolean;
error: string | null;
mode: GitDiffMode;
sessionId?: string;
onSelectMode: (m: GitDiffMode) => void;
onRefresh: () => void;
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
modeSuggestion?: GitDiffMode | null;
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
pendingCount?: number;
layout: 'unified' | 'split';
wrapLines: boolean;
hideWhitespace: boolean;
onLayoutChange: (layout: 'unified' | 'split') => void;
onWrapLinesChange: (wrap: boolean) => void;
onHideWhitespaceChange: (hide: boolean) => void;
}
const CHANGE_TYPE_LABELS: Record<string, string> = {
@@ -99,6 +111,12 @@ function FileDiffRow({
onStage,
onUnstage,
onDiscardRequest,
layout,
wrapLines,
expanded,
onToggleExpand,
sessionId,
diffMode,
}: {
file: GitDiffFile;
uncommitted: boolean;
@@ -106,11 +124,21 @@ function FileDiffRow({
onStage: (path: string) => void;
onUnstage: (path: string) => void;
onDiscardRequest: (file: GitDiffFile) => void;
layout: 'unified' | 'split';
wrapLines: boolean;
expanded: boolean;
onToggleExpand: (path: string) => void;
sessionId?: string;
diffMode?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [html, setHtml] = useState<string | null>(null);
const [highlighting, setHighlighting] = useState(false);
const highlightRef = useRef<HTMLDivElement | null>(null);
const [showEditor, setShowEditor] = useState(false);
const commentKey = `${file.path}:${file.change_type}`;
const diffModeVal = diffMode ?? '';
const { comments, addComment, updateComment, deleteComment } = useDiffComments(sessionId ?? '', diffModeVal);
const fileComments = comments.get(commentKey) ?? [];
useEffect(() => {
if (!expanded || !file.diff_body) return;
@@ -136,13 +164,27 @@ function FileDiffRow({
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
const displayPath = file.old_path ? `${file.old_path}${file.path}` : file.path;
const handleAddComment = (body: string) => {
const comment = { id: crypto.randomUUID(), body, createdAt: Date.now(), updatedAt: Date.now() };
addComment(commentKey, comment);
setShowEditor(false);
};
const handleEditComment = (id: string, body: string) => {
updateComment(commentKey, id, body);
};
const handleDeleteComment = (id: string) => {
deleteComment(commentKey, id);
};
return (
<li className="border-b border-border/30 last:border-0">
<div className="flex items-center group">
<button
type="button"
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
onClick={() => setExpanded((p) => !p)}
onClick={() => onToggleExpand(file.path)}
aria-expanded={expanded}
>
{expanded
@@ -203,23 +245,54 @@ function FileDiffRow({
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked not yet staged</p>
)}
{!file.is_binary && !file.is_too_large && file.diff_body && (
<>
{highlighting && (
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting</p>
)}
{!highlighting && html !== null ? (
<div
ref={highlightRef}
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
layout === 'split' ? (
<DiffSplitView file={file} wrapLines={wrapLines} />
) : (
<>
{highlighting && (
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting</p>
)}
{!highlighting && html !== null ? (
<div
ref={highlightRef}
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
/>
) : (
!highlighting && (
<pre className={cn(
'text-[11px] overflow-x-auto rounded bg-muted/30 p-2',
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
)}>
{file.diff_body}
</pre>
)
)}
{/* Comment button */}
<div className="flex items-center gap-1 mt-1">
<button
type="button"
onClick={() => setShowEditor(!showEditor)}
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 px-1 py-0.5 rounded hover:bg-muted/40"
>
<span>{showEditor ? 'Cancel' : 'Comment'}</span>
</button>
<span className="text-[10px] text-muted-foreground/50">
{fileComments.length > 0 && `${fileComments.length} comment${fileComments.length > 1 ? 's' : ''}`}
</span>
</div>
{showEditor && (
<InlineReviewEditor
onSave={handleAddComment}
onCancel={() => setShowEditor(false)}
/>
)}
<InlineReviewThread
comments={fileComments}
onEditComment={handleEditComment}
onDeleteComment={handleDeleteComment}
/>
) : (
!highlighting && (
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre">
{file.diff_body}
</pre>
)
)}
</>
</>
)
)}
</div>
)}
@@ -242,11 +315,41 @@ export function GitDiffView({
onDiscard,
modeSuggestion,
pendingCount,
layout,
wrapLines,
hideWhitespace,
onLayoutChange,
onWrapLinesChange,
onHideWhitespaceChange,
sessionId,
}: Props) {
const [commitMessage, setCommitMessage] = useState('');
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
const [lastAction, setLastAction] = useState<string | null>(null);
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const allExpandedComputed = useMemo(
() => result !== null && (result.files?.length ?? 0) > 0 && result.files.every((f) => expandedFiles.has(f.path)),
[result, expandedFiles],
);
const handleExpandAllChange = useCallback((expand: boolean) => {
if (expand && result?.files) {
setExpandedFiles(new Set(result.files.map((f) => f.path)));
} else {
setExpandedFiles(new Set());
}
}, [result?.files]);
const handleToggleExpand = useCallback((path: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
function flashAction(msg: string) {
setLastAction(msg);
@@ -378,6 +481,83 @@ export function GitDiffView({
</button>
</div>
{/* Diff toolbar */}
<div className="flex items-center gap-1 px-2 py-1 border-b shrink-0">
<button
type="button"
onClick={() => onLayoutChange('unified')}
className={cn(
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
layout === 'unified'
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
title="Unified diff"
>
<AlignJustify size={12} />
Unified
</button>
<button
type="button"
onClick={() => onLayoutChange('split')}
className={cn(
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
layout === 'split'
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
title="Split diff"
>
<Columns2 size={12} />
Split
</button>
<button
type="button"
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
className={cn(
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
hideWhitespace
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
>
<Pilcrow size={12} />
</button>
<button
type="button"
onClick={() => onWrapLinesChange(!wrapLines)}
className={cn(
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
wrapLines
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
title={wrapLines ? 'Unwrap lines' : 'Wrap lines'}
>
<WrapText size={12} />
</button>
<div className="flex-1" />
<button
type="button"
onClick={() => handleExpandAllChange(!allExpandedComputed)}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
title={allExpandedComputed ? 'Collapse all' : 'Expand all'}
>
{allExpandedComputed ? <ListChevronsDownUp size={12} /> : <ListChevronsUpDown size={12} />}
</button>
<button
type="button"
onClick={onRefresh}
disabled={loading || mutating}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Refresh diff"
title="Refresh"
>
<RefreshCw size={12} />
</button>
</div>
{/* Committed-mode base label */}
{result.mode === 'committed' && base_label && (
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
@@ -445,6 +625,12 @@ export function GitDiffView({
onStage={handleStage}
onUnstage={handleUnstage}
onDiscardRequest={handleDiscardRequest}
layout={layout}
wrapLines={wrapLines}
expanded={expandedFiles.has(file.path)}
onToggleExpand={handleToggleExpand}
sessionId={sessionId}
diffMode={mode}
/>
))}
</ul>

View File

@@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface InlineReviewEditorProps {
initialBody?: string;
onSave: (body: string) => void;
onCancel: () => void;
}
export function InlineReviewEditor({ initialBody = '', onSave, onCancel }: InlineReviewEditorProps) {
const [text, setText] = useState(initialBody);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && text.trim()) {
onSave(text.trim());
}
},
[onCancel, onSave, text],
);
return (
<div className="mx-2 my-1 rounded border border-border/80 bg-popover p-2 shadow-sm">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a comment..."
rows={3}
className="w-full resize-none bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 outline-none"
/>
<div className="flex items-center justify-end gap-1.5 mt-1.5 border-t border-border/40 pt-1.5">
<button
type="button"
onClick={onCancel}
className="text-xs px-2 py-1 rounded hover:bg-muted text-muted-foreground"
>
Cancel
</button>
<button
type="button"
disabled={!text.trim()}
onClick={() => onSave(text.trim())}
className="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
>
Save
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { cn } from '@/lib/utils';
import { Plus } from 'lucide-react';
interface InlineReviewGutterCellProps {
lineNumber: number | null;
type: 'add' | 'remove' | 'context' | 'header' | null;
hasComments: boolean;
canComment: boolean;
onClick?: () => void;
}
export function InlineReviewGutterCell({
lineNumber,
type,
hasComments,
canComment,
onClick,
}: InlineReviewGutterCellProps) {
return (
<div
className={cn(
'relative flex items-center justify-end pr-1 min-w-[40px] h-5 text-[11px] font-mono select-none',
type === 'add' && 'bg-green-950/30',
type === 'remove' && 'bg-red-950/30',
type === 'context' && 'bg-muted/10',
canComment && 'cursor-pointer group',
)}
onClick={canComment ? onClick : undefined}
>
<span className="text-muted-foreground/70">
{lineNumber != null ? lineNumber : ''}
</span>
{canComment && (
<span className="absolute left-0.5 hidden group-hover:flex items-center justify-center w-4 h-4 rounded text-muted-foreground hover:text-foreground">
<Plus size={12} />
</span>
)}
{hasComments && (
<span className="absolute left-0.5 w-1.5 h-1.5 rounded-full bg-blue-400" />
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { MessageSquare, Pencil, Trash2 } from 'lucide-react';
import type { DiffComment } from '@/stores/useDiffCommentStore';
import { InlineReviewEditor } from './InlineReviewEditor';
interface InlineReviewThreadProps {
comments: DiffComment[];
onEditComment: (id: string, body: string) => void;
onDeleteComment: (id: string) => void;
}
export function InlineReviewThread({
comments,
onEditComment,
onDeleteComment,
}: InlineReviewThreadProps) {
const [expanded, setExpanded] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editBody, setEditBody] = useState('');
if (comments.length === 0) return null;
const handleStartEdit = (id: string, body: string) => {
setEditingId(id);
setEditBody(body);
};
const handleSaveEdit = (body: string) => {
if (editingId) {
onEditComment(editingId, body);
setEditingId(null);
}
};
const handleCancelEdit = () => {
setEditingId(null);
};
return (
<div className="ml-1 border-l-2 border-blue-400/40 pl-2 my-1">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground mb-0.5"
>
<MessageSquare size={10} />
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
<span className="text-[9px]">{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="space-y-1">
{comments.map((comment) => (
<div key={comment.id} className="text-xs">
{editingId === comment.id ? (
<InlineReviewEditor
initialBody={editBody}
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
/>
) : (
<div className="flex items-start gap-1 group">
<span className="flex-1 text-foreground/90 leading-relaxed whitespace-pre-wrap">
{comment.body}
</span>
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0 mt-0.5">
<button
type="button"
onClick={() => handleStartEdit(comment.id, comment.body)}
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
title="Edit"
>
<Pencil size={10} />
</button>
<button
type="button"
onClick={() => onDeleteComment(comment.id)}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
import { BarChart3, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import mascot from '@/assets/brand/banner-mascot.png';
@@ -519,11 +519,40 @@ export function ProjectSidebar() {
})}
</nav>
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
workspace settings pane via the sessionEvents bus (Session.tsx owns
the panesHook). Outside a session there's no workspace to mount the
pane in, so we navigate to /settings (themes page) instead. */}
<div className="border-t shrink-0 p-2">
{/* bottom-pinned nav buttons. Results → Analytics → Settings. */}
<div className="border-t shrink-0 p-2 space-y-0.5">
<NavLink
to="/results"
onClick={() => { if (isMobile) setDrawerOpen(false); }}
className={({ isActive }) =>
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
}`
}
aria-label="Results"
>
<ScrollText className="size-3.5 shrink-0 opacity-70" />
<span className="flex-1 text-left">Results</span>
</NavLink>
<NavLink
to="/analytics"
onClick={() => { if (isMobile) setDrawerOpen(false); }}
className={({ isActive }) =>
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
}`
}
aria-label="Token Analytics"
>
<BarChart3 className="size-3.5 shrink-0 opacity-70" />
<span className="flex-1 text-left">Token Analytics</span>
</NavLink>
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
workspace settings pane via the sessionEvents bus (Session.tsx owns
the panesHook). Outside a session there's no workspace to mount the
pane in, so we navigate to /settings (themes page) instead. */}
<button
type="button"
onClick={() => {

View File

@@ -8,6 +8,7 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { useProjectGit } from '@/hooks/useProjectGit';
import { useGitDiff } from '@/hooks/useGitDiff';
import { useDiffPreferences } from '@/hooks/useDiffPreferences';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { GitDiffView } from '@/components/GitDiffView';
import { Input } from '@/components/ui/input';
@@ -90,6 +91,15 @@ export function RightRail({ projectId, sessionId }: Props) {
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
// Diff toolbar state (integration with expandedPaths pending)
const { preferences: diffPrefs, updatePreferences: updateDiffPrefs } = useDiffPreferences();
// File editing state
const [editingFile, setEditingFile] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const openNewFile = useCallback(() => {
setNewFilePath('');
setNewFileContent('');
@@ -167,6 +177,44 @@ export function RightRail({ projectId, sessionId }: Props) {
});
}
async function startEdit(path: string) {
setEditingFile(path);
setEditLoading(true);
setEditError(null);
try {
const result = await api.projects.viewFile(projectId, path);
setEditContent(result.content);
} catch {
setEditError('Failed to load file');
setEditingFile(null);
} finally {
setEditLoading(false);
}
}
async function saveEdit() {
if (!editingFile) return;
try {
await api.projects.writeFile(projectId, editingFile, editContent);
setEditingFile(null);
setEditContent('');
sessionEvents.emit({ type: 'git_diff_refresh' });
} catch {
setEditError('Failed to save file');
}
}
function cancelEdit() {
setEditingFile(null);
setEditContent('');
setEditError(null);
}
// Cancel edit when switching tabs
useEffect(() => {
if (tab !== 'files') cancelEdit();
}, [tab]);
async function openFile(path: string) {
try {
const result = await api.projects.viewFile(projectId, path);
@@ -323,6 +371,30 @@ export function RightRail({ projectId, sessionId }: Props) {
) : (
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
)
) : editingFile ? (
<div className="flex flex-col flex-1 overflow-hidden p-2 gap-2">
<div className="text-xs font-mono truncate text-muted-foreground">{editingFile}</div>
{editLoading ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">Loading...</div>
) : (
<>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="flex-1 font-mono text-xs p-2 rounded border bg-background resize-none outline-none focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
/>
{editError && <p className="text-xs text-destructive">{editError}</p>}
<div className="flex items-center gap-2 justify-end">
<button type="button" onClick={cancelEdit} className="text-xs px-2 py-1 rounded border hover:bg-muted">Cancel</button>
<button type="button" onClick={saveEdit} className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90">Save</button>
</div>
</>
)}
</div>
) : (
<TreeLevel
parentPath=""
@@ -332,6 +404,7 @@ export function RightRail({ projectId, sessionId }: Props) {
depth={0}
onToggleDir={toggleDir}
onSelectFile={(path) => void openFile(path)}
onEditFile={startEdit}
/>
)}
</div>
@@ -345,6 +418,7 @@ export function RightRail({ projectId, sessionId }: Props) {
loading={gitLoading}
error={gitError}
mode={gitMode}
sessionId={sessionId}
onSelectMode={selectMode}
onRefresh={refreshDiff}
mutating={gitMutating}
@@ -355,6 +429,12 @@ export function RightRail({ projectId, sessionId }: Props) {
onDiscard={gitDiscard}
modeSuggestion={gitModeSuggestion}
pendingCount={pendingCount}
layout={diffPrefs.layout}
wrapLines={diffPrefs.wrapLines}
hideWhitespace={diffPrefs.hideWhitespace}
onLayoutChange={(layout) => updateDiffPrefs({ layout })}
onWrapLinesChange={(wrapLines) => updateDiffPrefs({ wrapLines })}
onHideWhitespaceChange={(hideWhitespace) => updateDiffPrefs({ hideWhitespace })}
/>
)}
</aside>
@@ -421,9 +501,10 @@ interface TreeLevelProps {
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
onEditFile?: (path: string) => void;
}
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) {
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile, onEditFile }: TreeLevelProps) {
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
@@ -447,6 +528,9 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
if (entry.kind === 'dir') onToggleDir(fullPath);
else onSelectFile(fullPath);
}}
onDoubleClick={() => {
if (entry.kind === 'file') onEditFile?.(fullPath);
}}
>
{entry.kind === 'dir' ? (
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
@@ -469,6 +553,7 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onEditFile={onEditFile}
/>
)}
</li>

View File

@@ -218,6 +218,16 @@ function ContestantRow({
{isExpanded && (
<div className="border-t border-border/50 bg-muted/10 max-h-[55vh] overflow-y-auto">
{data.token_breakdown && (
<div className="flex items-center gap-1.5 px-3 py-2 text-xs text-muted-foreground border-b border-border/30">
{data.token_breakdown.system > 0 && <span title="system">{data.token_breakdown.system}s</span>}
{data.token_breakdown.user > 0 && <span title="user">{data.token_breakdown.user}u</span>}
{data.token_breakdown.assistant > 0 && <span title="assistant">{data.token_breakdown.assistant}a</span>}
{data.token_breakdown.tools > 0 && <span title="tools">{data.token_breakdown.tools}t</span>}
{data.token_breakdown.reasoning > 0 && <span title="reasoning" className="text-amber-500">{data.token_breakdown.reasoning}r</span>}
{data.token_breakdown.total > 0 && <span className="font-medium tabular-nums ml-1">{data.token_breakdown.total}</span>}
</div>
)}
{output.length === 0 ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
{data.status === 'queued'

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from 'react';
export interface DiffPreferences {
layout: 'unified' | 'split';
wrapLines: boolean;
hideWhitespace: boolean;
}
const DEFAULT_PREFERENCES: DiffPreferences = {
layout: 'unified',
wrapLines: false,
hideWhitespace: false,
};
const STORAGE_KEY = 'boocode.diff.preferences';
function loadPreferences(): DiffPreferences {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<DiffPreferences>;
return {
layout: parsed.layout ?? DEFAULT_PREFERENCES.layout,
wrapLines: parsed.wrapLines ?? DEFAULT_PREFERENCES.wrapLines,
hideWhitespace: parsed.hideWhitespace ?? DEFAULT_PREFERENCES.hideWhitespace,
};
}
} catch {
// ignore parse errors
}
return DEFAULT_PREFERENCES;
}
function savePreferences(prefs: DiffPreferences): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// ignore storage errors
}
}
export function useDiffPreferences(): {
preferences: DiffPreferences;
updatePreferences: (updates: Partial<DiffPreferences>) => void;
resetPreferences: () => void;
} {
const [preferences, setPreferences] = useState<DiffPreferences>(loadPreferences);
// Sync from localStorage on mount (handles multi-tab changes if we add a storage listener later)
useEffect(() => {
setPreferences(loadPreferences());
}, []);
const updatePreferences = useCallback((updates: Partial<DiffPreferences>) => {
setPreferences((prev) => {
const next = { ...prev, ...updates };
savePreferences(next);
return next;
});
}, []);
const resetPreferences = useCallback(() => {
setPreferences(DEFAULT_PREFERENCES);
savePreferences(DEFAULT_PREFERENCES);
}, []);
return { preferences, updatePreferences, resetPreferences };
}

View File

@@ -3,7 +3,7 @@ import { api } from '@/api/client';
import type { GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
import { sessionEvents } from './sessionEvents';
export function useGitDiff(projectId: string | null | undefined) {
export function useGitDiff(projectId: string | null | undefined, hideWhitespace = false) {
const [mode, setMode] = useState<GitDiffMode>('uncommitted');
const [pinned, setPinned] = useState(false);
const [result, setResult] = useState<GitDiffResult | null>(null);
@@ -23,7 +23,7 @@ export function useGitDiff(projectId: string | null | undefined) {
// FIX 1: when not pinned, omit mode param so the server auto-selects based on
// dirty state (dirty → uncommitted, clean → committed).
api.projects
.gitDiff(projectId, pinned ? mode : null)
.gitDiff(projectId, pinned ? mode : null, hideWhitespace)
.then((r) => {
if (!pinned) {
setMode(r.mode);
@@ -43,7 +43,7 @@ export function useGitDiff(projectId: string | null | undefined) {
inFlightRef.current = false;
setLoading(false);
});
}, [projectId, mode, pinned]);
}, [projectId, mode, pinned, hideWhitespace]);
// Re-run refresh when mode changes (user pinned a new mode).
useEffect(() => {
@@ -52,7 +52,7 @@ export function useGitDiff(projectId: string | null | undefined) {
return;
}
refresh();
}, [projectId, mode]); // eslint-disable-line react-hooks/exhaustive-deps
}, [projectId, mode, hideWhitespace]); // eslint-disable-line react-hooks/exhaustive-deps
// Subscribe to git_diff_refresh events (tab open, message_complete, manual).
useEffect(() => {

View File

@@ -0,0 +1,454 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, BarChart3, Wifi, Wrench, Layers } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '@/api/client';
import type {
AnalyticsSummary,
SessionAnalyticsRow,
ToolCostStat,
ContextWindowStats,
TokenBreakdownAgg,
} from '@/api/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// --- Independent section data fetcher ---
// Each section manages its own loading/error/data state so one failure doesn't
// block the rest of the page.
function useFetch<T>(fetcher: () => Promise<T>): {
data: T | null;
loading: boolean;
error: string | null;
retry: () => void;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
function load() {
setLoading(true);
setError(null);
fetcher()
.then(setData)
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'failed to load data');
})
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return { data, loading, error, retry: load };
}
// --- Skeleton pulse placeholder ---
function SkeletonBar({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
}
// --- Number formatting ---
function formatNumber(n: number | null | undefined): string {
if (n == null) return '—';
return n.toLocaleString();
}
function formatCost(n: number | null | undefined): string {
if (n == null) return '—';
if (n < 0.001) return `$${(n * 1000).toFixed(2)}m`;
if (n < 0.01) return `$${n.toFixed(4)}`;
return `$${n.toFixed(3)}`;
}
function formatPct(n: number | null | undefined): string {
if (n == null) return '—';
return `${(n * 100).toFixed(1)}%`;
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// --- Summary Cards ---
function SummaryCards({ summary }: { summary: AnalyticsSummary }) {
const cards = [
{
label: 'Total Input Tokens',
value: formatNumber(summary.total_input_tokens),
icon: BarChart3,
color: 'text-blue-500',
},
{
label: 'Total Output Tokens',
value: formatNumber(summary.total_output_tokens),
icon: BarChart3,
color: 'text-green-500',
},
{
label: 'Total Cost',
value: formatCost(summary.total_cost),
icon: Wifi,
color: 'text-amber-500',
},
{
label: 'Sessions Tracked',
value: formatNumber(summary.session_count),
icon: Layers,
color: 'text-purple-500',
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((c) => (
<Card key={c.label} size="sm">
<CardContent className="flex items-start gap-3 pt-3">
<c.icon className={cn('size-5 shrink-0 mt-0.5', c.color)} />
<div className="min-w-0">
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}
function SummaryCardsSkeleton() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[0, 1, 2, 3].map((i) => (
<Card key={i} size="sm">
<CardContent className="pt-3">
<SkeletonBar className="h-5 w-20 mb-2" />
<SkeletonBar className="h-3 w-24" />
</CardContent>
</Card>
))}
</div>
);
}
// --- Section wrappers ---
function SectionCard({
title,
loading,
error,
onRetry,
children,
}: {
title: string;
loading: boolean;
error: string | null;
onRetry: () => void;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
<SkeletonBar className="h-4 w-full" />
<SkeletonBar className="h-4 w-3/4" />
<SkeletonBar className="h-4 w-1/2" />
</div>
) : error ? (
<div className="flex items-center gap-3 text-sm">
<span className="text-destructive">{error}</span>
<Button size="sm" variant="outline" onClick={onRetry}>
Retry
</Button>
</div>
) : (
children
)}
</CardContent>
</Card>
);
}
function EmptyState({ message }: { message: string }) {
return <p className="text-sm text-muted-foreground py-2">{message}</p>;
}
// --- Per-Session Token Table ---
function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) {
if (sessions.length === 0) {
return <EmptyState message="No session token data available yet. Token data is collected as agent sessions run." />;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
<th className="py-2 pr-4 font-medium">Session</th>
<th className="py-2 pr-4 font-medium tabular-nums text-right">Input</th>
<th className="py-2 pr-4 font-medium tabular-nums text-right">Output</th>
<th className="py-2 pr-4 font-medium tabular-nums text-right">Cost</th>
<th className="py-2 font-medium tabular-nums text-right">Last Active</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30">
<td className="py-2 pr-4 truncate max-w-[200px]" title={s.session_name}>
{s.session_name || 'Untitled'}
</td>
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_input_tokens)}</td>
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_output_tokens)}</td>
<td className="py-2 pr-4 tabular-nums text-right">{formatCost(s.total_cost)}</td>
<td className="py-2 tabular-nums text-right text-muted-foreground">{formatDate(s.last_active_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// --- Per-Tool Cost Table ---
function ToolTable({ stats }: { stats: ToolCostStat[] }) {
if (stats.length === 0) {
return <EmptyState message="No tool cost data available yet. Stats accumulate after tool calls are made." />;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
<th className="py-2 pr-4 font-medium">Tool</th>
<th className="py-2 pr-4 font-medium tabular-nums text-right">Calls</th>
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Prompt</th>
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Completion</th>
<th className="py-2 font-medium tabular-nums text-right">Avg Total</th>
</tr>
</thead>
<tbody>
{stats.map((t) => (
<tr key={t.tool_name} className="border-b last:border-0 hover:bg-muted/30">
<td className="py-2 pr-4 flex items-center gap-2">
<Wrench className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[200px]" title={t.tool_name}>{t.tool_name}</span>
</td>
<td className="py-2 pr-4 tabular-nums text-right">{t.n_calls}</td>
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens)}</td>
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_completion_tokens)}</td>
<td className="py-2 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens + t.mean_completion_tokens)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// --- Context Window Utilization ---
function ContextSection({ stats }: { stats: ContextWindowStats }) {
if (stats.message_count === 0) {
return <EmptyState message="No context window data available yet. Data is captured during inference." />;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<div className="text-xs text-muted-foreground">Avg Context Used</div>
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_used ?? 0))}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Avg Context Limit</div>
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_max ?? 0))}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Avg Utilization</div>
<div className="text-lg font-semibold tabular-nums mt-1">{formatPct(stats.avg_utilization_pct)}</div>
</div>
<div className="sm:col-span-3">
<div className="text-xs text-muted-foreground mb-1">Based on {formatNumber(stats.message_count)} completed assistant messages</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: stats.avg_utilization_pct != null
? `${Math.min(stats.avg_utilization_pct * 100, 100)}%`
: '0%',
}}
/>
</div>
</div>
</div>
);
}
// --- Token Category Breakdown (CSS stacked bar) ---
const CATEGORY_COLORS: Record<string, string> = {
system: 'bg-blue-500',
user: 'bg-green-500',
assistant: 'bg-amber-500',
tools: 'bg-purple-500',
reasoning: 'bg-rose-500',
};
const CATEGORY_LABELS: Record<string, string> = {
system: 'System',
user: 'User',
assistant: 'Assistant',
tools: 'Tools',
reasoning: 'Reasoning',
};
function TokenBreakdownSection({ categories }: { categories: TokenBreakdownAgg[] }) {
if (categories.length === 0) {
return <EmptyState message="No token breakdown data available. Breakdown is captured for arena contestants and certain task types." />;
}
const total = categories.reduce((sum, c) => sum + c.total_tokens, 0);
if (total === 0) return <EmptyState message="Token breakdown totals are zero." />;
// Sort in a consistent order
const order = ['system', 'user', 'assistant', 'tools', 'reasoning'];
const sorted = [...categories].sort(
(a, b) => order.indexOf(a.category) - order.indexOf(b.category),
);
return (
<div className="space-y-3">
<div className="h-4 rounded-full bg-muted overflow-hidden flex">
{sorted.map((c) => {
const pct = (c.total_tokens / total) * 100;
if (pct < 1) return null;
return (
<div
key={c.category}
className={cn('h-full first:rounded-l-full last:rounded-r-full', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')}
style={{ width: `${pct}%` }}
title={`${CATEGORY_LABELS[c.category] ?? c.category}: ${formatNumber(c.total_tokens)} (${pct.toFixed(1)}%)`}
/>
);
})}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
{sorted.map((c) => {
const pct = (c.total_tokens / total) * 100;
return (
<div key={c.category} className="flex items-center gap-1.5">
<span className={cn('size-2.5 rounded-sm', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')} />
<span className="text-muted-foreground">{CATEGORY_LABELS[c.category] ?? c.category}</span>
<span className="font-medium tabular-nums">{pct.toFixed(1)}%</span>
<span className="text-muted-foreground tabular-nums">({formatNumber(c.total_tokens)})</span>
</div>
);
})}
</div>
</div>
);
}
// --- Main Page ---
export function Analytics() {
const navigate = useNavigate();
const summary = useFetch(() => api.analytics.summary());
const sessions = useFetch(() => api.analytics.sessions().then((r) => r.sessions));
const tools = useFetch(() => api.tools.costStats().then((r) => r.stats));
const context = useFetch(() => api.analytics.context());
const breakdown = useFetch(() => api.analytics.tokenBreakdown().then((r) => r.categories));
function handleBack() {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
}
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
<header className="space-y-2">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
aria-label="Back"
>
<ArrowLeft className="size-4" />
<span>Back</span>
</button>
<div>
<h1 className="text-xl font-semibold">Token Analytics</h1>
<p className="text-sm text-muted-foreground mt-1">
Aggregate token usage, cost, and context window data across all sessions.
</p>
</div>
</header>
{/* Summary Cards */}
{summary.loading ? (
<SummaryCardsSkeleton />
) : summary.error ? (
<div className="flex items-center gap-3 text-sm">
<span className="text-destructive">{summary.error}</span>
<Button size="sm" variant="outline" onClick={summary.retry}>
Retry
</Button>
</div>
) : summary.data ? (
<SummaryCards summary={summary.data} />
) : null}
{/* Per-Session Token Breakdown */}
<SectionCard
title="Per-Session Token Usage"
loading={sessions.loading}
error={sessions.error}
onRetry={sessions.retry}
>
{sessions.data && <SessionTable sessions={sessions.data} />}
</SectionCard>
{/* Per-Tool Cost Breakdown */}
<SectionCard
title="Per-Tool Token Cost"
loading={tools.loading}
error={tools.error}
onRetry={tools.retry}
>
{tools.data && <ToolTable stats={tools.data} />}
</SectionCard>
{/* Context Window Utilization */}
<SectionCard
title="Context Window Utilization"
loading={context.loading}
error={context.error}
onRetry={context.retry}
>
{context.data && <ContextSection stats={context.data} />}
</SectionCard>
{/* Token Category Breakdown */}
<SectionCard
title="Token Breakdown by Category"
loading={breakdown.loading}
error={breakdown.error}
onRetry={breakdown.retry}
>
{breakdown.data && <TokenBreakdownSection categories={breakdown.data} />}
</SectionCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,510 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Beaker, CheckCircle2, FileText, ScrollText, Swords, XCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '@/api/client';
import type { BattleShape, FlowRunRow } from '@/api/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/lib/utils';
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
function useFetch<T>(fetcher: () => Promise<T>): {
data: T | null;
loading: boolean;
error: string | null;
retry: () => void;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
function load() {
setLoading(true);
setError(null);
fetcher()
.then(setData)
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'failed to load data');
})
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return { data, loading, error, retry: load };
}
// ─── Skeleton ────────────────────────────────────────────────────────────────
function SkeletonBar({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
}
// ─── Formatters ──────────────────────────────────────────────────────────────
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatDuration(startIso: string, endIso?: string | null): string {
const start = new Date(startIso).getTime();
const end = endIso ? new Date(endIso).getTime() : Date.now();
const ms = end - start;
if (ms < 0) return '—';
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
}
function truncate(str: string, max: number): string {
if (str.length <= max) return str;
return str.slice(0, max) + '…';
}
// ─── Status dot (shared visual language with OrchestratorPane/ArenaPane) ──────
type DotStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'pending';
function StatusDot({ status }: { status: DotStatus }) {
if (status === 'running') {
return (
<span
aria-label="running"
className="inline-block w-2.5 h-2.5 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
/>
);
}
const cls =
status === 'completed'
? 'bg-emerald-500'
: status === 'failed'
? 'bg-destructive'
: status === 'cancelled'
? 'bg-muted-foreground/20'
: 'bg-muted-foreground/40'; // pending
return <span aria-label={status} className={cn('inline-block w-2 h-2 rounded-full shrink-0', cls)} />;
}
// ─── Tab bar ─────────────────────────────────────────────────────────────────
type TabId = 'runs' | 'battles';
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
return (
<div className="flex gap-1 border-b pb-px">
{[
{ id: 'runs' as TabId, label: 'Analysis Runs', icon: FileText },
{ id: 'battles' as TabId, label: 'Arena Battles', icon: Swords },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
active === tab.id
? 'bg-background border-border text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
)}
>
<tab.icon className="size-3.5" />
<span>{tab.label}</span>
</button>
))}
</div>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyState({ message }: { message: string }) {
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
}
// ─── Project selector ────────────────────────────────────────────────────────
function ProjectSelector({
projects,
value,
onChange,
}: {
projects: Array<{ id: string; name: string }>;
value: string;
onChange: (id: string) => void;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="text-sm bg-muted/30 border border-border rounded px-2 py-1 text-foreground"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
);
}
// ─── Analysis Runs tab ───────────────────────────────────────────────────────
function AnalysisRunsTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.runs.list(projectId).then((r) => r.runs));
const [selectedRun, setSelectedRun] = useState<FlowRunRow | null>(null);
if (loading) {
return (
<div className="space-y-2 pt-4">
{[0, 1, 2, 3].map((i) => (
<SkeletonBar key={i} className="h-12 w-full" />
))}
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-3 text-sm pt-4">
<span className="text-destructive">{error}</span>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
);
}
if (!data || data.length === 0) {
return <EmptyState message="No analysis runs yet. Start one from the Workflow button in any chat." />;
}
return (
<div className="pt-4 space-y-2">
{data.map((run) => (
<div key={run.id}>
<button
type="button"
onClick={() => setSelectedRun(selectedRun?.id === run.id ? null : run)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
selectedRun?.id === run.id && 'bg-muted/40',
)}
>
<StatusDot status={run.status as DotStatus} />
<span className="font-medium min-w-0 flex-1 truncate">
{run.flow_name}
<span className="text-muted-foreground font-normal ml-1.5 text-xs uppercase">
{run.band}
</span>
</span>
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{run.model ? run.model.split('/').pop() : '—'}
</span>
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{formatDuration(run.created_at, run.updated_at)}
</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(run.created_at)}
</span>
{run.error && (
<span className="text-destructive" title={run.error}>
<XCircle className="size-3.5" />
</span>
)}
{run.status === 'completed' && run.report && (
<FileText className="size-3.5 text-muted-foreground shrink-0" />
)}
</button>
{/* Expanded detail — report preview */}
{selectedRun?.id === run.id && run.status === 'completed' && run.report && (
<div className="ml-8 mr-2 mb-2 p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
{truncate(run.report, 3000)}
</div>
)}
</div>
))}
</div>
);
}
// ─── Arena Battles tab ───────────────────────────────────────────────────────
function ArenaBattlesTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.battles.list(projectId).then((r) => r.battles));
const [selectedBattle, setSelectedBattle] = useState<BattleShape | null>(null);
if (loading) {
return (
<div className="space-y-2 pt-4">
{[0, 1, 2, 3].map((i) => (
<SkeletonBar key={i} className="h-12 w-full" />
))}
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-3 text-sm pt-4">
<span className="text-destructive">{error}</span>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
);
}
if (!data || data.length === 0) {
return <EmptyState message="No arena battles yet. Start one from the Arena button in any chat." />;
}
return (
<div className="pt-4 space-y-2">
{data.map((battle) => {
const hasAnalysis = battle.status === 'completed' && battle.results_path;
return (
<div key={battle.id}>
<button
type="button"
onClick={() => setSelectedBattle(selectedBattle?.id === battle.id ? null : battle)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
selectedBattle?.id === battle.id && 'bg-muted/40',
)}
>
<StatusDot status={
battle.status === 'completed' ? 'completed'
: battle.status === 'failed' ? 'failed'
: battle.status === 'cancelled' ? 'cancelled'
: 'running'
} />
<span className="font-medium min-w-0 flex-1 truncate">
{battle.battle_type === 'coding' ? 'Coding Battle' : 'Q&A Battle'}
<span className="text-muted-foreground font-normal ml-1.5 text-xs">
{truncate(battle.prompt, 60)}
</span>
</span>
{battle.winner_contestant_id && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="size-3" />
Winner
</span>
)}
{battle.error && (
<span className="text-destructive" title={battle.error}>
<XCircle className="size-3.5" />
</span>
)}
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:block">
{formatDate(battle.created_at)}
</span>
{hasAnalysis && (
<Beaker className="size-3.5 text-muted-foreground shrink-0" />
)}
</button>
{/* Expanded detail — analysis preview */}
{selectedBattle?.id === battle.id && hasAnalysis && (
<div className="ml-8 mr-2 mb-2">
<AnalysisPreview battleId={battle.id} />
</div>
)}
</div>
);
})}
</div>
);
}
// ─── Battle analysis preview (fetches analysis.md on expand) ─────────────────
function AnalysisPreview({ battleId }: { battleId: string }) {
const { data, loading, error, retry } = useFetch(() => api.battles.getAnalysis(battleId).then((r) => r.text));
if (loading) {
return (
<div className="space-y-2 p-3 rounded-md bg-muted/20 border border-border/50">
<SkeletonBar className="h-3 w-full" />
<SkeletonBar className="h-3 w-3/4" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-3 p-3 rounded-md bg-muted/20 border border-border/50 text-xs">
<span className="text-destructive">{error}</span>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
);
}
return (
<div className="p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
{data ? truncate(data, 3000) : 'No analysis available.'}
</div>
);
}
// ─── Summary strip ───────────────────────────────────────────────────────────
function SummaryCards({
runs,
battles,
}: {
runs: FlowRunRow[] | null;
battles: BattleShape[] | null;
}) {
const totalRuns = runs?.length ?? 0;
const completedRuns = runs?.filter((r) => r.status === 'completed').length ?? 0;
const totalBattles = battles?.length ?? 0;
const completedBattles = battles?.filter((b) => b.status === 'completed').length ?? 0;
const cards = [
{ label: 'Total Runs', value: totalRuns, icon: FileText, color: 'text-blue-500' },
{ label: 'Completed Runs', value: completedRuns, icon: CheckCircle2, color: 'text-emerald-500' },
{ label: 'Total Battles', value: totalBattles, icon: Swords, color: 'text-violet-500' },
{ label: 'Completed Battles', value: completedBattles, icon: CheckCircle2, color: 'text-emerald-500' },
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{cards.map((c) => (
<Card key={c.label} size="sm">
<CardContent className="flex items-start gap-3 pt-3">
<c.icon className={cn('size-4 shrink-0 mt-0.5', c.color)} />
<div className="min-w-0">
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}
function SummaryCardsSkeleton() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[0, 1, 2, 3].map((i) => (
<Card key={i} size="sm">
<CardContent className="pt-3">
<SkeletonBar className="h-5 w-16 mb-2" />
<SkeletonBar className="h-3 w-20" />
</CardContent>
</Card>
))}
</div>
);
}
// ─── Main Page ───────────────────────────────────────────────────────────────
export function Results() {
const navigate = useNavigate();
const { data: sidebar, activeSession } = useSidebar();
const [tab, setTab] = useState<TabId>('runs');
const [projectId, setProjectId] = useState<string | null>(null);
// Derive default project from active session or first project.
const projects = useMemo(() => {
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
}, [sidebar]);
useEffect(() => {
if (!projectId && projects.length > 0) {
// Prefer active session's project, else first project.
const defaultId = activeSession?.project_id ?? projects[0]!.id;
setProjectId(defaultId);
}
}, [projects, activeSession, projectId]);
function handleBack() {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
}
const runsFetch = useFetch(
projectId ? () => api.runs.list(projectId).then((r) => r.runs) : () => Promise.resolve([] as FlowRunRow[]),
);
const battlesFetch = useFetch(
projectId ? () => api.battles.list(projectId).then((r) => r.battles) : () => Promise.resolve([] as BattleShape[]),
);
const summaryLoading = runsFetch.loading && battlesFetch.loading;
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
{/* Header */}
<header className="space-y-2">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
aria-label="Back"
>
<ArrowLeft className="size-4" />
<span>Back</span>
</button>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<ScrollText className="size-5" />
Results
</h1>
<p className="text-sm text-muted-foreground mt-1">
Completed orchestrator runs and arena battles.
</p>
</div>
{projects.length > 0 && projectId && (
<ProjectSelector
projects={projects}
value={projectId}
onChange={setProjectId}
/>
)}
</div>
</header>
{/* Summary Cards */}
{summaryLoading ? (
<SummaryCardsSkeleton />
) : (
<SummaryCards runs={runsFetch.data} battles={battlesFetch.data} />
)}
{/* Tab bar */}
<TabBar active={tab} onChange={setTab} />
{/* Tab content */}
{!projectId ? (
<EmptyState message="Select a project to view results." />
) : tab === 'runs' ? (
<AnalysisRunsTab projectId={projectId} />
) : (
<ArenaBattlesTab projectId={projectId} />
)}
</div>
</div>
);
}

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