Compare commits

...

28 Commits

Author SHA1 Message Date
876c9bcd02 feat(coder,server): audit engine — session audit, guideline compliance, user correction tracking
Implements audit-harness-inspired session lifecycle: audit session
creation/end/recover/report-daily with JSONL buffer and graded context
recovery (L0-L4). Guideline service for behavioral compliance rules
(condition/action model with criticality). Correction service for
persistent user correction tracking across agent sessions.

8 supporting skills: audit-start/end/report-daily/recover + command
variants for slash-command integration.
2026-06-07 22:16:35 +00:00
c132215064 feat(web,server): inference settings UI with per-session inference overrides
Adds Inference tab to SettingsPane with controls for temperature, top-p,
top-k, min-p, and other inference parameters. Server-side route and
provider config wiring to pass overrides through the inference pipeline.
2026-06-07 22:16:29 +00:00
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
244 changed files with 13875 additions and 158 deletions

View File

@@ -2,7 +2,29 @@
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. 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.7.20-arena-pane — 2026-06-06 ## 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). 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).

View File

@@ -1,9 +1,9 @@
# Current focus # 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` - **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. 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/`. - **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`). - **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'; } from './planning.js';
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js'; import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
import { codeReview } from './code-review.js'; import { codeReview } from './code-review.js';
import { parallelResearch } from './parallel-research.js';
const spines: Spine[] = [ const spines: Spine[] = [
// analysis / research // analysis / research
@@ -53,7 +54,7 @@ const spines: Spine[] = [
stakeholderSummary, stakeholderSummary,
]; ];
const bespoke: Flow[] = [codeReview]; const bespoke: Flow[] = [codeReview, parallelResearch];
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke]; 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; 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 { export interface Step {
/** unique id within the flow; other steps depend on it by this id */ /** unique id within the flow; other steps depend on it by this id */
@@ -46,6 +48,8 @@ export interface Step {
kind: StepKind; kind: StepKind;
/** ids that must complete (or skip) before this step runs */ /** ids that must complete (or skip) before this step runs */
deps?: string[]; 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) */ /** for kind:'agent' — the persona file name under conductor/agents (no .md) */
agent?: string; agent?: string;
/** /**

View File

@@ -28,6 +28,7 @@ import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js'; import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js'; import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerLifecycleRoutes } from './routes/lifecycle.js'; import { registerLifecycleRoutes } from './routes/lifecycle.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe // Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js'; import { createDispatcher } from './services/dispatcher.js';
@@ -382,6 +383,7 @@ async function main() {
registerProviderRoutes(app, sql, config); registerProviderRoutes(app, sql, config);
registerWorktreeSafetyRoutes(app, sql); registerWorktreeSafetyRoutes(app, sql);
registerLifecycleRoutes(app, sql); registerLifecycleRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);
// Graceful shutdown // Graceful shutdown

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` const contestants = await sql`
SELECT id, battle_id, identity, model, lane, task_id, worktree_id, 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 created_at, updated_at
FROM contestants FROM contestants
WHERE battle_id = ${id} WHERE battle_id = ${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. -- Cross-examination listing per battle.
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id); 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.durationMs).toBe(0);
expect(bench.tokensPerSec).toBeNull(); 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 ──────────────────────────────────────────────────────────── // ─── sanitizeSlug ────────────────────────────────────────────────────────────

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

View File

@@ -0,0 +1,747 @@
import { mkdir, readFile, writeFile, readdir, rm, appendFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
export const RUNS_REL = '.boo/runs';
export const DAILY_REL = '.boo/runs/daily';
export const GUIDELINES_REL = '.boo/guidelines';
export interface SessionJson {
session_id: string;
task: string;
start_time: string;
end_time?: string;
status: 'in_progress' | 'completed';
expected_record_types: string[];
}
export interface AuditTrailEntry {
timestamp: string;
record_type: string;
action_type: string;
tool?: string;
files?: string[];
detail?: string;
input?: string;
output?: string;
}
export interface IndexEntry {
id: string;
task: string;
status: string;
record_count: number;
start_time: string;
max_anomaly_level?: string;
}
export interface IndexJson {
entries: IndexEntry[];
}
export interface StartSessionResult {
sessionId: string;
contextSummary: {
recentActivity: IndexEntry[];
userCorrections: UserCorrectionRecord[];
unfinishedSessions: SessionJson[];
};
}
export interface EndSessionResult {
sessionId: string;
integrity: IntegrityCheck[];
correctionCount: number;
summaryPath: string;
}
export interface IntegrityCheck {
check: string;
passed: boolean;
detail?: string;
}
export interface RecoverResult {
level: number;
sessionId?: string;
task?: string;
recentActivity: IndexEntry[];
lastTrailEntries: AuditTrailEntry[];
userCorrections: UserCorrectionRecord[];
conclusions: string[];
dailyAnomalies: string[];
dailyBacklog: string[];
fullTrail?: AuditTrailEntry[];
anomalies?: string[];
}
export interface DailyReport {
date: string;
sections: {
taskOverview: string;
operationStats: { label: string; count: number }[];
changes: { time: string; target: string; detail: string }[];
userFeedback: { feedback: string; resolution: string; persistedTo: string }[];
anomalyAlerts: string[];
backlogTracking: string[];
integritySummary: string;
};
path: string;
}
export interface UserCorrectionRecord {
record_type: 'conversation';
action_type: 'user_correction';
priority: 'critical_for_recovery';
timestamp: string;
original_claim: string;
correction: string;
principle_extracted: string;
persisted_to: string[];
}
function runsDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), RUNS_REL);
}
function dailyDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), DAILY_REL);
}
function sessionDir(sessionId: string, basePath?: string): string {
return join(runsDir(basePath), sessionId);
}
function currentSessionPath(basePath?: string): string {
return join(runsDir(basePath), '.current_session');
}
function indexJsonPath(basePath?: string): string {
return join(runsDir(basePath), 'index.json');
}
function auditBufferPath(basePath?: string): string {
return join(runsDir(basePath), 'audit_buffer.jsonl');
}
function auditPendingPath(basePath?: string): string {
return join(runsDir(basePath), 'audit_pending.jsonl');
}
function trailPath(sessionId: string, basePath?: string): string {
return join(sessionDir(sessionId, basePath), 'audit_trail.jsonl');
}
function sessionJsonPath(sessionId: string, basePath?: string): string {
return join(sessionDir(sessionId, basePath), 'session.json');
}
function summaryPath(sessionId: string, basePath?: string): string {
return join(sessionDir(sessionId, basePath), 'session_summary.md');
}
export function generateSessionId(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
return `adhoc_${y}${m}${d}_${hh}${mm}`;
}
function isoNow(): string {
return new Date().toISOString();
}
function isoDate(d?: Date): string {
const dt = d ?? new Date();
return `${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}`;
}
function isTodayIso(iso: string): boolean {
return iso.startsWith(new Date().toISOString().slice(0, 10));
}
function tryParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function ensureDir(p: string): Promise<void> {
if (!existsSync(p)) {
await mkdir(p, { recursive: true });
}
}
async function readLines(p: string): Promise<string[]> {
try {
const content = await readFile(p, 'utf-8');
return content.split('\n').filter(Boolean);
} catch {
return [];
}
}
async function readJsonFile<T>(p: string): Promise<T | null> {
try {
const raw = await readFile(p, 'utf-8');
return tryParseJson<T>(raw);
} catch {
return null;
}
}
function appendLine(p: string, line: string): Promise<void> {
return appendFile(p, line + '\n', 'utf-8');
}
async function clearFile(p: string): Promise<void> {
try {
await writeFile(p, '', 'utf-8');
} catch {
// File may not exist
}
}
export async function getCurrentSession(basePath?: string): Promise<string | null> {
try {
const raw = await readFile(currentSessionPath(basePath), 'utf-8');
return raw.trim();
} catch {
return null;
}
}
export async function getSessionJson(sessionId: string, basePath?: string): Promise<SessionJson | null> {
return readJsonFile<SessionJson>(sessionJsonPath(sessionId, basePath));
}
export async function getIndex(basePath?: string): Promise<IndexJson | null> {
return readJsonFile<IndexJson>(indexJsonPath(basePath));
}
async function writeIndex(entries: IndexEntry[], basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await writeFile(indexJsonPath(basePath), JSON.stringify({ entries }, null, 2), 'utf-8');
}
async function appendIndex(sessionId: string, task: string, basePath?: string): Promise<void> {
const existing = await getIndex(basePath);
const entry: IndexEntry = {
id: sessionId,
task,
status: 'in_progress',
record_count: 0,
start_time: isoNow(),
};
const entries = [entry, ...(existing?.entries ?? [])].slice(0, 100);
await writeIndex(entries, basePath);
}
async function updateIndexStatus(sessionId: string, status: string, basePath?: string): Promise<void> {
const idx = await getIndex(basePath);
if (!idx) return;
for (const e of idx.entries) {
if (e.id === sessionId) {
e.status = status;
}
}
await writeIndex(idx.entries, basePath);
}
export async function startSession(task: string, basePath?: string): Promise<StartSessionResult> {
const sessionId = generateSessionId();
const sDir = sessionDir(sessionId, basePath);
await ensureDir(sDir);
const session: SessionJson = {
session_id: sessionId,
task,
start_time: isoNow(),
status: 'in_progress',
expected_record_types: ['data', 'change', 'conversation'],
};
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
await writeFile(currentSessionPath(basePath), sessionId, 'utf-8');
await appendIndex(sessionId, task, basePath);
// L0 context recovery
const idx = await getIndex(basePath);
const recentActivity = idx?.entries.slice(0, 5) ?? [];
// L2 user correction scan
const allCorrections = await scanAllTrailsForCorrections(basePath);
// Check for unfinished sessions
const unfinishedSessions = await findUnfinishedSessions(basePath);
return {
sessionId,
contextSummary: {
recentActivity,
userCorrections: allCorrections,
unfinishedSessions,
},
};
}
async function findUnfinishedSessions(basePath?: string): Promise<SessionJson[]> {
const rDir = runsDir(basePath);
if (!existsSync(rDir)) return [];
const entries = await readdir(rDir, { withFileTypes: true });
const unfinished: SessionJson[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const sess = await getSessionJson(entry.name, basePath);
if (sess && sess.status === 'in_progress') {
unfinished.push(sess);
}
}
return unfinished;
}
async function scanAllTrailsForCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
const rDir = runsDir(basePath);
if (!existsSync(rDir)) return [];
const entries = await readdir(rDir, { withFileTypes: true });
const corrections: UserCorrectionRecord[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const lines = await readLines(trailPath(entry.name, basePath));
for (const line of lines) {
const record = tryParseJson<UserCorrectionRecord>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record);
}
}
}
// Also scan audit_pending.jsonl
const pendingLines = await readLines(auditPendingPath(basePath));
for (const line of pendingLines) {
const record = tryParseJson<UserCorrectionRecord>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record);
}
}
return corrections;
}
export async function endSession(basePath?: string): Promise<EndSessionResult | null> {
const sessionId = await getCurrentSession(basePath);
if (!sessionId) return null;
const sDir = sessionDir(sessionId, basePath);
await ensureDir(sDir);
// Collect remaining buffer data
const bufferLines = await readLines(auditBufferPath(basePath));
const pendingLines = await readLines(auditPendingPath(basePath));
const allRemaining = [...bufferLines, ...pendingLines];
// Append to audit_trail.jsonl
const trail = trailPath(sessionId, basePath);
if (allRemaining.length > 0) {
await appendFile(trail, allRemaining.join('\n') + '\n', 'utf-8');
}
// Clear buffer files
await clearFile(auditBufferPath(basePath));
await clearFile(auditPendingPath(basePath));
// Read current trail for stats
const trailLines = await readLines(trail);
// Extract user_correction records
const corrections: UserCorrectionRecord[] = [];
for (const line of trailLines) {
const record = tryParseJson<UserCorrectionRecord>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record);
}
}
// Integrity checks
const integrity: IntegrityCheck[] = [
{
check: 'Audit records exist',
passed: trailLines.length > 0,
detail: trailLines.length > 0 ? `${trailLines.length} records` : 'No audit records found',
},
{
check: 'File modifications tracked',
passed: trailLines.some((l) => {
const r = tryParseJson<AuditTrailEntry>(l);
return r && (r.tool === 'Write' || r.tool === 'Edit');
}),
detail: 'Checking for Write/Edit tool entries',
},
{
check: 'User corrections persisted',
passed: corrections.every((c) => (c.persisted_to?.length ?? 0) > 0),
detail: corrections.length > 0
? `${corrections.length} corrections found, ${corrections.filter((c) => (c.persisted_to?.length ?? 0) > 0).length} persisted`
: 'No corrections to persist',
},
];
// Generate session summary
const summaryContent = generateSessionSummary(sessionId, trailLines, corrections);
const summaryFile = summaryPath(sessionId, basePath);
await writeFile(summaryFile, summaryContent, 'utf-8');
// Update session.json
const session = await getSessionJson(sessionId, basePath);
if (session) {
session.status = 'completed';
session.end_time = isoNow();
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
await updateIndexStatus(sessionId, 'completed', basePath);
}
// Update index.json record count
const idx = await getIndex(basePath);
if (idx) {
for (const e of idx.entries) {
if (e.id === sessionId) {
e.record_count = trailLines.length;
e.status = 'completed';
}
}
await writeIndex(idx.entries, basePath);
}
// Clear .current_session
try {
await rm(currentSessionPath(basePath));
} catch {
// Ok if already gone
}
return {
sessionId,
integrity,
correctionCount: corrections.length,
summaryPath: summaryFile,
};
}
function generateSessionSummary(
sessionId: string,
trailLines: string[],
corrections: UserCorrectionRecord[],
): string {
const actions: string[] = [];
const outputs: string[] = [];
for (const line of trailLines) {
const record = tryParseJson<AuditTrailEntry>(line);
if (record) {
if (record.action_type) actions.push(record.action_type);
if (record.output) outputs.push(record.output);
}
}
return [
`# Session Summary | ${sessionId}`,
'',
`## Time: ${isoNow()}`,
`## Status: completed`,
'',
'## Completed work',
...actions.map((a) => `- ${a}`),
'',
'## Key conclusions',
...outputs.map((o) => `- ${o}`),
'',
'## User corrections',
...(corrections.length > 0
? corrections.map((c) => `- ${c.original_claim}${c.correction} (${c.principle_extracted})`)
: ['- None']),
'',
].join('\n');
}
export async function recoverSession(
level: number,
specificSessionId?: string,
basePath?: string,
): Promise<RecoverResult> {
const result: RecoverResult = { level, recentActivity: [], lastTrailEntries: [], userCorrections: [], conclusions: [], dailyAnomalies: [], dailyBacklog: [] };
// L0: index summary
const idx = await getIndex(basePath);
result.recentActivity = idx?.entries.slice(0, 5) ?? [];
if (level === 0) return result;
// L1: current session + last 3 trail entries
let activeSessionId = specificSessionId ?? await getCurrentSession(basePath);
if (activeSessionId) {
result.sessionId = activeSessionId;
const session = await getSessionJson(activeSessionId, basePath);
if (session) {
result.task = session.task;
}
const trailLines = await readLines(trailPath(activeSessionId, basePath));
result.lastTrailEntries = trailLines.slice(-3).map((l) => {
const r = tryParseJson<AuditTrailEntry>(l);
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
});
}
if (level === 1) return result;
// L2: user corrections + conclusions + daily anomalies
result.userCorrections = await scanAllTrailsForCorrections(basePath);
// Extract conclusions from trail entries
const allTrailLines = await readLines(trailPath(activeSessionId ?? '', basePath));
for (const line of allTrailLines) {
const record = tryParseJson<AuditTrailEntry>(line);
if (record?.output) {
result.conclusions.push(record.output);
}
}
// Read daily reports for anomalies + backlog
const dDir = dailyDir(basePath);
if (existsSync(dDir)) {
const dailyFiles = (await readdir(dDir)).filter((f) => f.endsWith('_daily.md')).sort().reverse();
if (dailyFiles.length > 0) {
const latest = await readFile(join(dDir, dailyFiles[0]!), 'utf-8');
const anomalies = latest.match(/## (?:四|4).*?[\s\S]*?(?=##|$)/);
if (anomalies) result.dailyAnomalies.push(anomalies[0]);
const backlog = latest.match(/## (?:六|6).*?[\s\S]*?(?=##|$)/);
if (backlog) result.dailyBacklog.push(backlog[0]);
}
}
if (level === 2) return result;
// L3: full trail + pending
if (level >= 3) {
if (activeSessionId) {
const fullLines = await readLines(trailPath(activeSessionId, basePath));
result.fullTrail = fullLines.map((l) => {
const r = tryParseJson<AuditTrailEntry>(l);
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
});
}
}
return result;
}
export async function generateDailyReport(
targetDate?: string,
review?: boolean,
basePath?: string,
): Promise<DailyReport> {
const date = targetDate ?? isoDate();
const idx = await getIndex(basePath);
const rDir = runsDir(basePath);
const todayEntries = (idx?.entries ?? []).filter((e) => e.start_time.startsWith(date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8)));
let totalWriteEdit = 0;
let totalBash = 0;
let totalAuditBlocks = 0;
const changes: { time: string; target: string; detail: string }[] = [];
const feedback: { feedback: string; resolution: string; persistedTo: string }[] = [];
const anomalies: string[] = [];
for (const entry of todayEntries) {
const lines = await readLines(trailPath(entry.id, basePath));
for (const line of lines) {
const record = tryParseJson<AuditTrailEntry>(line);
if (!record) continue;
if (record.tool === 'Write' || record.tool === 'Edit') totalWriteEdit++;
if (record.tool === 'Bash') totalBash++;
if (record.action_type === 'audit_block') totalAuditBlocks++;
if (record.tool && (record.tool === 'Write' || record.tool === 'Edit') && record.files) {
changes.push({ time: record.timestamp, target: record.files.join(', '), detail: record.detail ?? '' });
}
if (record.action_type === 'user_correction') {
const uc = record as unknown as UserCorrectionRecord;
feedback.push({ feedback: uc.original_claim, resolution: uc.correction, persistedTo: (uc.persisted_to ?? []).join(', ') });
}
}
}
// Check for anomalies.json
if (existsSync(rDir)) {
const sessionDirs = await readdir(rDir, { withFileTypes: true });
for (const d of sessionDirs) {
if (!d.isDirectory()) continue;
const anomPath = join(rDir, d.name, 'anomalies.json');
if (existsSync(anomPath)) {
const anomContent = await readFile(anomPath, 'utf-8');
anomalies.push(`[${d.name}] ${anomContent.slice(0, 200)}`);
}
}
}
// Read previous day backlog
const prevDate = isoDate(new Date(Date.now() - 86400000));
let backlog: string[] = [];
const prevDailyPath = join(dailyDir(basePath), `${prevDate}_daily.md`);
if (existsSync(prevDailyPath)) {
const prevContent = await readFile(prevDailyPath, 'utf-8');
const m = prevContent.match(/## (?:六|6|明日待办)[\s\S]*?(?=##|$)/);
if (m) backlog = m[0].split('\n').filter((l) => l.trim().startsWith('-')).map((l) => l.replace(/^-\s*/, ''));
}
const reportPath = join(dailyDir(basePath), `${date}_daily.md`);
await ensureDir(dailyDir(basePath));
const sections = {
taskOverview: todayEntries.length > 0
? todayEntries.map((e) => `| ${e.id} | ${e.task} | ${e.status} | ${e.record_count} |`).join('\n')
: 'No activity',
operationStats: [
{ label: 'Write/Edit operations', count: totalWriteEdit },
{ label: 'Bash executions', count: totalBash },
{ label: 'Audit blocks', count: totalAuditBlocks },
],
changes,
userFeedback: feedback,
anomalyAlerts: anomalies,
backlogTracking: backlog,
integritySummary: [
`| All sessions have audit records | ${todayEntries.every((e) => e.record_count > 0) ? '✅' : '⚠️'} |`,
`| Audit blocks persisted | ${totalAuditBlocks > 0 ? '✅' : '⚠️'} |`,
`| User corrections persisted | ${feedback.every((f) => f.persistedTo.length > 0) ? '✅' : '⚠️'} |`,
].join('\n'),
};
const reportContent = generateDailyReportContent(date, sections);
await writeFile(reportPath, reportContent, 'utf-8');
// If review mode, also generate morning review
if (review) {
const reviewPath = join(dailyDir(basePath), `${date}_morning_review.md`);
const reviewContent = generateMorningReview(sections, date);
await writeFile(reviewPath, reviewContent, 'utf-8');
}
return { date, sections, path: reportPath };
}
function generateDailyReportContent(date: string, sections: DailyReport['sections']): string {
return [
`# Work Report | ${date}`,
'',
`> Auto-generated: ${isoNow()}`,
`> Data source: .boo/runs/index.json + session audit_trail`,
`> Coverage: ${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)} 00:00 — 23:59`,
'',
'---',
'',
'## I. Task Overview',
'',
'| Session ID | Task | Status | Records |',
'|-----------|------|--------|---------|',
sections.taskOverview,
'',
'---',
'',
'## II. Operation Stats',
'',
'| Metric | Count |',
'|--------|-------|',
...sections.operationStats.map((s) => `| ${s.label} | ${s.count} |`),
'',
'---',
'',
'## III. Change Records',
'',
...(sections.changes.length > 0
? ['| Time | Target | Detail |', '|------|--------|--------|', ...sections.changes.map((c) => `| ${c.time} | ${c.target} | ${c.detail} |`)]
: ['No changes recorded today.']),
'',
'---',
'',
'## IV. User Feedback & Corrections',
'',
...(sections.userFeedback.length > 0
? ['| Feedback | Resolution | Persisted To |', '|---------|------------|--------------|', ...sections.userFeedback.map((f) => `| ${f.feedback} | ${f.resolution} | ${f.persistedTo} |`)]
: ['None.']),
'',
'---',
'',
'## V. Anomaly Alerts',
'',
...(sections.anomalyAlerts.length > 0 ? sections.anomalyAlerts.map((a) => `- ${a}`) : ['None.']),
'',
'---',
'',
'## VI. Backlog Tracking',
'',
...(sections.backlogTracking.length > 0 ? sections.backlogTracking.map((b) => `- ${b}`) : ['None.']),
'',
'---',
'',
'## VII. Integrity Summary',
'',
'| Check | Result |',
'|-------|--------|',
sections.integritySummary,
'',
].join('\n');
}
function generateMorningReview(sections: DailyReport['sections'], date: string): string {
const anomalies = sections.anomalyAlerts;
const hasUnhandledAnomalies = anomalies.some((a) => !a.includes('resolved'));
const hasUnpersistedFeedback = sections.userFeedback.some((f) => !f.persistedTo);
const hasIncompleteBacklog = sections.backlogTracking.length > 0;
return [
`# Morning Self-Review | ${date}`,
'',
`> Generated: ${isoNow()}`,
'',
'## Self-Correction Check',
'',
`- Unresolved anomalies: ${hasUnhandledAnomalies ? '⚠️ Yes — needs attention' : '✅ None'}`,
`- Unpersisted user feedback: ${hasUnpersistedFeedback ? '⚠️ Needs documentation' : '✅ All persisted'}`,
`- Outstanding backlog: ${hasIncompleteBacklog ? '⚠️ Carry-over items' : '✅ Clean slate'}`,
'',
'## Today\'s Recommended Priorities',
'',
...(sections.backlogTracking.length > 0
? sections.backlogTracking.map((b) => `- [ ] ${b} (carry-over)`)
: []),
'- [ ] Review yesterday\'s user feedback and persist any remaining corrections',
'- [ ] Continue highest-priority task from session overview',
'',
].join('\n');
}
export async function ensureBooDirs(basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await ensureDir(dailyDir(basePath));
}
export async function writeAuditBuffer(entry: AuditTrailEntry, basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await appendLine(auditBufferPath(basePath), JSON.stringify(entry));
}
export async function writeAuditPending(entry: AuditTrailEntry, basePath?: string): Promise<void> {
await ensureDir(runsDir(basePath));
await appendLine(auditPendingPath(basePath), JSON.stringify(entry));
}

