Compare commits

...

3 Commits

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

8 supporting skills: audit-start/end/report-daily/recover + command
variants for slash-command integration.
2026-06-07 22:16:35 +00:00
c132215064 feat(web,server): inference settings UI with per-session inference overrides
Adds Inference tab to SettingsPane with controls for temperature, top-p,
top-k, min-p, and other inference parameters. Server-side route and
provider config wiring to pass overrides through the inference pipeline.
2026-06-07 22:16:29 +00:00
a72f7954b4 feat(web,coder): add analytics + results pages for token usage and run history
New /analytics route: token usage dashboard with aggregate summary,
per-session breakdown, context window stats, and per-category token
distribution. Data served from existing agent_sessions + tool_cost_stats.

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

Sidebar gains Results (ScrollText icon) and Token Analytics (BarChart3
icon) nav buttons above Settings.
2026-06-07 22:16:25 +00:00
31 changed files with 5109 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.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 { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js';
@@ -122,6 +124,8 @@ async function main() {
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
registerToolsRoutes(app, sql);
registerAnalyticsRoutes(app, sql);
registerInferenceSettingsRoutes(app);
// 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,11 +57,21 @@ interface ConfigLike {
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;
if (flags && flags.length > 0) {
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 };
}
@@ -70,15 +80,13 @@ export function upstreamModel(
modelId: string,
agent?: AgentLike | null,
): LanguageModel {
const { route, flags } = resolveRoute(agent ?? null);
const { route, flags } = resolveRoute(agent ?? null, config);
if (route === 'sidecar') {
const url = config.LLAMA_SIDECAR_URL;
if (!url) {
throw new Error(
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
);
throw new Error(`Sidecar route selected 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);
}

View File

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

View File

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

View File

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

View File

@@ -423,6 +423,7 @@ export function ArenaPane({ state, onClose }: Props) {
duration_ms: null,
tokens_per_sec: null,
cost_tokens: null,
token_breakdown: null,
result_path: null,
error: null,
created_at: new Date().toISOString(),

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
---
name: audit-end
description: End an audit session with integrity checks and summary. Use when the user says "/end", "done", "pause", or when the current task is complete.
---
# /end — Audit Session End + Integrity Check
## Trigger
```
/end
```
## Steps
### 1. Determine current session
Read `.boo/runs/.current_session` for session_id.
If missing:
- Check for `auto_` sessions (hook-created)
- If none, report "No active session"
### 2. Collect audit data
Sources:
- `.boo/runs/audit_buffer.jsonl` — hook-recorded Write/Edit/Bash ops
- `.boo/runs/audit_pending.jsonl` — agent [AUDIT] blocks
- `.boo/runs/{session_id}/audit_trail.jsonl` — previously flushed records
Steps:
1. Read buffer + pending remaining data
2. Append to `audit_trail.jsonl`
3. Clear buffer + pending files
### 3. Extract user corrections
Scan audit_trail for `user_correction` records:
```json
{
"record_type": "conversation",
"action_type": "user_correction",
"priority": "critical_for_recovery",
"timestamp": "<ISO 8601>",
"original_claim": "<what agent said>",
"correction": "<what user corrected>",
"principle_extracted": "<general principle>",
"persisted_to": ["CLAUDE.md", ".boo/guidelines/..."]
}
```
### 4. Integrity checks
| Check | Condition | Fail |
|-------|-----------|------|
| Has records | audit_trail lines > 0 | ⚠️ "Zero audit records" |
| Files covered | Write/Edit entries exist for modified files | ⚠️ List uncovered files |
| Corrections persisted | persisted_to is non-empty for each correction | ⚠️ Remind to persist |
Output:
```
=== Session Audit Check ===
Session: <id>
Task: <task>
Duration: <start → end>
[✅] Records: N
[⚠️] Files not in audit: <list>
[✅] Corrections persisted: M
```
### 5. Generate session summary
Write `.boo/runs/{session_id}/session_summary.md`:
```
# Session Summary | <id>
## Task: <description>
## Time: <start → end>
## Status: completed
## Completed
- <action list>
## User Corrections
- <correction records>
## Stats
- Records: N
- Corrections: M
```
### 6. Update state
- Set `session.json status = "completed", end_time = now()`
- Update `index.json` entry
- Clear `.current_session`
## Notes
- Save even if checks find problems — recording > perfection
- ⚠️ = don't block save; ❌ = warn user, still save
- /end itself may trigger one more Stop hook flush — normal

View File

@@ -0,0 +1,84 @@
---
name: audit-recover
description: Restore lost context from audit trail. Use when unsure of prior decisions, can't remember what was discussed, or the user says "/recover". Do not guess — check the records.
---
# /recover — Context Recovery
## Trigger
```
/recover # L0+L1+L2 (current session)
/recover full # L3 (full trail)
/recover {session_id} # specific session
```
## Core principle
**When uncertain, check the audit trail. Do not work from memory.**
Recovering from records is the only reliable way to avoid repeating corrected mistakes.
## When to trigger
| Signal | What to do |
|--------|-----------|
| Can't recall session details | Run /recover |
| Unsure about current task | Run /recover |
| About to propose something possibly corrected | Run /recover, check corrections |
| Answer is vague, missing specifics | Run /recover full |
## Steps
### Graded loading
**Level 0 — Index (~200t)**
Read `.boo/runs/index.json` → last 5 entries (id, task, status)
**Level 1 — Task state (~500t)**
Read `.current_session` → session_id
Read `session.json` → task, start_time
Read last 3 `audit_trail.jsonl` entries → "where am I"
**Level 2 — User corrections (~1000t) ⚠️ HIGHEST PRIORITY**
Scan all audit_trail files for `user_correction` records
Scan for `conclusion` entries
Read latest daily report §4 (anomalies) + §6 (backlog)
**Level 3 — Full context (~3000t, /recover full only)**
Full `audit_trail.jsonl`
Full `audit_pending.jsonl`
### Output
```
=== Context Recovery Report ===
Source: .boo/runs/<session_id>/
Level: L2
Task: <session.task>
Status: <last action>
⚠️ User corrections (must follow):
1. <timestamp> Original: "..."
Correction: "..."
Principle: <principle>
Key conclusions:
- <...>
Open issues:
- <...>
⚠️ Recovered from audit trail, not memory.
```
## Notes
- Corrections have highest priority — don't contradict them
- If current plan contradicts corrections, correct the plan
- Keep output concise — don't copy entire trail into context
- Recover "why" and "don't" before "what was done"

View File

@@ -0,0 +1,100 @@
---
name: audit-report-daily
description: Generate a daily work report from audit data. Every number traces to a source file. Use when user says "/report-daily", "daily report", "what did I do today".
---
# /report-daily — Audit-Driven Daily Report
## Trigger
```
/report-daily # today
/report-daily 20260319 # specific date
/report-daily review # with morning self-review
```
## Data Sources
| Section | Source |
|---------|--------|
| Task overview | `.boo/runs/index.json` |
| Operation stats | `*/audit_trail.jsonl` tool records |
| Changes | trail entries with edit/create/delete |
| User feedback | `user_correction` entries in trail |
| Anomalies | `*/anomalies.json` |
| Backlog | previous day's daily report §6 |
Every number must trace to a file. Do not fill from memory.
## Steps
### 1. Collect data
1. Read index.json, filter sessions for target date
2. Read each session's audit_trail.jsonl
3. Read pending (unflushed data)
4. Read previous day's report §6 (backlog) if exists
### 2. Generate report
Write to `.boo/runs/daily/{YYYYMMDD}_daily.md`:
```
# Daily Report | <DATE>
> Source: .boo/runs/index.json + audit_trails
## 1. Task Overview
| # | Type | Session | Task | Status | Records |
## 2. Operation Stats
| Metric | Count |
|--------|-------|
| Write/Edit | N |
| Bash | N |
| AUDIT blocks | N |
## 3. Changes
| Time | File | Change |
## 4. User Feedback & Corrections
| Feedback | Persisted To |
## 5. Anomaly Alerts
- <alerts from anomalies.json>
## 6. Backlog
- previous day's todos
- current status
## 7. Integrity
- All sessions have records: ✅/❌
- Corrections persisted: ✅/❌
```
### 3. If /report-daily review
After report, additionally:
1. Check: yesterday's anomalies all addressed?
2. Check: user feedback converted to improvements?
3. Check: backlog items completed?
4. Write `.boo/runs/daily/{YYYYMMDD}_morning_review.md`
5. Output recommended priorities for today
```
=== Morning Self-Review ===
Trend: <up/down/flat compared to last 3 days>
Anomalies resolved: N/M
Backlog cleared: N/M
Recommended priorities:
1. <...>
2. <...>
```
## Notes
- If no sessions today, generate empty report with "No activity"
- Report itself should write one [AUDIT] block
- Historical reports are append-only — corrections go in new report
- Every number must cite its source file

View File

@@ -0,0 +1,85 @@
---
name: audit-start
description: Create an audit session with context recovery. Use when beginning a new task, before making changes, or when the user says "/start". Ensures all subsequent work is tracked in a recoverable session.
---
# /start — Audit Session + Context Recovery
## Trigger
```
/start "task description"
```
## Why
Every work session should be tracked. Without a session:
- Hooks output to an auto_ session with no task description
- /end can't run integrity checks
- Daily reports lack task context
/start costs one directory + one JSON file. The return is traceability.
## Steps
### 1. Create the session
1. Generate `session_id = adhoc_YYYYMMDD_HHMM`
2. `mkdir -p .boo/runs/{session_id}`
3. Write `session.json`:
```json
{
"session_id": "<id>",
"task": "<user description>",
"start_time": "<ISO 8601>",
"status": "in_progress",
"expected_record_types": ["data", "change", "conversation"]
}
```
4. Write `.boo/runs/.current_session` with session_id (hook handshake)
### 2. Context recovery
**Level 0 — Index**:
- Read `.boo/runs/index.json` → last 5 entries (id, task, status)
**Level 2 — User corrections (critical)**:
- Scan recent `audit_trail.jsonl` files for `user_correction` records
- These must be surfaced first — repeating corrected mistakes wastes effort
**Level 1 — Task state**:
- Read latest `.boo/runs/daily/*_daily.md` if it exists (§4 anomalies, §6 backlog)
- Read latest `*_morning_review.md` if it exists
### 3. Check unfinished sessions
- Scan `.boo/runs/` session dirs for `session.json` with `status: "in_progress"`
- If found, propose: continue existing session or start fresh
### 4. Output recovery summary
```
Audit session: adhoc_20260320_1400
Task: <description>
Context recovery:
Recent activity:
- <last 3 completed tasks>
⚠️ User corrections (must follow):
- <all user_correction records>
Unresolved:
- <unfinished sessions, open alerts>
Today's priorities:
- <recommendations>
```
## Notes
- If `.boo/runs/` doesn't exist, create it
- If no history, start clean — no errors
- session_id stays constant for the whole session; all [AUDIT] blocks share it
- If `.current_session` already points at an active session, ask before replacing

View File

@@ -0,0 +1,61 @@
---
name: command-end
description: End the current audit session, flush remaining buffer data, run integrity checks, and generate a session summary. Use when finishing a task, taking a break, or ending work. Examples: "end", "finish session", "end session", "wrap up", "/end".
allowed-tools:
- Read
- Glob
- Grep
- Bash
- Write
---
# /end — Audit Session End + Integrity Check
## Trigger
```
/end
```
## Steps
### 1. Find current session
Read `.boo/runs/.current_session` for the session_id.
If absent, check for auto-created sessions. If none, report "No active session."
### 2. Collect remaining audit data
Read `.boo/runs/audit_buffer.jsonl` and `audit_pending.jsonl` for any data the Stop hook hasn't flushed yet. Append both to `.boo/runs/{session_id}/audit_trail.jsonl`, then clear the buffer files.
### 3. Extract user corrections
Scan `audit_trail.jsonl` for `user_correction` entries. Each should have a non-empty `persisted_to` array. If any are unpersisted, flag them.
### 4. Integrity checks
| Check | Source | Pass | Fail |
|-------|--------|------|------|
| Has records | trail line count | > 0 | Warn |
| Files tracked | tool=Write/Edit entries | Every changed file has an entry | Warn |
| Corrections persisted | user_correction entries | persisted_to non-empty | Warn |
### 5. Generate summary
Write `.boo/runs/{session_id}/session_summary.md`:
```markdown
# Session Summary | {session_id}
## Task: {description}
## Time: {start} → {end}
## Status: completed
Completed work: {action list}
Key conclusions: {output entries}
User corrections: {correction records}
```
### 6. Close
Update `session.json`: status=completed, end_time=now. Update `index.json`. Delete `.current_session`.

View File

@@ -0,0 +1,61 @@
---
name: command-recover
description: Recover lost context from audit session records. Use when you can't remember earlier discussion, aren't sure about task progress, or need to check what the user has corrected before. Also use when your answers feel vague — don't guess, recover. Examples: "recover", "what was I doing", "recap", "what did we discuss", "/recover".
allowed-tools:
- Read
- Glob
- Grep
---
# /recover — Context Recovery
## Trigger
```
/recover # L0+L1+L2 (current session)
/recover full # L3 (full audit_trail)
/recover {session_id} # Specific session
```
## When to use
**Do not work from memory — query the audit trail when:**
- You can't recall what was decided earlier
- Unsure what phase the task is in
- About to propose something the user may have already corrected
- Answers feel generic (missing file names, specific numbers)
## Graded loading
### L0 — Index (~200t)
Read `.boo/runs/index.json` → last 5 entries (id, task, status)
### L1 — Task state (~500t)
Read `.current_session` → session.json → last 3 audit_trail entries
### L2 — User corrections + decisions (~1000t) ⚠️ MOST IMPORTANT
Scan ALL audit_trails for `user_correction` records + conclusions
Read daily report §4 (anomalies) + §6 (backlog)
### L3 — Full context (~3000t, /recover full only)
Complete audit_trail.jsonl + audit_pending.jsonl
## Output
```
=== Context Recovery Report ===
Level: L2
Source: .boo/runs/{session_id}/
Current task: {description}
Progress: {last action}
USER CORRECTIONS (must follow):
1. [{time}] {original claim} → {correction}
Principle: {principle_extracted}
Key conclusions: ...
Unresolved: ...
Source: audit records (not memory)
```

View File

@@ -0,0 +1,70 @@
---
name: command-report-daily
description: Generate a data-driven daily work report from audit session records. Every number is traceable to `.boo/runs/` files. Use for daily standup, progress tracking, or morning review. Examples: "daily report", "report today", "what did I do today", "generate report", "/report-daily".
allowed-tools:
- Read
- Glob
- Grep
- Bash
- Write
---
# /report-daily — Audit-Driven Work Report
## Trigger
```
/report-daily # Today
/report-daily 20260319 # Specific date
/report-daily review # Report + morning self-review
```
## Data sources (every number must be traceable)
| Section | Source |
|---------|--------|
| Task overview | `.boo/runs/index.json` |
| Operation stats | `*/audit_trail.jsonl` tool records |
| Changes | trail entries with Write/Edit |
| User feedback | user_correction entries |
| Anomalies | `*/anomalies.json` (if any) |
| Backlog | previous day's daily report |
## Sections
### I. Task Overview
Table of today's sessions with status and record count.
### II. Operation Stats
Write/Edit count, Bash count, Audit block count.
### III. Change Records
Timeline of file modifications with timestamps.
### IV. User Feedback & Corrections
User corrections made today, with persistence status.
### V. Anomaly Alerts
Unresolved issues flagged across sessions.
### VI. Backlog Tracking
Carry-over items from yesterday.
### VII. Integrity Summary
Health checks for all sessions.
## Output
Save to `.boo/runs/daily/{YYYYMMDD}_daily.md`.
### Review variant (`review`)
After the report, also generate `.boo/runs/daily/{YYYYMMDD}_morning_review.md` with:
- Self-correction check (anomalies resolved? feedback persisted? backlog handled?)
- Recommended priorities for today
## Notes
- If no sessions for the date, generate an empty report labeled "No activity"
- Reports are append-only — correct errors with a follow-up report, never edit
- Record one [AUDIT] block for report generation itself

View File

@@ -0,0 +1,72 @@
---
name: command-start
description: Create an audit session with context recovery before starting work. Use when beginning a new task, analysis, or code modification — establishes a session for tracking tool usage, user corrections, and decisions. Examples: "start", "begin session", "start work", "/start 'migrate database schema'".
allowed-tools:
- Read
- Glob
- Grep
- Bash
- Write
---
# /start — Audit Session Start + Context Recovery
## Trigger
```
/start "task description"
```
## Why
Every task should run in an audit session. Without one:
- Tool-use data goes to unnamed sessions with no task context
- `/end` can't run integrity checks
- Daily reports miss task descriptions
## Steps
### 1. Create session
Generate `session_id = adhoc_YYYYMMDD_HHMM`, create `.boo/runs/{session_id}/`, write `session.json`:
```json
{
"session_id": "adhoc_20260320_1400",
"task": "task description",
"start_time": "ISO 8601",
"status": "in_progress",
"expected_record_types": ["data", "change", "conversation"]
}
```
Write session_id to `.boo/runs/.current_session` (the Stop hook reads this for buffer archiving).
### 2. Context recovery (L0 + L2)
**L0 — Index summary**: read `.boo/runs/index.json`, last 5 entries.
**L2 — User corrections (critical)**: scan all `audit_trail.jsonl` and `audit_pending.jsonl` for `user_correction` records — these must be restored first to avoid repeating mistakes.
**Check for unfinished sessions**: scan `.boo/runs/adhoc_*/session.json` for `status: "in_progress"`. If found, prompt the user to continue the old session instead.
### 3. Output summary
```
Session created: adhoc_20260320_1400
Task: {description}
Context recovery:
Recent activity: {last 3 completed tasks}
User corrections (must follow): {all user_correction records}
Unresolved issues: {open anomalies/alerts}
Today's priorities: {from morning review}
All [AUDIT] blocks use batch_id = {session_id}
```
### 4. Notes
- If `.boo/runs/` doesn't exist, create it and skip recovery
- If `.current_session` already points to an in_progress session, prompt before creating a new one
- The session_id stays constant for the whole session — all [AUDIT] blocks share it