Compare commits
28 Commits
v2.7.21-ed
...
v2.8.5-aud
| Author | SHA1 | Date | |
|---|---|---|---|
| 876c9bcd02 | |||
| c132215064 | |||
| a72f7954b4 | |||
| 31d8efe66a | |||
| c935687725 | |||
| 0d6e9a2413 | |||
| 6344105877 | |||
| 028c08b4cd | |||
| fb52eb3efa | |||
| 648a59a563 | |||
| 7f59f30f2d | |||
| f436021bf9 | |||
| bef6bef504 | |||
| 87923cb07b | |||
| c6ecd984c5 | |||
| 2a83f61070 | |||
| 44874f0097 | |||
| 1b70d41996 | |||
| b64941ad4b | |||
| cdc782e044 | |||
| 02bb355a09 | |||
| b8b2666fdc | |||
| ee749d8698 | |||
| bc83475a3d | |||
| 214cc32ac2 | |||
| 6b7c2bab1e | |||
| 373ba86e5d | |||
| 9106334e70 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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 2–6 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 2–6 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal 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.';
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
42
apps/coder/src/plugins/host.ts
Normal file
42
apps/coder/src/plugins/host.ts
Normal 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();
|
||||||
|
}
|
||||||
78
apps/coder/src/routes/analytics.ts
Normal file
78
apps/coder/src/routes/analytics.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|||||||
747
apps/coder/src/services/audit-session.ts
Normal file
747
apps/coder/src/services/audit-session.ts
Normal 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));
|
||||||
|
}
|
||||||
186
apps/coder/src/services/correction-service.ts
Normal file
186
apps/coder/src/services/correction-service.ts
Normal 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');
|
||||||
|
}
|
||||||
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
47
apps/coder/src/services/edit-guards-imports.ts
Normal 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: [] };
|
||||||
|
}
|
||||||
42
apps/coder/src/services/edit-guards.ts
Normal file
42
apps/coder/src/services/edit-guards.ts
Normal 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'}`;
|
||||||
|
}
|
||||||
23
apps/coder/src/services/flow-artifacts.ts
Normal file
23
apps/coder/src/services/flow-artifacts.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
560
apps/coder/src/services/guideline-service.ts
Normal file
560
apps/coder/src/services/guideline-service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
apps/coder/src/services/lsp/client.ts
Normal file
75
apps/coder/src/services/lsp/client.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/coder/src/services/lsp/config.ts
Normal file
19
apps/coder/src/services/lsp/config.ts
Normal 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;
|
||||||
|
}
|
||||||
86
apps/coder/src/services/lsp/operations.ts
Normal file
86
apps/coder/src/services/lsp/operations.ts
Normal 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 }));
|
||||||
|
}
|
||||||
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
119
apps/coder/src/services/lsp/server-manager.ts
Normal 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();
|
||||||
28
apps/coder/src/services/lsp/types.ts
Normal file
28
apps/coder/src/services/lsp/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal 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;
|
||||||
|
}
|
||||||
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
35
apps/coder/src/services/token-analysis/persist.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal 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') };
|
||||||
|
},
|
||||||
|
};
|
||||||
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal 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')}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal 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}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
33
apps/server/src/routes/analytics.ts
Normal file
33
apps/server/src/routes/analytics.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
55
apps/server/src/routes/inference-settings.ts
Normal file
55
apps/server/src/routes/inference-settings.ts
Normal 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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
52
apps/server/src/services/audit/corrections.ts
Normal file
52
apps/server/src/services/audit/corrections.ts
Normal 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;
|
||||||
|
}
|
||||||
251
apps/server/src/services/audit/guideline-store.ts
Normal file
251
apps/server/src/services/audit/guideline-store.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/server/src/services/audit/index.ts
Normal file
68
apps/server/src/services/audit/index.ts
Normal 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';
|
||||||
189
apps/server/src/services/audit/journey-projection.ts
Normal file
189
apps/server/src/services/audit/journey-projection.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
360
apps/server/src/services/audit/journey-store.ts
Normal file
360
apps/server/src/services/audit/journey-store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/server/src/services/audit/runs-dir.ts
Normal file
111
apps/server/src/services/audit/runs-dir.ts
Normal 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');
|
||||||
|
}
|
||||||
236
apps/server/src/services/audit/session-manager.ts
Normal file
236
apps/server/src/services/audit/session-manager.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -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 ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
apps/server/src/services/inference/dcp/index.ts
Normal file
4
apps/server/src/services/inference/dcp/index.ts
Normal 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';
|
||||||
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
34
apps/server/src/services/inference/dcp/messages.ts
Normal 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 } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
27
apps/server/src/services/inference/dcp/state.ts
Normal file
27
apps/server/src/services/inference/dcp/state.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
|
}
|
||||||
52
apps/server/src/services/inference/dcp/transform.ts
Normal file
52
apps/server/src/services/inference/dcp/transform.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
68
apps/server/src/services/inference/loop-detectors.ts
Normal file
68
apps/server/src/services/inference/loop-detectors.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
45
apps/server/src/services/inference/tool-shim.ts
Normal file
45
apps/server/src/services/inference/tool-shim.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal file
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal file
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal file
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/server/src/services/memory/bm25.ts
Normal file
67
apps/server/src/services/memory/bm25.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/server/src/services/memory/embeddings.ts
Normal file
55
apps/server/src/services/memory/embeddings.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/server/src/services/memory/entries.ts
Normal file
54
apps/server/src/services/memory/entries.ts
Normal 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;
|
||||||
|
}
|
||||||
6
apps/server/src/services/memory/index.ts
Normal file
6
apps/server/src/services/memory/index.ts
Normal 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';
|
||||||
17
apps/server/src/services/memory/paths.ts
Normal file
17
apps/server/src/services/memory/paths.ts
Normal 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 })));
|
||||||
|
}
|
||||||
5
apps/server/src/services/memory/prompt.ts
Normal file
5
apps/server/src/services/memory/prompt.ts
Normal 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>`;
|
||||||
|
}
|
||||||
100
apps/server/src/services/memory/recall.ts
Normal file
100
apps/server/src/services/memory/recall.ts
Normal 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';
|
||||||
72
apps/server/src/services/memory/scan.ts
Normal file
72
apps/server/src/services/memory/scan.ts
Normal 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 });
|
||||||
|
}
|
||||||
35
apps/server/src/services/memory/store.ts
Normal file
35
apps/server/src/services/memory/store.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
31
apps/server/src/services/tools/codecontext/get_call_graph.ts
Normal file
31
apps/server/src/services/tools/codecontext/get_call_graph.ts
Normal 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 };
|
||||||
@@ -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 };
|
||||||
44
apps/server/src/services/tools/extract_memory.ts
Normal file
44
apps/server/src/services/tools/extract_memory.ts
Normal 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}/`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
40
apps/server/src/services/tools/search_memory.ts
Normal file
40
apps/server/src/services/tools/search_memory.ts
Normal 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')}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
206
apps/web/src/components/DiffSplitView.tsx
Normal file
206
apps/web/src/components/DiffSplitView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
271
apps/web/src/components/InferenceSettings.tsx
Normal file
271
apps/web/src/components/InferenceSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/src/components/InlineReviewEditor.tsx
Normal file
60
apps/web/src/components/InlineReviewEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal file
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
apps/web/src/components/InlineReviewThread.tsx
Normal file
92
apps/web/src/components/InlineReviewThread.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
68
apps/web/src/hooks/useDiffPreferences.ts
Normal file
68
apps/web/src/hooks/useDiffPreferences.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
454
apps/web/src/pages/Analytics.tsx
Normal file
454
apps/web/src/pages/Analytics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
510
apps/web/src/pages/Results.tsx
Normal file
510
apps/web/src/pages/Results.tsx
Normal 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
Reference in New Issue
Block a user