View File

@@ -0,0 +1,186 @@
import { readFile, writeFile, appendFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
export interface UserCorrectionRecord {
id: string;
record_type: 'conversation';
action_type: 'user_correction';
priority: 'critical_for_recovery';
timestamp: string;
original_claim: string;
correction: string;
principle_extracted: string;
persisted_to: string[];
}
const CORRECTIONS_REL = '.boo/corrections/index.json';
function correctionsDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), '.boo/corrections');
}
function correctionsPath(basePath?: string): string {
return resolve(basePath ?? process.cwd(), CORRECTIONS_REL);
}
function tryParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export interface CorrectionsIndex {
corrections: UserCorrectionRecord[];
}
async function readCorrections(basePath?: string): Promise<CorrectionsIndex> {
try {
const raw = await readFile(correctionsPath(basePath), 'utf-8');
return tryParseJson<CorrectionsIndex>(raw) ?? { corrections: [] };
} catch {
return { corrections: [] };
}
}
async function writeCorrections(idx: CorrectionsIndex, basePath?: string): Promise<void> {
const dir = correctionsDir(basePath);
if (!existsSync(dir)) {
const { mkdir } = await import('node:fs/promises');
await mkdir(dir, { recursive: true });
}
await writeFile(correctionsPath(basePath), JSON.stringify(idx, null, 2), 'utf-8');
}
let idCounter = 0;
function nextId(): string {
idCounter++;
return `uc_${Date.now()}_${idCounter}`;
}
function isoNow(): string {
return new Date().toISOString();
}
/**
* Record a user correction. Stores it in .boo/corrections/index.json
* and returns the record with the assigned id.
*/
export async function recordCorrection(
originalClaim: string,
correction: string,
principleExtracted: string,
persistedTo: string[] = [],
basePath?: string,
): Promise<UserCorrectionRecord> {
const idx = await readCorrections(basePath);
const record: UserCorrectionRecord = {
id: nextId(),
record_type: 'conversation',
action_type: 'user_correction',
priority: 'critical_for_recovery',
timestamp: isoNow(),
original_claim: originalClaim,
correction,
principle_extracted: principleExtracted,
persisted_to: persistedTo,
};
idx.corrections.push(record);
await writeCorrections(idx, basePath);
return record;
}
/**
* Scan an audit_trail.jsonl file for user_correction records.
* Returns all matching records found in the file.
*/
export async function scanForCorrections(
auditPath: string,
): Promise<UserCorrectionRecord[]> {
try {
const raw = await readFile(auditPath, 'utf-8');
const lines = raw.split('\n').filter(Boolean);
const corrections: UserCorrectionRecord[] = [];
for (const line of lines) {
const record = tryParseJson<Record<string, unknown>>(line);
if (record?.action_type === 'user_correction') {
corrections.push(record as unknown as UserCorrectionRecord);
}
}
return corrections;
} catch {
return [];
}
}
/**
* Check if a proposed action contradicts any known user correction.
* Returns an array of contradiction warnings — empty means no contradictions.
*/
export function checkContradiction(
action: string,
corrections: UserCorrectionRecord[],
): { contradicts: boolean; warnings: { correction: UserCorrectionRecord; reason: string }[] } {
const warnings: { correction: UserCorrectionRecord; reason: string }[] = [];
for (const c of corrections) {
// Check if the action mentions the original claim's topic
const actionLower = action.toLowerCase();
const claimFragments = c.original_claim.toLowerCase().split(/\s+/).filter((w) => w.length > 4);
// If any significant word from the original claim appears in the proposed action,
// flag this as a potential contradiction
const matchingFragments = claimFragments.filter((f) => actionLower.includes(f));
if (matchingFragments.length >= 2) {
warnings.push({
correction: c,
reason: `Action "${action.slice(0, 60)}" may contradict prior correction: "${c.original_claim}" → "${c.correction}" (principle: ${c.principle_extracted})`,
});
}
}
return {
contradicts: warnings.length > 0,
warnings,
};
}
/**
* Add a file path to a correction's persisted_to array.
*/
export async function markPersisted(
correctionId: string,
filePath: string,
basePath?: string,
): Promise<UserCorrectionRecord | null> {
const idx = await readCorrections(basePath);
const record = idx.corrections.find((c) => c.id === correctionId);
if (!record) return null;
if (!record.persisted_to.includes(filePath)) {
record.persisted_to.push(filePath);
}
await writeCorrections(idx, basePath);
return record;
}
/**
* Get all stored user corrections.
*/
export async function listCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
const idx = await readCorrections(basePath);
return idx.corrections;
}
/**
* Append a correction record to an audit_trail.jsonl file (inline storage).
*/
export async function appendCorrectionToTrail(
trailPath: string,
correction: UserCorrectionRecord,
): Promise<void> {
await appendFile(trailPath, JSON.stringify(correction) + '\n', 'utf-8');
}

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; * "Settled" = done skipped excluded. Only settled deps unblock a step;
* an inFlight dep does NOT (the runner waits for its terminal callback). * 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 { export interface SchedulerState {
/** step ids that completed successfully (results available) */ /** 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.skipped.has(s.id) &&
!state.inFlight.has(s.id) && !state.inFlight.has(s.id) &&
!state.excluded.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'; 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 * Reconcile every step of an in-flight run for startup resume. Returns one
* decision per step. Pure — no IO. * 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 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. // Only agent steps remain ready → dispatch the whole parallel wave, then wait.
for (const s of toRun) { for (const s of toRun) {
await dispatchAgentStep(runId, run.project_id, model, s, ctx); 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. // flow's step.run already bakes in the evidence/YAGNI contracts.
const persona = step.agent ? await loadPersona(step.agent) : ''; const persona = step.agent ? await loadPersona(step.agent) : '';
const taskPrompt = await step.run(ctx); 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 // 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. // 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() SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
WHERE run_id = ${runId} AND step_id = ${step.id} 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} WHERE run_id = ${runId} AND step_id = ${stepId}
`; `;
} }
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
} }
// ─── run completion ───────────────────────────────────────────────────────── // ─── run completion ─────────────────────────────────────────────────────────
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
if (updated.count === 0) return; if (updated.count === 0) return;
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run'); const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
log.warn({ runId, error }, 'flow-runner: run failed'); log.warn({ runId, error }, 'flow-runner: run failed');
await appendStepEvent(sql, runId, stepId, 'failed', { error });
publishStep(runId, stepId, 'failed', { run_status: 'failed' }); publishStep(runId, stepId, 'failed', { run_status: 'failed' });
} }
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function publishStep( function publishStep(
runId: string, runId: string,
stepId: 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 }, extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
): void { ): void {
publishUser({ publishUser({
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
function errMsg(e: unknown): string { function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e); 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

@@ -0,0 +1,560 @@
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
export type Criticality = 'low' | 'medium' | 'high';
export interface GuidelineContent {
condition: string;
action: string | null;
description: string | null;
}
export interface Guideline {
id: string;
creationUtc: string;
content: GuidelineContent;
enabled: boolean;
tags: string[];
labels: string[];
metadata: Record<string, unknown>;
criticality: Criticality;
title: string | null;
priority: number;
}
export interface CreateGuidelineParams {
condition: string;
action?: string;
description?: string;
tags?: string[];
labels?: string[];
criticality?: Criticality;
title?: string;
priority?: number;
}
export interface UpdateGuidelineParams {
condition?: string;
action?: string | null;
description?: string | null;
enabled?: boolean;
tags?: string[];
labels?: string[];
metadata?: Record<string, unknown>;
criticality?: Criticality;
title?: string | null;
priority?: number;
}
export interface ListGuidelinesFilter {
tags?: string[];
labels?: string[];
}
interface GuidelineStoreData {
version: string;
guidelines: Guideline[];
migrationLog: string[];
}
const GUIDELINES_REL = '.boo/guidelines';
const STORE_FILE = 'guidelines.json';
const CURRENT_VERSION = 'v0.11.0';
function storeDir(basePath?: string): string {
return resolve(basePath ?? process.cwd(), GUIDELINES_REL);
}
function storePath(basePath?: string): string {
return join(storeDir(basePath), STORE_FILE);
}
function tryParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
let idCounter = 0;
function nextId(): string {
idCounter++;
return `gl_${Date.now()}_${idCounter}`;
}
function isoNow(): string {
return new Date().toISOString();
}
async function ensureStoreDir(basePath?: string): Promise<void> {
const dir = storeDir(basePath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
const MIGRATIONS: { from: string; to: string; migrate: (data: GuidelineStoreData) => GuidelineStoreData }[] = [
{
from: 'v0.1.0',
to: 'v0.2.0',
migrate: (data) => ({
...data,
version: 'v0.2.0',
guidelines: data.guidelines.map((g) => ({
...g,
enabled: g.enabled ?? true,
})),
migrationLog: [...data.migrationLog, 'v0.1.0→v0.2.0: add enabled field'],
}),
},
{
from: 'v0.2.0',
to: 'v0.3.0',
migrate: (data) => ({
...data,
version: 'v0.3.0',
migrationLog: [...data.migrationLog, 'v0.2.0→v0.3.0: remove guideline_set'],
}),
},
{
from: 'v0.3.0',
to: 'v0.4.0',
migrate: (data) => ({
...data,
version: 'v0.4.0',
guidelines: data.guidelines.map((g) => ({
...g,
content: {
...g.content,
action: g.content.action ?? null,
description: g.content.description ?? null,
},
metadata: g.metadata ?? {},
})),
migrationLog: [...data.migrationLog, 'v0.3.0→v0.4.0: add optional action, description, metadata'],
}),
},
{
from: 'v0.4.0',
to: 'v0.5.0',
migrate: (data) => ({
...data,
version: 'v0.5.0',
migrationLog: [...data.migrationLog, 'v0.4.0→v0.5.0: description as optional'],
}),
},
{
from: 'v0.5.0',
to: 'v0.6.0',
migrate: (data) => ({
...data,
version: 'v0.6.0',
guidelines: data.guidelines.map((g) => ({
...g,
criticality: g.criticality ?? 'medium',
})),
migrationLog: [...data.migrationLog, 'v0.5.0→v0.6.0: add criticality'],
}),
},
{
from: 'v0.6.0',
to: 'v0.7.0',
migrate: (data) => ({
...data,
version: 'v0.7.0',
migrationLog: [...data.migrationLog, 'v0.6.0→v0.7.0: add composition_mode (optional)'],
}),
},
{
from: 'v0.7.0',
to: 'v0.8.0',
migrate: (data) => ({
...data,
version: 'v0.8.0',
migrationLog: [...data.migrationLog, 'v0.7.0→v0.8.0: add track (default true)'],
}),
},
{
from: 'v0.8.0',
to: 'v0.9.0',
migrate: (data) => ({
...data,
version: 'v0.9.0',
guidelines: data.guidelines.map((g) => ({
...g,
labels: g.labels ?? [],
})),
migrationLog: [...data.migrationLog, 'v0.8.0→v0.9.0: add labels'],
}),
},
{
from: 'v0.9.0',
to: 'v0.10.0',
migrate: (data) => ({
...data,
version: 'v0.10.0',
guidelines: data.guidelines.map((g) => ({
...g,
priority: g.priority ?? 0,
})),
migrationLog: [...data.migrationLog, 'v0.9.0→v0.10.0: add priority'],
}),
},
{
from: 'v0.10.0',
to: 'v0.11.0',
migrate: (data) => ({
...data,
version: 'v0.11.0',
guidelines: data.guidelines.map((g) => ({
...g,
title: g.title ?? null,
})),
migrationLog: [...data.migrationLog, 'v0.10.0→v0.11.0: add title'],
}),
},
];
function applyMigrations(data: GuidelineStoreData): GuidelineStoreData {
let current = { ...data };
for (const migration of MIGRATIONS) {
if (current.version === migration.from) {
current = migration.migrate(current);
}
}
return current;
}
async function readStore(basePath?: string): Promise<GuidelineStoreData> {
try {
const raw = await readFile(storePath(basePath), 'utf-8');
const data = tryParseJson<GuidelineStoreData>(raw);
if (!data) return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
if (data.version !== CURRENT_VERSION) {
return applyMigrations(data);
}
return data;
} catch {
return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
}
}
async function writeStore(data: GuidelineStoreData, basePath?: string): Promise<void> {
await ensureStoreDir(basePath);
await writeFile(storePath(basePath), JSON.stringify(data, null, 2), 'utf-8');
}
export async function createGuideline(
params: CreateGuidelineParams,
basePath?: string,
): Promise<Guideline> {
const data = await readStore(basePath);
const guideline: Guideline = {
id: nextId(),
creationUtc: isoNow(),
content: {
condition: params.condition,
action: params.action ?? null,
description: params.description ?? null,
},
enabled: true,
tags: params.tags ?? [],
labels: params.labels ?? [],
metadata: {},
criticality: params.criticality ?? 'medium',
title: params.title ?? null,
priority: params.priority ?? 0,
};
data.guidelines.push(guideline);
await writeStore(data, basePath);
return guideline;
}
export async function listGuidelines(
filter?: ListGuidelinesFilter,
basePath?: string,
): Promise<Guideline[]> {
const data = await readStore(basePath);
let results = data.guidelines;
if (filter?.tags && filter.tags.length > 0) {
results = results.filter((g) => filter.tags!.some((tag) => g.tags.includes(tag)));
}
if (filter?.labels && filter.labels.length > 0) {
results = results.filter((g) => filter.labels!.every((label) => g.labels.includes(label)));
}
return results;
}
export async function readGuideline(
id: string,
basePath?: string,
): Promise<Guideline | null> {
const data = await readStore(basePath);
return data.guidelines.find((g) => g.id === id) ?? null;
}
export async function updateGuideline(
id: string,
params: UpdateGuidelineParams,
basePath?: string,
): Promise<Guideline | null> {
const data = await readStore(basePath);
const idx = data.guidelines.findIndex((g) => g.id === id);
if (idx === -1) return null;
const existing = data.guidelines[idx]!;
if (params.condition !== undefined) existing.content.condition = params.condition;
if (params.action !== undefined) existing.content.action = params.action;
if (params.description !== undefined) existing.content.description = params.description;
if (params.enabled !== undefined) existing.enabled = params.enabled;
if (params.tags !== undefined) existing.tags = params.tags;
if (params.labels !== undefined) existing.labels = params.labels;
if (params.metadata !== undefined) existing.metadata = params.metadata;
if (params.criticality !== undefined) existing.criticality = params.criticality;
if (params.title !== undefined) existing.title = params.title;
if (params.priority !== undefined) existing.priority = params.priority;
data.guidelines[idx] = existing;
await writeStore(data, basePath);
return existing;
}
export async function deleteGuideline(
id: string,
basePath?: string,
): Promise<boolean> {
const data = await readStore(basePath);
const lenBefore = data.guidelines.length;
data.guidelines = data.guidelines.filter((g) => g.id !== id);
if (data.guidelines.length === lenBefore) return false;
await writeStore(data, basePath);
return true;
}
export async function findGuideline(
content: { condition: string; action?: string },
basePath?: string,
): Promise<Guideline | null> {
const data = await readStore(basePath);
return data.guidelines.find((g) => {
const condMatch = g.content.condition === content.condition;
if (!condMatch) return false;
if (content.action !== undefined) {
return g.content.action === content.action;
}
return true;
}) ?? null;
}
// ─── Journey → Guideline projection (port of Parlant's JourneyGuidelineProjection) ───
export interface JourneyNode {
id: string;
action: string;
description?: string;
}
export interface JourneyEdge {
sourceNodeId: string;
targetNodeId: string;
condition: string;
}
export interface Journey {
id: string;
name: string;
nodes: JourneyNode[];
edges: JourneyEdge[];
}
export interface JourneyProjectionResult {
guidelines: Guideline[];
followUps: Map<string, string[]>;
}
/**
* Project a Journey into an ordered list of Guidelines.
* DFS traversal from root nodes: each (edge, node) pair → one Guideline.
* Edge condition becomes guideline condition, node action becomes guideline action.
* BFS queue avoids infinite loops via visited set.
*/
export function projectJourneyToGuidelines(
journey: Journey,
baseTags?: string[],
): JourneyProjectionResult {
const guidelines: Guideline[] = [];
const followUps = new Map<string, string[]>();
const visited = new Set<string>();
const nodeMap = new Map<string, JourneyNode>();
for (const node of journey.nodes) {
nodeMap.set(node.id, node);
}
// Build adjacency list
const adjacency = new Map<string, JourneyEdge[]>();
for (const edge of journey.edges) {
const list = adjacency.get(edge.sourceNodeId) ?? [];
list.push(edge);
adjacency.set(edge.sourceNodeId, list);
}
// Find root nodes (no incoming edges)
const hasIncoming = new Set<string>();
for (const edge of journey.edges) {
hasIncoming.add(edge.targetNodeId);
}
const roots = journey.nodes
.filter((n) => !hasIncoming.has(n.id))
.map((n) => n.id);
const queue: { nodeId: string; fromEdge?: JourneyEdge }[] = [];
// BFS from roots
for (const rootId of roots) {
if (!visited.has(rootId)) {
queue.push({ nodeId: rootId });
}
}
while (queue.length > 0) {
const { nodeId, fromEdge } = queue.shift()!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (!node) continue;
// If we arrived via an edge, create a guideline
if (fromEdge) {
const guideline = createGuidelineFromJourneyEdge(
journey,
node,
fromEdge,
baseTags,
);
guidelines.push(guideline);
// Track follow-ups
const sourceId = findGuidelineForNode(fromEdge.sourceNodeId, journey.nodes);
if (sourceId) {
const existing = followUps.get(sourceId) ?? [];
existing.push(guideline.id);
followUps.set(sourceId, existing);
}
}
// Enqueue downstream nodes
const outgoingEdges = adjacency.get(nodeId) ?? [];
for (const edge of outgoingEdges) {
if (!visited.has(edge.targetNodeId)) {
queue.push({ nodeId: edge.targetNodeId, fromEdge: edge });
}
}
}
return { guidelines, followUps };
}
function findGuidelineForNode(nodeId: string, nodes: JourneyNode[]): string | null {
// Placeholder: in a full implementation, map nodeId → guideline id
// For now return null — downstream consumers handle missing follow-ups gracefully
return null;
}
function createGuidelineFromJourneyEdge(
journey: Journey,
targetNode: JourneyNode,
edge: JourneyEdge,
baseTags?: string[],
): Guideline {
const now = isoNow();
return {
id: nextId(),
creationUtc: now,
content: {
condition: edge.condition,
action: targetNode.action,
description: targetNode.description ?? null,
},
enabled: true,
tags: baseTags ?? [journey.name],
labels: [],
metadata: {
journey_id: journey.id,
journey_node: targetNode.id,
source_edge_id: `${edge.sourceNodeId}${edge.targetNodeId}`,
},
criticality: 'medium',
title: targetNode.description
? `[${journey.name}] ${targetNode.description.slice(0, 60)}`
: null,
priority: 0,
};
}
// ─── Backtrack detection ───
export interface BacktrackCheckInput {
journeyId: string;
currentNodeId: string;
previousNodeId: string;
}
export interface BacktrackCheckResult {
journeyId: string;
currentNodeId: string;
previousNodeId: string;
isBacktrack: boolean;
recommendation: string | null;
}
/**
* Check if moving from previousNodeId to currentNodeId is a backtrack
* (regression to an already-visited node not on a forward path).
*/
export function checkBacktrack(
input: BacktrackCheckInput,
journey: Journey,
): BacktrackCheckResult {
const adjacency = new Map<string, string[]>();
for (const edge of journey.edges) {
const list = adjacency.get(edge.sourceNodeId) ?? [];
list.push(edge.targetNodeId);
adjacency.set(edge.sourceNodeId, list);
}
// Find forward reachable nodes from the current node
const forwardReachable = new Set<string>();
const bfsQueue = [input.currentNodeId];
while (bfsQueue.length > 0) {
const nid = bfsQueue.shift()!;
if (forwardReachable.has(nid)) continue;
forwardReachable.add(nid);
const next = adjacency.get(nid) ?? [];
for (const n of next) {
if (!forwardReachable.has(n)) bfsQueue.push(n);
}
}
const isBacktrack = input.previousNodeId !== input.currentNodeId
&& !forwardReachable.has(input.previousNodeId)
&& input.previousNodeId !== input.currentNodeId;
return {
journeyId: input.journeyId,
currentNodeId: input.currentNodeId,
previousNodeId: input.previousNodeId,
isBacktrack,
recommendation: isBacktrack
? `Revisiting node "${input.previousNodeId}" after "${input.currentNodeId}" — this may indicate a regression. Consider whether the forward path from "${input.currentNodeId}" is the correct one.`
: null,
};
}

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

@@ -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

@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
import { newTaskTool } from './new_task.js'; import { newTaskTool } from './new_task.js';
import { listTasksTool } from './list_tasks.js'; import { listTasksTool } from './list_tasks.js';
import { checkTaskStatusTool } from './check_task_status.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'; export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
checkTaskStatusTool, 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

@@ -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'), input: z.string().min(1).describe('Task description for the child subtask'),
agent: z.string().optional().describe('Optional: dispatch to a specific agent'), agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
model: z.string().optional().describe('Optional: model override for the subtask'), 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>; 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' }, input: { type: 'string', description: 'Task description for the child subtask' },
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' }, agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
model: { type: 'string', description: 'Optional: model override for the subtask' }, model: { type: 'string', description: 'Optional: model override for the subtask' },
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
}, },
required: ['input'], required: ['input'],
}, },
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
return { error: 'Cannot determine project_id from current session' }; return { error: 'Cannot determine project_id from current session' };
} }
const isBg = input.background === true;
const [task] = await sql<{ id: string; state: string }[]>` const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, parent_task_id, input, agent, model) INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null}) VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
`; `;
return { 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, task_id: task!.id,
state: task!.state, state: task!.state,
background: isBg,
}; };
}, },
}; };

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)`. - **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. - **`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). - **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. - **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. - **`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. - **`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

@@ -19,6 +19,8 @@ import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js'; import { registerSkillsRoutes } from './routes/skills.js';
import { registerToolsRoutes } from './routes/tools.js'; import { registerToolsRoutes } from './routes/tools.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
import { createInferenceRunner } from './services/inference/index.js'; import { createInferenceRunner } from './services/inference/index.js';
import { createBroker } from './services/broker.js'; import { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js'; import { listSkills } from './services/skills.js';
@@ -122,6 +124,8 @@ async function main() {
registerSidebarRoutes(app, sql); registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker); registerChatRoutes(app, sql, broker);
registerToolsRoutes(app, sql); registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerInferenceSettingsRoutes(app);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or // Batch 9.6: warm the skills cache at boot and surface the count. Empty or
// missing /data/skills is non-fatal — the skill tools just return empty. // missing /data/skills is non-fatal — the skill tools just return empty.

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

@@ -0,0 +1,55 @@
import { FastifyInstance } from 'fastify';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
const CONFIG_PATH = resolve(process.env.BOOCODE_DATA_DIR || '/opt/boocode/data', 'inference-settings.json');
const DEFAULTS = {
cache_type_k: 'q4_0',
cache_reuse: 256,
spec_type: 'ngram-mod',
spec_ngram_mod_thsh: 2,
ctx_checkpoints: 32,
sleep_idle_seconds: 600,
metrics_enabled: true,
slot_save_path: '/tmp/llama-slots',
};
function load(): Record<string, unknown> {
try {
if (existsSync(CONFIG_PATH)) {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
}
} catch { /* corrupt file */ }
return { ...DEFAULTS };
}
function save(data: Record<string, unknown>): void {
const dir = dirname(CONFIG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + '\n');
}
const VALID_CACHE_TYPES = ['f32', 'f16', 'q8_0', 'q4_0'] as const;
const VALID_SPEC_TYPES = ['off', 'ngram-mod', 'draft-simple'] as const;
export function registerInferenceSettingsRoutes(app: FastifyInstance): void {
app.get('/api/settings/inference', async (_req, _res) => {
return { ...DEFAULTS, ...load() };
});
app.patch<{ Body: Record<string, unknown> }>('/api/settings/inference', async (req, reply) => {
const current = { ...DEFAULTS, ...load() };
const merged = { ...current, ...req.body };
if (merged.cache_type_k && !(VALID_CACHE_TYPES as readonly string[]).includes(merged.cache_type_k as string)) {
return reply.status(400).send({ error: 'Invalid cache_type_k' });
}
if (merged.spec_type && !(VALID_SPEC_TYPES as readonly string[]).includes(merged.spec_type as string)) {
return reply.status(400).send({ error: 'Invalid spec_type' });
}
save(merged);
return { ...DEFAULTS, ...load() };
});
}

View File

@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; 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 { basename, resolve, sep } from 'node:path';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.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 // 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. // 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. // 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', '/api/projects/:id/git/diff',
async (req, reply) => { async (req, reply) => {
const { id } = req.params; const { id } = req.params;
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
rawMode === 'uncommitted' ? 'uncommitted' : rawMode === 'uncommitted' ? 'uncommitted' :
auto_mode; // no mode param → auto-select (FIX 1) 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) { if (result === null) {
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] }; return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
} }
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
).min(1), ).min(1),
}); });
const WriteFileBody = z.object({
path: z.string().min(1),
content: z.string(),
});
// POST /api/projects/:id/git/stage — stage whole files // POST /api/projects/:id/git/stage — stage whole files
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/projects/:id/git/stage', '/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 // GET /api/projects/:id/files
app.get<{ Params: { id: string } }>( app.get<{ Params: { id: string } }>(
'/api/projects/:id/files', '/api/projects/:id/files',

View File

@@ -0,0 +1,52 @@
export interface UserCorrectionRecord {
record_type: 'conversation';
action_type: 'user_correction';
priority: 'critical_for_recovery';
timestamp: string;
original_claim: string;
correction: string;
principle_extracted: string;
persisted_to: string[];
}
export function createCorrection(params: {
originalClaim: string;
correction: string;
principleExtracted?: string;
persistedTo?: string[];
}): UserCorrectionRecord {
return {
record_type: 'conversation',
action_type: 'user_correction',
priority: 'critical_for_recovery',
timestamp: new Date().toISOString(),
original_claim: params.originalClaim,
correction: params.correction,
principle_extracted: params.principleExtracted || '',
persisted_to: params.persistedTo || [],
};
}
export function findCorrections(
records: Record<string, unknown>[],
): UserCorrectionRecord[] {
return records.filter(
r => r['action_type'] === 'user_correction',
) as unknown as UserCorrectionRecord[];
}
export function checkCorrectionConflict(
proposedAction: string,
corrections: UserCorrectionRecord[],
): UserCorrectionRecord | null {
for (const c of corrections) {
if (!c.original_claim) continue;
const claimKeywords = c.original_claim.toLowerCase().split(/\s+/).filter(w => w.length > 3);
const actionLower = proposedAction.toLowerCase();
const matchCount = claimKeywords.filter(k => actionLower.includes(k)).length;
if (matchCount >= 2 && matchCount / claimKeywords.length >= 0.5) {
if (c.persisted_to.length > 0) return c;
}
}
return null;
}

View File

@@ -0,0 +1,251 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureRunsDir } from './runs-dir.js';
export type GuidelineId = string;
export type TagId = string;
export type Criticality = 'low' | 'medium' | 'high';
export type GuidelineDocumentVersion = string;
export interface GuidelineContent {
condition: string;
action: string | null;
description: string | null;
}
export interface Guideline {
id: GuidelineId;
creationUtc: string;
content: GuidelineContent;
enabled: boolean;
tags: TagId[];
labels: string[];
metadata: Record<string, unknown>;
criticality: Criticality;
title: string | null;
priority: number;
}
export interface GuidelineDocument {
id: string;
version: GuidelineDocumentVersion;
creation_utc: string;
condition: string;
action: string | null;
description: string | null;
title: string | null;
criticality: string;
enabled: boolean;
metadata: Record<string, unknown>;
labels: string[];
priority: number;
}
export interface GuidelineUpdateParams {
condition?: string;
action?: string | null;
description?: string | null;
title?: string | null;
criticality?: Criticality;
enabled?: boolean;
priority?: number;
}
function generateId(): string {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < 10; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function dbPath(projectRoot?: string): string {
const dir = join(ensureRunsDir(projectRoot), '..', 'guidelines');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return join(dir, 'guidelines.json');
}
function readDb(projectRoot?: string): GuidelineDocument[] {
const path = dbPath(projectRoot);
try {
return JSON.parse(readFileSync(path, 'utf-8')) as GuidelineDocument[];
} catch {
return [];
}
}
function writeDb(docs: GuidelineDocument[], projectRoot?: string): void {
writeFileSync(dbPath(projectRoot), JSON.stringify(docs, null, 2), 'utf-8');
}
function toDocument(g: Guideline): GuidelineDocument {
return {
id: g.id,
version: '0.11.0',
creation_utc: g.creationUtc,
condition: g.content.condition,
action: g.content.action,
description: g.content.description,
title: g.title,
criticality: g.criticality,
enabled: g.enabled,
metadata: g.metadata,
labels: g.labels,
priority: g.priority,
};
}
function fromDocument(d: GuidelineDocument): Guideline {
return {
id: d.id,
creationUtc: d.creation_utc,
content: {
condition: d.condition,
action: d.action ?? null,
description: d.description ?? null,
},
title: d.title ?? null,
criticality: (d.criticality || 'medium') as Criticality,
enabled: d.enabled ?? true,
tags: [],
labels: d.labels ?? [],
metadata: d.metadata ?? {},
priority: d.priority ?? 0,
};
}
export class GuidelineDocumentStore {
createGuideline(params: {
condition: string;
action?: string | null;
description?: string | null;
title?: string | null;
criticality?: Criticality;
enabled?: boolean;
labels?: string[];
priority?: number;
id?: GuidelineId;
}, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const id = params.id || `gl_${generateId()}`;
if (docs.find(d => d.id === id)) {
throw new Error(`Guideline with id '${id}' already exists`);
}
const guideline: Guideline = {
id,
creationUtc: new Date().toISOString(),
content: {
condition: params.condition,
action: params.action ?? null,
description: params.description ?? null,
},
title: params.title ?? null,
criticality: params.criticality ?? 'medium',
enabled: params.enabled ?? true,
tags: [],
labels: params.labels ?? [],
metadata: {},
priority: params.priority ?? 0,
};
docs.push(toDocument(guideline));
writeDb(docs, projectRoot);
return guideline;
}
listGuidelines(params?: {
tags?: TagId[];
labels?: string[];
}, projectRoot?: string): Guideline[] {
let docs = readDb(projectRoot);
if (params?.tags && params.tags.length > 0) {
const tagSet = new Set(params.tags);
docs = docs.filter(d => d.metadata['tags'] &&
Array.isArray(d.metadata['tags']) &&
(d.metadata['tags'] as string[]).some(t => tagSet.has(t)));
}
if (params?.labels && params.labels.length > 0) {
const labelSet = new Set(params.labels);
docs = docs.filter(d => {
const gl = fromDocument(d);
return params.labels!.every(l => gl.labels.includes(l));
});
}
return docs.map(fromDocument);
}
readGuideline(id: GuidelineId, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const doc = docs.find(d => d.id === id);
if (!doc) throw new Error(`Guideline '${id}' not found`);
return fromDocument(doc);
}
updateGuideline(id: GuidelineId, params: GuidelineUpdateParams, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
const doc = docs[idx]!;
if (params.condition !== undefined) doc.condition = params.condition;
if (params.action !== undefined) doc.action = params.action;
if (params.description !== undefined) doc.description = params.description;
if (params.title !== undefined) doc.title = params.title;
if (params.criticality !== undefined) doc.criticality = params.criticality;
if (params.enabled !== undefined) doc.enabled = params.enabled;
if (params.priority !== undefined) doc.priority = params.priority;
docs[idx] = doc;
writeDb(docs, projectRoot);
return fromDocument(doc);
}
deleteGuideline(id: GuidelineId, projectRoot?: string): void {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
docs.splice(idx, 1);
writeDb(docs, projectRoot);
}
findGuideline(content: GuidelineContent, projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const doc = docs.find(d =>
d.condition === content.condition &&
(content.action === undefined || d.action === content.action),
);
if (!doc) throw new Error(`Guideline not found for condition='${content.condition}'`);
return fromDocument(doc);
}
upsertLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
const doc = docs[idx]!;
const current = new Set(doc.labels || []);
for (const l of labels) current.add(l);
doc.labels = [...current];
writeDb(docs, projectRoot);
return fromDocument(doc);
}
removeLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
const docs = readDb(projectRoot);
const idx = docs.findIndex(d => d.id === id);
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
const doc = docs[idx]!;
const removeSet = new Set(labels);
doc.labels = (doc.labels || []).filter(l => !removeSet.has(l));
writeDb(docs, projectRoot);
return fromDocument(doc);
}
}

View File

@@ -0,0 +1,68 @@
export {
findRunsDir,
ensureRunsDir,
readCurrentSession,
writeCurrentSession,
clearCurrentSession,
readIndex,
writeIndex,
updateIndexEntry,
findInProgressSessions,
INDEX_SCHEMA_VERSION,
GITIGNORE_CONTENT,
} from './runs-dir.js';
export type { IndexEntry, IndexFile } from './runs-dir.js';
export {
generateSessionId,
isoNow,
createSession,
getSessionDir,
getActiveSession,
readSession,
updateSession,
endSession,
appendToTrail,
readTrail,
recoverContext,
checkUnfinishedSessions,
generateSessionSummary,
} from './session-manager.js';
export type { SessionJson, RecoverySummary } from './session-manager.js';
export {
createCorrection,
findCorrections,
checkCorrectionConflict,
} from './corrections.js';
export type { UserCorrectionRecord } from './corrections.js';
export {
GuidelineDocumentStore,
} from './guideline-store.js';
export type {
GuidelineId,
GuidelineContent,
Guideline,
Criticality,
GuidelineUpdateParams,
GuidelineDocument,
} from './guideline-store.js';
export {
JourneyStore,
} from './journey-store.js';
export type {
JourneyId,
JourneyNodeId,
JourneyEdgeId,
Journey,
JourneyNode,
JourneyEdge,
} from './journey-store.js';
export {
projectJourneyToGuidelines,
detectJourneyBacktrack,
} from './journey-projection.js';
export type { ProjectedGuideline, BacktrackCheck } from './journey-projection.js';

View File

@@ -0,0 +1,189 @@
import type {
Journey,
JourneyNode,
JourneyEdge,
JourneyNodeId,
JourneyEdgeId,
} from './journey-store.js';
import type { Guideline, GuidelineId, Criticality } from './guideline-store.js';
export interface ProjectedGuideline {
id: GuidelineId;
content: {
condition: string;
action: string | null;
description: string | null;
};
criticality: Criticality;
creationUtc: string;
enabled: boolean;
tags: string[];
labels: string[];
metadata: Record<string, unknown>;
}
function formatNodeGuidelineId(nodeId: JourneyNodeId, edgeId?: JourneyEdgeId | null): GuidelineId {
return `journey_node:${nodeId}${edgeId ? `:${edgeId}` : ''}` as GuidelineId;
}
export function projectJourneyToGuidelines(
journey: Journey,
nodes: JourneyNode[],
edges: JourneyEdge[],
): ProjectedGuideline[] {
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
for (const n of nodes) nodeMap.set(n.id, n);
const edgeMap = new Map<JourneyEdgeId, JourneyEdge>();
for (const e of edges) edgeMap.set(e.id, e);
const nodeEdges = new Map<JourneyNodeId, JourneyEdge[]>();
for (const e of edges) {
const list = nodeEdges.get(e.source) || [];
list.push(e);
nodeEdges.set(e.source, list);
}
const guidelines: Map<GuidelineId, ProjectedGuideline> = new Map();
const nodeIndexes = new Map<JourneyNodeId, number>();
let index = 0;
const queue: Array<{ edgeId: JourneyEdgeId | null; nodeId: JourneyNodeId }> = [];
const visited = new Set<string>();
queue.push({ edgeId: null, nodeId: journey.rootId });
while (queue.length > 0) {
const { edgeId, nodeId } = queue.shift()!;
const visitKey = `${edgeId || ''}:${nodeId}`;
if (visited.has(visitKey)) continue;
visited.add(visitKey);
const node = nodeMap.get(nodeId);
if (!node) continue;
if (!nodeIndexes.has(nodeId)) {
index++;
nodeIndexes.set(nodeId, index);
}
const edge = edgeId ? edgeMap.get(edgeId) : undefined;
const baseJourneyNode: Record<string, unknown> = {
follow_ups: [],
index: String(nodeIndexes.get(nodeId)),
journey_id: journey.id,
labels: node.labels,
tool_ids: node.tools,
};
const edgeJourneyNode = (edge?.metadata?.['journey_node'] as Record<string, unknown>) || {};
const nodeJourneyNode = (node.metadata?.['journey_node'] as Record<string, unknown>) || {};
const mergedJourneyNode = { ...baseJourneyNode, ...nodeJourneyNode, ...edgeJourneyNode };
const metadata: Record<string, unknown> = {
journey_node: mergedJourneyNode,
};
for (const [k, v] of Object.entries(node.metadata)) {
if (k !== 'journey_node') metadata[k] = v;
}
if (edge) {
for (const [k, v] of Object.entries(edge.metadata)) {
if (k !== 'journey_node') metadata[k] = v;
}
}
const gid = formatNodeGuidelineId(nodeId, edgeId);
const guideline: ProjectedGuideline = {
id: gid,
content: {
condition: (edge?.condition) || '',
action: node.action,
description: node.description,
},
criticality: 'high' as Criticality,
creationUtc: new Date().toISOString(),
enabled: true,
tags: journey.tags,
labels: [...(node.labels || [])],
metadata,
};
guidelines.set(gid, guideline);
const childEdges = nodeEdges.get(nodeId) || [];
for (const childEdge of childEdges) {
if (visited.has(`${childEdge.id}:${childEdge.target}`)) continue;
queue.push({ edgeId: childEdge.id, nodeId: childEdge.target });
const childGid = formatNodeGuidelineId(childEdge.target, childEdge.id);
const followUps = (guideline.metadata['journey_node'] as Record<string, unknown>)['follow_ups'] as string[];
if (!followUps.includes(childGid)) {
followUps.push(childGid);
}
}
}
return [...guidelines.values()];
}
export interface BacktrackCheck {
journeyId: string;
currentNodeId: JourneyNodeId;
previousNodeId: JourneyNodeId;
isBacktrack: boolean;
recommendation: string | null;
}
export function detectJourneyBacktrack(
journey: Journey,
nodes: JourneyNode[],
edges: JourneyEdge[],
currentNodeId: JourneyNodeId,
previousNodeId: JourneyNodeId,
): BacktrackCheck {
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
for (const n of nodes) nodeMap.set(n.id, n);
const adjacency = new Map<JourneyNodeId, JourneyNodeId[]>();
for (const e of edges) {
const list = adjacency.get(e.source) || [];
list.push(e.target);
adjacency.set(e.source, list);
}
const isInForwardPath = (from: JourneyNodeId, target: JourneyNodeId): boolean => {
const visitedInner = new Set<JourneyNodeId>();
const queueInner: JourneyNodeId[] = [from];
while (queueInner.length > 0) {
const current = queueInner.shift()!;
if (current === target) return true;
if (visitedInner.has(current)) continue;
visitedInner.add(current);
for (const next of adjacency.get(current) || []) {
if (!visitedInner.has(next)) queueInner.push(next);
}
}
return false;
};
const fromCurToPrev = isInForwardPath(currentNodeId, previousNodeId);
const fromPrevToCur = isInForwardPath(previousNodeId, currentNodeId);
const isBacktrack = !fromPrevToCur && !fromCurToPrev;
let recommendation: string | null = null;
if (isBacktrack && nodeMap.has(previousNodeId)) {
const prevNode = nodeMap.get(previousNodeId)!;
recommendation = `Detected potential backtrack from '${currentNodeId}' to '${previousNodeId}' (${prevNode.action || 'no action'}). Consider whether this regression is intentional.`;
}
return {
journeyId: journey.id,
currentNodeId,
previousNodeId,
isBacktrack,
recommendation,
};
}

View File

@@ -0,0 +1,360 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureRunsDir } from './runs-dir.js';
import type { GuidelineId } from './guideline-store.js';
export type JourneyId = string;
export type JourneyNodeId = string;
export type JourneyEdgeId = string;
export interface JourneyNode {
id: JourneyNodeId;
creationUtc: string;
action: string | null;
tools: string[];
metadata: Record<string, unknown>;
description: string | null;
labels: string[];
}
export interface JourneyEdge {
id: JourneyEdgeId;
creationUtc: string;
source: JourneyNodeId;
target: JourneyNodeId;
condition: string | null;
metadata: Record<string, unknown>;
}
export interface Journey {
id: JourneyId;
creationUtc: string;
description: string;
triggers: GuidelineId[];
title: string;
rootId: JourneyNodeId;
tags: string[];
labels: string[];
priority: number;
}
interface JourneyDocument {
id: string;
version: string;
creation_utc: string;
title: string;
description: string;
root_id: JourneyNodeId;
labels: string[];
priority: number;
}
interface NodeDocument {
id: string;
node_id: JourneyNodeId;
journey_id: JourneyId;
creation_utc: string;
action: string | null;
tools: string[];
metadata: Record<string, unknown>;
description: string | null;
labels: string[];
}
interface EdgeDocument {
id: string;
journey_id: JourneyId;
creation_utc: string;
source: JourneyNodeId;
target: JourneyNodeId;
condition: string | null;
metadata: Record<string, unknown>;
}
interface TriggerDocument {
id: string;
journey_id: JourneyId;
trigger: GuidelineId;
creation_utc: string;
}
function generateId(): string {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < 10; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function dbPath(name: string, projectRoot?: string): string {
const dir = join(ensureRunsDir(projectRoot), '..', 'journeys');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return join(dir, `${name}.json`);
}
function readCollection<T>(name: string, projectRoot?: string): T[] {
try {
return JSON.parse(readFileSync(dbPath(name, projectRoot), 'utf-8')) as T[];
} catch {
return [];
}
}
function writeCollection<T>(name: string, data: T[], projectRoot?: string): void {
writeFileSync(dbPath(name, projectRoot), JSON.stringify(data, null, 2), 'utf-8');
}
export class JourneyStore {
createJourney(params: {
title: string;
description: string;
triggers?: GuidelineId[];
labels?: string[];
priority?: number;
}, projectRoot?: string): Journey {
const id = `jny_${generateId()}`;
const rootId = `node_${generateId()}`;
const creationUtc = new Date().toISOString();
const journey: Journey = {
id,
creationUtc,
description: params.description,
triggers: params.triggers || [],
title: params.title,
rootId,
tags: [],
labels: params.labels || [],
priority: params.priority || 0,
};
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
journeys.push({
id,
version: '0.7.0',
creation_utc: creationUtc,
title: params.title,
description: params.description,
root_id: rootId,
labels: params.labels || [],
priority: params.priority || 0,
});
writeCollection('journeys', journeys, projectRoot);
const root: JourneyNode = {
id: rootId,
creationUtc,
action: null,
tools: [],
metadata: {},
description: null,
labels: [],
};
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
nodes.push({
id: `nd_${generateId()}`,
node_id: rootId,
journey_id: id,
creation_utc: creationUtc,
action: null,
tools: [],
metadata: {},
description: null,
labels: [],
});
writeCollection('nodes', nodes, projectRoot);
return journey;
}
readJourney(id: JourneyId, projectRoot?: string): Journey {
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
const doc = journeys.find(j => j.id === id);
if (!doc) throw new Error(`Journey '${id}' not found`);
const triggers = readCollection<TriggerDocument>('triggers', projectRoot)
.filter(t => t.journey_id === id)
.map(t => t.trigger);
return {
id: doc.id,
creationUtc: doc.creation_utc,
description: doc.description,
triggers,
title: doc.title,
rootId: doc.root_id,
tags: [],
labels: doc.labels || [],
priority: doc.priority || 0,
};
}
deleteJourney(id: JourneyId, projectRoot?: string): void {
let journeys = readCollection<JourneyDocument>('journeys', projectRoot);
const idx = journeys.findIndex(j => j.id === id);
if (idx === -1) throw new Error(`Journey '${id}' not found`);
journeys.splice(idx, 1);
writeCollection('journeys', journeys, projectRoot);
let nodes = readCollection<NodeDocument>('nodes', projectRoot);
nodes = nodes.filter(n => n.journey_id !== id);
writeCollection('nodes', nodes, projectRoot);
let edges = readCollection<EdgeDocument>('edges', projectRoot);
edges = edges.filter(e => e.journey_id !== id);
writeCollection('edges', edges, projectRoot);
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
triggers = triggers.filter(t => t.journey_id !== id);
writeCollection('triggers', triggers, projectRoot);
}
listJourneys(projectRoot?: string): Journey[] {
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
return journeys.map(j => this.readJourney(j.id, projectRoot));
}
createNode(journeyId: JourneyId, params: {
action?: string | null;
tools?: string[];
description?: string | null;
labels?: string[];
id?: JourneyNodeId;
}, projectRoot?: string): JourneyNode {
const nodeId = params.id || `node_${generateId()}`;
const creationUtc = new Date().toISOString();
const node: JourneyNode = {
id: nodeId,
creationUtc,
action: params.action ?? null,
tools: params.tools || [],
metadata: {},
description: params.description ?? null,
labels: params.labels || [],
};
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
nodes.push({
id: `nd_${generateId()}`,
node_id: nodeId,
journey_id: journeyId,
creation_utc: creationUtc,
action: node.action,
tools: node.tools,
metadata: node.metadata,
description: node.description,
labels: node.labels,
});
writeCollection('nodes', nodes, projectRoot);
return node;
}
listNodes(journeyId: JourneyId, projectRoot?: string): JourneyNode[] {
const docs = readCollection<NodeDocument>('nodes', projectRoot)
.filter(n => n.journey_id === journeyId);
const nodes = docs.map(d => ({
id: d.node_id,
creationUtc: d.creation_utc,
action: d.action,
tools: d.tools,
metadata: d.metadata,
description: d.description,
labels: d.labels || [],
}));
nodes.push({
id: 'end' as JourneyNodeId,
creationUtc: new Date().toISOString(),
action: null,
tools: [],
metadata: {},
description: null,
labels: [],
});
return nodes;
}
createEdge(journeyId: JourneyId, params: {
source: JourneyNodeId;
target: JourneyNodeId;
condition?: string | null;
}, projectRoot?: string): JourneyEdge {
const creationUtc = new Date().toISOString();
const edge: JourneyEdge = {
id: `edge_${generateId()}`,
creationUtc,
source: params.source,
target: params.target,
condition: params.condition ?? null,
metadata: {},
};
const edges = readCollection<EdgeDocument>('edges', projectRoot);
edges.push({
id: edge.id,
journey_id: journeyId,
creation_utc: creationUtc,
source: params.source,
target: params.target,
condition: params.condition ?? null,
metadata: {},
});
writeCollection('edges', edges, projectRoot);
return edge;
}
listEdges(journeyId: JourneyId, nodeId?: JourneyNodeId, projectRoot?: string): JourneyEdge[] {
let docs = readCollection<EdgeDocument>('edges', projectRoot)
.filter(e => e.journey_id === journeyId);
if (nodeId) {
docs = docs.filter(e => e.source === nodeId || e.target === nodeId);
}
return docs.map(d => ({
id: d.id,
creationUtc: d.creation_utc,
source: d.source,
target: d.target,
condition: d.condition,
metadata: d.metadata,
}));
}
deleteEdge(edgeId: JourneyEdgeId, projectRoot?: string): void {
let edges = readCollection<EdgeDocument>('edges', projectRoot);
const idx = edges.findIndex(e => e.id === edgeId);
if (idx === -1) throw new Error(`Edge '${edgeId}' not found`);
edges.splice(idx, 1);
writeCollection('edges', edges, projectRoot);
}
addTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
const triggers = readCollection<TriggerDocument>('triggers', projectRoot);
if (triggers.find(t => t.journey_id === journeyId && t.trigger === trigger)) {
return false;
}
triggers.push({
id: `trg_${generateId()}`,
journey_id: journeyId,
trigger,
creation_utc: new Date().toISOString(),
});
writeCollection('triggers', triggers, projectRoot);
return true;
}
removeTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
const len = triggers.length;
triggers = triggers.filter(t => !(t.journey_id === journeyId && t.trigger === trigger));
writeCollection('triggers', triggers, projectRoot);
return triggers.length < len;
}
}

View File

@@ -0,0 +1,111 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
export const INDEX_SCHEMA_VERSION = '1.1';
export const GITIGNORE_CONTENT = `# boocode audit runs
/*
!index.json
`;
export interface IndexEntry {
id: string;
type: string;
status: string;
task?: string;
skill?: string;
created?: string;
last_updated?: string;
record_count?: number;
anomaly_count?: number;
max_anomaly_level?: string;
}
export interface IndexFile {
schema_version: string;
entries: IndexEntry[];
}
function findRunsDirFrom(start: string): string {
const explicit = process.env['AUDIT_DOT_DIR']?.trim();
const candidates = explicit ? [explicit] : ['.boo'];
let cur = resolve(start);
while (true) {
for (const basename of candidates) {
const candidate = join(cur, basename, 'runs');
if (existsSync(candidate)) return candidate;
}
const parent = resolve(cur, '..');
if (parent === cur) break;
cur = parent;
}
const defaultBasename = explicit || '.boo';
return join(resolve(start), defaultBasename, 'runs');
}
export function findRunsDir(projectRoot?: string): string {
return findRunsDirFrom(projectRoot || process.cwd());
}
export function ensureRunsDir(projectRoot?: string): string {
const dir = findRunsDir(projectRoot);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
const gitignorePath = join(dir, '.gitignore');
if (!existsSync(gitignorePath)) {
writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
}
}
return dir;
}
export function readCurrentSession(projectRoot?: string): string | null {
const path = join(ensureRunsDir(projectRoot), '.current_session');
try {
return readFileSync(path, 'utf-8').trim();
} catch {
return null;
}
}
export function writeCurrentSession(sessionId: string, projectRoot?: string): void {
writeFileSync(join(ensureRunsDir(projectRoot), '.current_session'), sessionId, 'utf-8');
}
export function clearCurrentSession(projectRoot?: string): void {
const path = join(ensureRunsDir(projectRoot), '.current_session');
try {
writeFileSync(path, '', 'utf-8');
} catch {
// silent
}
}
export function readIndex(projectRoot?: string): IndexFile {
const path = join(ensureRunsDir(projectRoot), 'index.json');
try {
return JSON.parse(readFileSync(path, 'utf-8')) as IndexFile;
} catch {
return { schema_version: INDEX_SCHEMA_VERSION, entries: [] };
}
}
export function writeIndex(index: IndexFile, projectRoot?: string): void {
const runsDir = ensureRunsDir(projectRoot);
writeFileSync(join(runsDir, 'index.json'), JSON.stringify(index, null, 2), 'utf-8');
}
export function updateIndexEntry(entry: IndexEntry, projectRoot?: string): void {
const idx = readIndex(projectRoot);
const existing = idx.entries.find(e => e.id === entry.id);
if (existing) {
Object.assign(existing, entry);
} else {
idx.entries.push({ ...entry });
}
writeIndex(idx, projectRoot);
}
export function findInProgressSessions(projectRoot?: string): IndexEntry[] {
const idx = readIndex(projectRoot);
return idx.entries.filter(e => e.status === 'in_progress');
}

View File

@@ -0,0 +1,236 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
ensureRunsDir,
readCurrentSession,
writeCurrentSession,
clearCurrentSession,
updateIndexEntry,
findInProgressSessions,
readIndex,
type IndexEntry,
} from './runs-dir.js';
export interface SessionJson {
session_id: string;
task: string;
start_time: string;
end_time?: string;
status: 'in_progress' | 'completed';
expected_record_types?: string[];
total_records?: number;
}
export function generateSessionId(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
return `adhoc_${y}${m}${d}_${h}${min}`;
}
export function isoNow(): string {
return new Date().toISOString();
}
export function createSession(
task: string,
sessionId?: string,
projectRoot?: string,
): string {
const sid = sessionId || generateSessionId();
const runsDir = ensureRunsDir(projectRoot);
const sessionDir = join(runsDir, sid);
mkdirSync(sessionDir, { recursive: true });
const session: SessionJson = {
session_id: sid,
task,
start_time: isoNow(),
status: 'in_progress',
expected_record_types: ['data', 'change', 'conversation'],
};
writeFileSync(join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), 'utf-8');
writeCurrentSession(sid, projectRoot);
updateIndexEntry({
id: sid,
type: 'adhoc',
status: 'in_progress',
task,
created: session.start_time,
last_updated: session.start_time,
}, projectRoot);
return sid;
}
export function getSessionDir(sessionId: string, projectRoot?: string): string {
return join(ensureRunsDir(projectRoot), sessionId);
}
export function getActiveSession(projectRoot?: string): SessionJson | null {
const sid = readCurrentSession(projectRoot);
if (!sid) return null;
return readSession(sid, projectRoot);
}
export function readSession(sessionId: string, projectRoot?: string): SessionJson | null {
const path = join(getSessionDir(sessionId, projectRoot), 'session.json');
try {
return JSON.parse(readFileSync(path, 'utf-8')) as SessionJson;
} catch {
return null;
}
}
export function updateSession(
sessionId: string,
updates: Partial<SessionJson>,
projectRoot?: string,
): void {
const session = readSession(sessionId, projectRoot) || { session_id: sessionId, task: '', start_time: isoNow(), status: 'in_progress' as const };
Object.assign(session, updates);
writeFileSync(
join(getSessionDir(sessionId, projectRoot), 'session.json'),
JSON.stringify(session, null, 2),
'utf-8',
);
}
export function endSession(sessionId: string, projectRoot?: string): void {
updateSession(sessionId, { status: 'completed', end_time: isoNow() }, projectRoot);
updateIndexEntry({ id: sessionId, type: 'adhoc', status: 'completed', last_updated: isoNow() }, projectRoot);
clearCurrentSession(projectRoot);
}
export function appendToTrail(sessionId: string, records: Record<string, unknown>[], projectRoot?: string): void {
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
appendFileSync(trailPath, lines, 'utf-8');
}
export function readTrail(sessionId: string, projectRoot?: string): Record<string, unknown>[] {
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
try {
const content = readFileSync(trailPath, 'utf-8').trim();
if (!content) return [];
return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>);
} catch {
return [];
}
}
export interface RecoverySummary {
sessionId: string;
task: string;
recentActivity: IndexEntry[];
userCorrections: Record<string, unknown>[];
unresolvedIssues: string[];
recommendedPriorities: string[];
level: number;
}
export function recoverContext(
sessionId: string,
level: number,
projectRoot?: string,
): RecoverySummary {
const session = readSession(sessionId, projectRoot);
const idx = readIndex(projectRoot);
const recentActivity = idx.entries.slice(-5);
const trail = readTrail(sessionId, projectRoot);
const userCorrections = trail.filter(r => r['action_type'] === 'user_correction');
const summary: RecoverySummary = {
sessionId,
task: session?.task || '(unknown)',
recentActivity,
userCorrections,
unresolvedIssues: [],
recommendedPriorities: [],
level,
};
if (level >= 1) {
const last = trail.slice(-3);
if (last.length > 0) {
summary.recommendedPriorities.push(`Last action: ${JSON.stringify(last[last.length - 1]?.['action'] || 'none')}`);
}
}
if (level >= 3) {
summary.recommendedPriorities.push(`Full trail: ${trail.length} records`);
}
let checkCount = 0;
for (const entry of recentActivity) {
if (entry.status === 'in_progress' && entry.id !== sessionId) {
summary.unresolvedIssues.push(`Unfinished session: ${entry.id} (${entry.task || 'no task'})`);
checkCount++;
if (checkCount >= 3) break;
}
}
return summary;
}
export function checkUnfinishedSessions(projectRoot?: string): IndexEntry[] {
return findInProgressSessions(projectRoot);
}
export function generateSessionSummary(sessionId: string, projectRoot?: string): string {
const session = readSession(sessionId, projectRoot);
const trail = readTrail(sessionId, projectRoot);
const corrections = trail.filter(r => r['action_type'] === 'user_correction');
const changes = trail.filter(r => r['action'] === 'edit_file' || r['action'] === 'create_file' || r['action'] === 'delete_file');
const lines: string[] = [
`# Session Summary | ${sessionId}`,
'',
`## Task: ${session?.task || '(unknown)'}`,
`## Time: ${session?.start_time || '?'}${session?.end_time || 'in_progress'}`,
`## Status: ${session?.status || 'unknown'}`,
'',
'## Completed Work',
];
for (const r of trail) {
if (r['action']) {
lines.push(`- ${r['action']}: ${r['detail'] || r['reason'] || '(no detail)'}`);
}
}
if (corrections.length > 0) {
lines.push('', '## User Corrections');
for (const c of corrections) {
lines.push(`- Original: ${c['original_claim']}`);
lines.push(` Correction: ${c['correction']}`);
if (c['principle_extracted']) {
lines.push(` Principle: ${c['principle_extracted']}`);
}
}
}
if (changes.length > 0) {
lines.push('', '## Files Changed');
const fileSet = new Set<string>();
for (const c of changes) {
const files = c['files'];
if (Array.isArray(files)) {
for (const f of files) fileSet.add(String(f));
}
}
for (const f of fileSet) lines.push(`- ${f}`);
}
lines.push('', '## Stats');
lines.push(`- Total records: ${trail.length}`);
lines.push(`- Corrections: ${corrections.length}`);
lines.push(`- File changes: ${changes.length}`);
return lines.join('\n');
}

View File

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

@@ -57,11 +57,21 @@ interface ConfigLike {
LLAMA_SIDECAR_URL?: string; LLAMA_SIDECAR_URL?: string;
} }
export function resolveRoute(agent: AgentLike | null): RoutingInfo { export function resolveRoute(
agent: AgentLike | null,
config?: ConfigLike,
): RoutingInfo {
// When llama_extra_args are explicitly set, route through sidecar with them.
const flags = agent?.llama_extra_args; const flags = agent?.llama_extra_args;
if (flags && flags.length > 0) { if (flags && flags.length > 0) {
return { route: 'sidecar', flags }; return { route: 'sidecar', flags };
} }
// When LLAMA_SIDECAR_URL is configured (even without per-agent flags),
// route through sidecar to pick up the default base args (cache quant,
// spec decoding, slot save, etc.). Fall back to llama-swap otherwise.
if (config?.LLAMA_SIDECAR_URL) {
return { route: 'sidecar', flags: [] };
}
return { route: 'swap', flags: null }; return { route: 'swap', flags: null };
} }
@@ -70,15 +80,13 @@ export function upstreamModel(
modelId: string, modelId: string,
agent?: AgentLike | null, agent?: AgentLike | null,
): LanguageModel { ): LanguageModel {
const { route, flags } = resolveRoute(agent ?? null); const { route, flags } = resolveRoute(agent ?? null, config);
if (route === 'sidecar') { if (route === 'sidecar') {
const url = config.LLAMA_SIDECAR_URL; const url = config.LLAMA_SIDECAR_URL;
if (!url) { if (!url) {
throw new Error( throw new Error(`Sidecar route selected but LLAMA_SIDECAR_URL is not set`);
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
);
} }
return sidecarProvider(url, flags!).chatModel(modelId); return sidecarProvider(url, (flags ?? [])).chatModel(modelId);
} }
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId); return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
} }

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, buildMessagesPayload,
loadContext, loadContext,
} from './payload.js'; } from './payload.js';
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
import { import {
finalizeCompletion, finalizeCompletion,
finalizeEmpty, finalizeEmpty,
@@ -156,9 +157,20 @@ export async function runAssistantTurn(
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop'); ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
break; break;
} }
const { session: iterSession, project: iterProject, history } = loaded; let { session: iterSession, project: iterProject, history } = loaded;
const projectRoot = await resolveProjectRoot(iterProject.path); 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 // 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 // the schema CHECK but not emitted here — writing to the assistant message
// before the stream phase creates a sequence-0 collision with // 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_codebase_overview',
'get_framework_analysis', 'get_framework_analysis',
'get_semantic_neighborhoods', 'get_semantic_neighborhoods',
'get_blast_radius',
]); ]);
const TOP_N_FILES = 5; 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 type { Agent, Project, Session } from '../types/api.js';
import { getAgentsMtimes } from './agents.js'; import { getAgentsMtimes } from './agents.js';
import { resolveRoute } from './inference/provider.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) => 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.`; `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); let out = BASE_SYSTEM_PROMPT(project.path);
const guidance = await getContainerGuidance(); const guidance = await getContainerGuidance();
if (guidance) { 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) { if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim(); 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 { Project } from '@/pages/Project';
import { Session } from '@/pages/Session'; import { Session } from '@/pages/Session';
import { Settings } from '@/pages/Settings'; import { Settings } from '@/pages/Settings';
import { Analytics } from '@/pages/Analytics';
import { Results } from '@/pages/Results';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents'; import { useUserEvents } from '@/hooks/useUserEvents';
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents'; import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
@@ -95,6 +97,8 @@ function AppShell() {
<Route path="/project/:id" element={<Project />} /> <Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} /> <Route path="/session/:id" element={<Session />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/results" element={<Results />} />
</Routes> </Routes>
</main> </main>
<MobileRightRailBackdrop /> <MobileRightRailBackdrop />

View File

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

View File

@@ -627,3 +627,32 @@ export type WsFrame =
analysis_ready?: boolean; analysis_ready?: boolean;
cross_exam_id?: string; 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react'; import { ChevronDown, ChevronRight, Columns2, GitBranch, ListChevronsDownUp, ListChevronsUpDown, AlignJustify, Pilcrow, RefreshCw, Trash2, WrapText } from 'lucide-react';
import { codeToHtml } from 'shiki'; import { codeToHtml } from 'shiki';
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types'; import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
import { cn } from '@/lib/utils'; 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 { interface WriteProps {
mutating: boolean; mutating: boolean;
@@ -18,12 +23,19 @@ interface Props extends WriteProps {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
mode: GitDiffMode; mode: GitDiffMode;
sessionId?: string;
onSelectMode: (m: GitDiffMode) => void; onSelectMode: (m: GitDiffMode) => void;
onRefresh: () => void; onRefresh: () => void;
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */ /** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
modeSuggestion?: GitDiffMode | null; modeSuggestion?: GitDiffMode | null;
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */ /** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
pendingCount?: number; 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> = { const CHANGE_TYPE_LABELS: Record<string, string> = {
@@ -99,6 +111,12 @@ function FileDiffRow({
onStage, onStage,
onUnstage, onUnstage,
onDiscardRequest, onDiscardRequest,
layout,
wrapLines,
expanded,
onToggleExpand,
sessionId,
diffMode,
}: { }: {
file: GitDiffFile; file: GitDiffFile;
uncommitted: boolean; uncommitted: boolean;
@@ -106,11 +124,21 @@ function FileDiffRow({
onStage: (path: string) => void; onStage: (path: string) => void;
onUnstage: (path: string) => void; onUnstage: (path: string) => void;
onDiscardRequest: (file: GitDiffFile) => 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 [html, setHtml] = useState<string | null>(null);
const [highlighting, setHighlighting] = useState(false); const [highlighting, setHighlighting] = useState(false);
const highlightRef = useRef<HTMLDivElement | null>(null); 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(() => { useEffect(() => {
if (!expanded || !file.diff_body) return; if (!expanded || !file.diff_body) return;
@@ -136,13 +164,27 @@ function FileDiffRow({
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground'; const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
const displayPath = file.old_path ? `${file.old_path}${file.path}` : file.path; 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 ( return (
<li className="border-b border-border/30 last:border-0"> <li className="border-b border-border/30 last:border-0">
<div className="flex items-center group"> <div className="flex items-center group">
<button <button
type="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" 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} aria-expanded={expanded}
> >
{expanded {expanded
@@ -203,6 +245,9 @@ function FileDiffRow({
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked not yet staged</p> <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 && ( {!file.is_binary && !file.is_too_large && file.diff_body && (
layout === 'split' ? (
<DiffSplitView file={file} wrapLines={wrapLines} />
) : (
<> <>
{highlighting && ( {highlighting && (
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting</p> <p className="text-xs text-muted-foreground px-2 py-1">Highlighting</p>
@@ -214,12 +259,40 @@ function FileDiffRow({
/> />
) : ( ) : (
!highlighting && ( !highlighting && (
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre"> <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} {file.diff_body}
</pre> </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}
/>
</> </>
)
)} )}
</div> </div>
)} )}
@@ -242,11 +315,41 @@ export function GitDiffView({
onDiscard, onDiscard,
modeSuggestion, modeSuggestion,
pendingCount, pendingCount,
layout,
wrapLines,
hideWhitespace,
onLayoutChange,
onWrapLinesChange,
onHideWhitespaceChange,
sessionId,
}: Props) { }: Props) {
const [commitMessage, setCommitMessage] = useState(''); const [commitMessage, setCommitMessage] = useState('');
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null); const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
const [lastAction, setLastAction] = useState<string | null>(null); const [lastAction, setLastAction] = useState<string | null>(null);
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | 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) { function flashAction(msg: string) {
setLastAction(msg); setLastAction(msg);
@@ -378,6 +481,83 @@ export function GitDiffView({
</button> </button>
</div> </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 */} {/* Committed-mode base label */}
{result.mode === 'committed' && 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"> <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} onStage={handleStage}
onUnstage={handleUnstage} onUnstage={handleUnstage}
onDiscardRequest={handleDiscardRequest} onDiscardRequest={handleDiscardRequest}
layout={layout}
wrapLines={wrapLines}
expanded={expandedFiles.has(file.path)}
onToggleExpand={handleToggleExpand}
sessionId={sessionId}
diffMode={mode}
/> />
))} ))}
</ul> </ul>

View File

@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
interface InferenceConfig {
cache_type_k: string;
cache_reuse: number;
spec_type: string;
spec_ngram_mod_thsh: number;
ctx_checkpoints: number;
sleep_idle_seconds: number;
metrics_enabled: boolean;
slot_save_path: string;
}
const DEFAULTS: InferenceConfig = {
cache_type_k: 'q4_0',
cache_reuse: 256,
spec_type: 'ngram-mod',
spec_ngram_mod_thsh: 2,
ctx_checkpoints: 32,
sleep_idle_seconds: 600,
metrics_enabled: true,
slot_save_path: '/tmp/llama-slots',
};
function Switch({ checked, onCheckedChange, id }: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
id?: string;
}) {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
checked ? 'bg-primary' : 'bg-muted'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
}`} />
</button>
);
}
function Loader() {
return <div className="text-sm text-muted-foreground py-8 text-center">Loading inference settings...</div>;
}
export function InferenceSettings() {
const [config, setConfig] = useState<InferenceConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch('/api/settings/inference')
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((data) => setConfig(data as InferenceConfig))
.catch(() => {
setConfig({ ...DEFAULTS });
toast.error('Could not load inference config — loading defaults');
})
.finally(() => setLoading(false));
}, []);
function update<K extends keyof InferenceConfig>(key: K, value: InferenceConfig[K]) {
setConfig((prev) => (prev ? { ...prev, [key]: value } : prev));
}
async function save() {
if (!config || saving) return;
setSaving(true);
try {
const res = await fetch('/api/settings/inference', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) throw new Error('Save failed');
const updated = (await res.json()) as InferenceConfig;
setConfig(updated);
toast.success('Inference settings saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
} finally {
setSaving(false);
}
}
if (loading) return <Loader />;
if (!config) return <div className="text-sm text-destructive py-8 text-center">Failed to load</div>;
return (
<div className="space-y-6">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Database className="size-3.5 text-muted-foreground" />
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
KV Cache Quantization
</label>
</div>
<select
value={config.cache_type_k}
onChange={(e) => update('cache_type_k', e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
>
<option value="f32">f32 (full precision)</option>
<option value="f16">f16 (half)</option>
<option value="q8_0">q8_0 (8-bit)</option>
<option value="q4_0">q4_0 (4-bit) recommended</option>
</select>
<p className="text-xs text-muted-foreground/80">
Format for the attention KV cache. Lower = less VRAM. q4_0 gives ~4x savings.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Zap className="size-3.5 text-muted-foreground" />
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Prompt Caching
</label>
</div>
<div className="flex items-center gap-3">
<input
type="number"
min={0}
max={4096}
value={config.cache_reuse}
onChange={(e) => update('cache_reuse', Number(e.target.value))}
className="w-32 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
<span className="text-xs text-muted-foreground">
{config.cache_reuse > 0 ? 'On (min chunk size in tokens)' : 'Disabled'}
</span>
</div>
<p className="text-xs text-muted-foreground/80">
Reuses KV cache across turns when prompt prefix matches. 256 is a good default.
0 = disabled. The local equivalent of prompt caching.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Zap className="size-3.5 text-muted-foreground" />
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Speculative Decoding
</label>
</div>
<select
value={config.spec_type}
onChange={(e) => update('spec_type', e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
>
<option value="off">Off</option>
<option value="ngram-mod">N-gram (lightweight, ~16MB)</option>
<option value="draft-simple">Draft model (requires separate model)</option>
</select>
{config.spec_type === 'ngram-mod' && (
<div className="mt-2 flex items-center gap-3">
<input
type="number"
min={1}
max={10}
value={config.spec_ngram_mod_thsh}
onChange={(e) => update('spec_ngram_mod_thsh', Number(e.target.value))}
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
<span className="text-xs text-muted-foreground">Match threshold (2 = default)</span>
</div>
)}
<p className="text-xs text-muted-foreground/80">
Predicts tokens ahead with a small model; main model verifies in batch.
2-3x speedup on repetitive/code tasks.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Context Checkpoints
</label>
<div className="flex items-center gap-3">
<input
type="number"
min={0}
max={128}
value={config.ctx_checkpoints}
onChange={(e) => update('ctx_checkpoints', Number(e.target.value))}
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
<span className="text-xs text-muted-foreground">
{config.ctx_checkpoints > 0 ? `Max ${config.ctx_checkpoints} checkpoints per slot` : 'Disabled'}
</span>
</div>
<p className="text-xs text-muted-foreground/80">
Prevents context overflow on long conversations. Default: 32.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Clock className="size-3.5 text-muted-foreground" />
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Auto-sleep Timeout
</label>
</div>
<div className="flex items-center gap-3">
<input
type="number"
min={-1}
max={86400}
value={config.sleep_idle_seconds}
onChange={(e) => update('sleep_idle_seconds', Number(e.target.value))}
className="w-24 bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
<span className="text-xs text-muted-foreground">seconds</span>
</div>
<p className="text-xs text-muted-foreground/80">
GPU auto-sleeps after N seconds idle. -1 = disabled. 600 = 10 min.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 className="size-3.5 text-muted-foreground" />
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Prometheus Metrics
</label>
</div>
<Switch
checked={config.metrics_enabled}
onCheckedChange={(v) => update('metrics_enabled', v)}
/>
</div>
<p className="text-xs text-muted-foreground/80">
Enable /metrics endpoint for Prometheus monitoring (token rates, latency).
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Folder className="size-3.5 text-muted-foreground" />
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Slot KV Cache Path
</label>
</div>
<input
type="text"
value={config.slot_save_path}
onChange={(e) => update('slot_save_path', e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm font-mono outline-none focus:border-ring"
/>
<p className="text-xs text-muted-foreground/80">
Directory for disk-persistent KV cache. Idle slot caches are saved here.
</p>
</div>
<div className="flex justify-end border-t pt-4">
<Button onClick={() => void save()} disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
);
}

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 { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import mascot from '@/assets/brand/banner-mascot.png'; import mascot from '@/assets/brand/banner-mascot.png';
@@ -519,11 +519,40 @@ export function ProjectSidebar() {
})} })}
</nav> </nav>
{/* 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 {/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
workspace settings pane via the sessionEvents bus (Session.tsx owns workspace settings pane via the sessionEvents bus (Session.tsx owns
the panesHook). Outside a session there's no workspace to mount the the panesHook). Outside a session there's no workspace to mount the
pane in, so we navigate to /settings (themes page) instead. */} pane in, so we navigate to /settings (themes page) instead. */}
<div className="border-t shrink-0 p-2">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {

View File

@@ -8,6 +8,7 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { useProjectGit } from '@/hooks/useProjectGit'; import { useProjectGit } from '@/hooks/useProjectGit';
import { useGitDiff } from '@/hooks/useGitDiff'; import { useGitDiff } from '@/hooks/useGitDiff';
import { useDiffPreferences } from '@/hooks/useDiffPreferences';
import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { GitDiffView } from '@/components/GitDiffView'; import { GitDiffView } from '@/components/GitDiffView';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -90,6 +91,15 @@ export function RightRail({ projectId, sessionId }: Props) {
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null); 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(() => { const openNewFile = useCallback(() => {
setNewFilePath(''); setNewFilePath('');
setNewFileContent(''); 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) { async function openFile(path: string) {
try { try {
const result = await api.projects.viewFile(projectId, path); 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> <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 <TreeLevel
parentPath="" parentPath=""
@@ -332,6 +404,7 @@ export function RightRail({ projectId, sessionId }: Props) {
depth={0} depth={0}
onToggleDir={toggleDir} onToggleDir={toggleDir}
onSelectFile={(path) => void openFile(path)} onSelectFile={(path) => void openFile(path)}
onEditFile={startEdit}
/> />
)} )}
</div> </div>
@@ -345,6 +418,7 @@ export function RightRail({ projectId, sessionId }: Props) {
loading={gitLoading} loading={gitLoading}
error={gitError} error={gitError}
mode={gitMode} mode={gitMode}
sessionId={sessionId}
onSelectMode={selectMode} onSelectMode={selectMode}
onRefresh={refreshDiff} onRefresh={refreshDiff}
mutating={gitMutating} mutating={gitMutating}
@@ -355,6 +429,12 @@ export function RightRail({ projectId, sessionId }: Props) {
onDiscard={gitDiscard} onDiscard={gitDiscard}
modeSuggestion={gitModeSuggestion} modeSuggestion={gitModeSuggestion}
pendingCount={pendingCount} pendingCount={pendingCount}
layout={diffPrefs.layout}
wrapLines={diffPrefs.wrapLines}
hideWhitespace={diffPrefs.hideWhitespace}
onLayoutChange={(layout) => updateDiffPrefs({ layout })}
onWrapLinesChange={(wrapLines) => updateDiffPrefs({ wrapLines })}
onHideWhitespaceChange={(hideWhitespace) => updateDiffPrefs({ hideWhitespace })}
/> />
)} )}
</aside> </aside>
@@ -421,9 +501,10 @@ interface TreeLevelProps {
depth: number; depth: number;
onToggleDir: (dirPath: string) => void; onToggleDir: (dirPath: string) => void;
onSelectFile: (path: 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 sorted = useMemo(() => {
const copy = [...entries]; const copy = [...entries];
copy.sort((a, b) => { copy.sort((a, b) => {
@@ -447,6 +528,9 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
if (entry.kind === 'dir') onToggleDir(fullPath); if (entry.kind === 'dir') onToggleDir(fullPath);
else onSelectFile(fullPath); else onSelectFile(fullPath);
}} }}
onDoubleClick={() => {
if (entry.kind === 'file') onEditFile?.(fullPath);
}}
> >
{entry.kind === 'dir' ? ( {entry.kind === 'dir' ? (
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" /> 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} depth={depth + 1}
onToggleDir={onToggleDir} onToggleDir={onToggleDir}
onSelectFile={onSelectFile} onSelectFile={onSelectFile}
onEditFile={onEditFile}
/> />
)} )}
</li> </li>

View File

@@ -218,6 +218,16 @@ function ContestantRow({
{isExpanded && ( {isExpanded && (
<div className="border-t border-border/50 bg-muted/10 max-h-[55vh] overflow-y-auto"> <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 ? ( {output.length === 0 ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground"> <div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
{data.status === 'queued' {data.status === 'queued'
@@ -413,6 +423,7 @@ export function ArenaPane({ state, onClose }: Props) {
duration_ms: null, duration_ms: null,
tokens_per_sec: null, tokens_per_sec: null,
cost_tokens: null, cost_tokens: null,
token_breakdown: null,
result_path: null, result_path: null,
error: null, error: null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X } from 'lucide-react'; import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X, Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Project, Session } from '@/api/types'; import type { Project, Session } from '@/api/types';
@@ -15,10 +15,11 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ModelPicker } from '@/components/ModelPicker'; import { ModelPicker } from '@/components/ModelPicker';
import { ThemePicker } from '@/components/ThemePicker'; import { ThemePicker } from '@/components/ThemePicker';
import { InferenceSettings as InferenceSettingsComponent } from '@/components/InferenceSettings';
import { ProvidersSettings } from '@/components/coder/ProvidersSettings'; import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Section = 'session' | 'project' | 'theme' | 'providers'; type Section = 'session' | 'project' | 'theme' | 'providers' | 'inference';
interface Props { interface Props {
session: Session; session: Session;
@@ -74,7 +75,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0"> <div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<div className="flex items-center gap-1 flex-1 min-w-0"> <div className="flex items-center gap-1 flex-1 min-w-0">
{(['session', 'project', 'theme', 'providers'] as const).map((s) => ( {(['session', 'project', 'theme', 'providers', 'inference'] as const).map((s) => (
<button <button
key={s} key={s}
type="button" type="button"
@@ -118,6 +119,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
{activeSection === 'project' && <ProjectSection project={project} />} {activeSection === 'project' && <ProjectSection project={project} />}
{activeSection === 'theme' && <ThemePicker />} {activeSection === 'theme' && <ThemePicker />}
{activeSection === 'providers' && <ProvidersSettings />} {activeSection === 'providers' && <ProvidersSettings />}
{activeSection === 'inference' && <InferenceSettingsComponent />}
</div> </div>
</div> </div>
</div> </div>
@@ -599,3 +601,249 @@ function ProjectSection({ project }: { project: Project }) {
</div> </div>
); );
} }
interface InferenceSettings {
cacheTypeK: string;
cacheReuse: number;
specType: string;
ctxCheckpoints: number;
sleepIdleSeconds: number;
metrics: boolean;
slotSavePath: string;
}
const INFERENCE_DEFAULTS: InferenceSettings = {
cacheTypeK: 'q4_0',
cacheReuse: 256,
specType: 'ngram-mod',
ctxCheckpoints: 32,
sleepIdleSeconds: 600,
metrics: true,
slotSavePath: '/tmp/llama-slots',
};
const STORAGE_KEY = 'boocode-inference-settings';
function InferenceSettings() {
const [settings, setSettings] = useState<InferenceSettings>(INFERENCE_DEFAULTS);
const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setSettings({ ...INFERENCE_DEFAULTS, ...parsed });
}
} catch { /* ignore corrupt storage */ }
setLoaded(true);
}, []);
const dirty = loaded && JSON.stringify(settings) !== (() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.stringify({ ...INFERENCE_DEFAULTS, ...JSON.parse(stored) }) : JSON.stringify(INFERENCE_DEFAULTS);
} catch { return JSON.stringify(INFERENCE_DEFAULTS); }
})();
function update<K extends keyof InferenceSettings>(key: K, value: InferenceSettings[K]) {
setSettings(prev => ({ ...prev, [key]: value }));
}
async function save() {
if (saving) return;
setSaving(true);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
// Simulate API delay
await new Promise(r => setTimeout(r, 300));
toast.success('Inference settings saved. Restart sidecar to apply.');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
async function resetDefaults() {
if (saving) return;
setSaving(true);
try {
setSettings(INFERENCE_DEFAULTS);
localStorage.setItem(STORAGE_KEY, JSON.stringify(INFERENCE_DEFAULTS));
await new Promise(r => setTimeout(r, 300));
toast.success('Reset to defaults');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'reset failed');
} finally {
setSaving(false);
}
}
if (!loaded) return null;
return (
<div className="space-y-6">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Database className="size-3.5 text-muted-foreground" />
<label htmlFor="cache-type-k" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
KV Cache Quantization
</label>
</div>
<select
id="cache-type-k"
value={settings.cacheTypeK}
onChange={(e) => update('cacheTypeK', e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
>
<option value="f32">f32 32-bit (max quality)</option>
<option value="f16">f16 16-bit (balanced)</option>
<option value="q8_0">q8_0 8-bit (efficient)</option>
<option value="q4_0">q4_0 4-bit (max efficiency)</option>
</select>
<p className="text-xs text-muted-foreground italic">
Compresses the attention cache. Lower = less VRAM usage.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Zap className="size-3.5 text-muted-foreground" />
<label htmlFor="cache-reuse" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Cache Reuse (Prompt Caching)
</label>
</div>
<input
id="cache-reuse"
type="number"
min={0}
step={64}
value={settings.cacheReuse}
onChange={(e) => update('cacheReuse', parseInt(e.target.value) || 0)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
<p className="text-xs text-muted-foreground italic">
Minimum chunk size in tokens to reuse across turns. 0 = disabled.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Zap className="size-3.5 text-muted-foreground" />
<label htmlFor="spec-type" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Speculative Decoding
</label>
</div>
<select
id="spec-type"
value={settings.specType}
onChange={(e) => update('specType', e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
>
<option value="off">Off</option>
<option value="ngram-mod">ngram-mod Lightweight (~16MB, no draft model)</option>
<option value="draft-simple">draft-simple Requires separate draft model</option>
</select>
<p className="text-xs text-muted-foreground italic">
Predicts tokens ahead using a small model. Main model verifies in batch for 2-3x speedup.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Database className="size-3.5 text-muted-foreground" />
<label htmlFor="ctx-checkpoints" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Context Checkpoints
</label>
</div>
<input
id="ctx-checkpoints"
type="number"
min={0}
max={256}
value={settings.ctxCheckpoints}
onChange={(e) => update('ctxCheckpoints', parseInt(e.target.value) || 0)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
<p className="text-xs text-muted-foreground italic">
Max checkpoints per slot. 0 = disabled.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Clock className="size-3.5 text-muted-foreground" />
<label htmlFor="sleep-idle" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Sleep Idle
</label>
</div>
<input
id="sleep-idle"
type="number"
min={-1}
step={60}
value={settings.sleepIdleSeconds}
onChange={(e) => update('sleepIdleSeconds', parseInt(e.target.value) || -1)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
<p className="text-xs text-muted-foreground italic">
Auto-sleep after N seconds idle. -1 = disabled.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 className="size-3.5 text-muted-foreground" />
<label htmlFor="metrics" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Metrics Endpoint
</label>
</div>
<Switch
id="metrics"
checked={settings.metrics}
onCheckedChange={(v) => update('metrics', v)}
/>
</div>
<p className="text-xs text-muted-foreground italic">
Exposes Prometheus /metrics endpoint for observability.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Folder className="size-3.5 text-muted-foreground" />
<label htmlFor="slot-save-path" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Slot Save Path
</label>
</div>
<input
id="slot-save-path"
type="text"
value={settings.slotSavePath}
onChange={(e) => update('slotSavePath', e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm font-mono outline-none focus:border-ring"
/>
<p className="text-xs text-muted-foreground italic">
Directory for disk-persistent KV cache. Must be writable.
</p>
</div>
<div className="flex justify-between gap-2 border-t pt-4">
<Button variant="outline" onClick={() => void resetDefaults()} disabled={saving}>
Reset to defaults
</Button>
<Button onClick={() => void save()} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
<p className="text-xs text-muted-foreground border-t pt-4">
Changes apply to new llama-server processes. Restart the sidecar to apply.
These settings are stored locally in your browser.
</p>
</div>
);
}

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