Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

### Added

- **Derived analytics archive (foundation)** (#40). New `~/.relayburn/archive.sqlite` materialized read model alongside the canonical `ledger.jsonl` — the architectural gap between burn as an event collector and burn as a usable local analytics system. The archive is disposable: `rm archive.sqlite && burn archive rebuild` always reproduces the same state from the ledger. This PR lands the schema (`sessions`, `turns`, `tool_calls`, `compactions`, plus a reserved `tool_result_events` table for the future content-sidecar bridge), incremental build keyed off ledger byte offset, and the `burn archive build | rebuild | status` command group. Rewiring `burn summary` / `compare` / `plans` / `@relayburn/mcp` onto SQL queries lands in follow-up PRs. Backed by `node:sqlite` so no native build step.
- `@relayburn/ledger` — `buildArchive`, `rebuildArchive`, `getArchiveStatus`, `openArchive`, `archivePath`, `ARCHIVE_VERSION`, `ArchiveStatus`, `BuildResult`.
- `@relayburn/cli` — `burn archive build | rebuild | status` (with `--json`).
- **`@relayburn/mcp` package + `burn mcp-server`** (#26). Closes the loop between observation and decision: a running agent can self-query its own cost and quota state mid-session via MCP and adjust behavior (downgrade model, defer expensive subagent, abort) before hitting the 5-hour wall. None of the surveyed competitors do this — ccusage's MCP is for user-query, not agent-self-query.
- `@relayburn/mcp` (new) — minimal JSON-RPC 2.0 stdio MCP server with no external SDK runtime dep. Exports `startStdioServer`, `buildMcpConfig({sessionId})`, `createSessionCostTool`, `createCurrentBlockTool`. Registers `burn__sessionCost` and `burn__currentBlock`. Read-only by construction.
- `@relayburn/cli` — `burn mcp-server [--session-id <uuid>]` subcommand. Spawners use `buildMcpConfig` to inject the server into `claude --mcp-config <…>` so the registered tools default to the running session.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`burn archive build | rebuild | status`** — manage the new derived analytics archive (`~/.relayburn/archive.sqlite`). `build` applies any ledger tail not yet materialized, `rebuild` recreates from scratch, `status` reports schema version, row counts, and sync state. Both `build` and `status` accept `--json`. The archive is rebuildable from the canonical `ledger.jsonl` at any time, so `rm ~/.relayburn/archive.sqlite && burn archive rebuild` always reproduces the same state. Foundation for #40; rewiring `burn summary` / `compare` / `plans` to read from the archive lands in follow-up PRs.
- **`burn mcp-server`** — stdio MCP (Model Context Protocol) server that lets a running agent self-query its own cost and quota state mid-session. Registers `burn__sessionCost` and `burn__currentBlock`. Read-only. Pair with `buildMcpConfig({sessionId})` from `@relayburn/mcp` to inject the server into a spawned `claude --mcp-config <…>` session. (#26)

## [0.11.0] - 2026-04-25
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { parseArgs } from './args.js';
import { runArchive } from './commands/archive.js';
import { runByTool } from './commands/by-tool.js';
import { runClaudeWrapper } from './commands/claude.js';
import { runCodexWrapper } from './commands/codex.js';
Expand Down Expand Up @@ -36,6 +37,7 @@ Usage:
burn ingest --runtime claude [--quiet] (reads hook payload on stdin)
burn mcp-server [--session-id <uuid>] (stdio MCP server for in-session self-query)
burn content prune [--days <n>] [--force]
burn archive build | rebuild | status [--json]
burn rebuild --index | --reclassify [--force]
burn rebuild-index (alias for 'burn rebuild --index')

Expand All @@ -61,6 +63,9 @@ Examples:
burn codex --tag workflow=refactor
burn opencode --tag workflow=refactor
burn content prune --days 30
burn archive status
burn archive build
burn archive rebuild
burn rebuild --reclassify
`;

Expand Down Expand Up @@ -105,6 +110,8 @@ async function main(): Promise<number> {
return runMcpServer(args);
case 'content':
return runContent(args);
case 'archive':
return runArchive(args);
case 'rebuild':
return runRebuild(args);
case 'rebuild-index':
Expand Down
148 changes: 148 additions & 0 deletions packages/cli/src/commands/archive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { strict as assert } from 'node:assert';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { after, beforeEach, describe, it } from 'node:test';

import { appendTurns, stamp } from '@relayburn/ledger';
import type { TurnRecord } from '@relayburn/reader';

import { runArchive } from './archive.js';

function fakeTurn(overrides: Partial<TurnRecord> = {}): TurnRecord {
return {
v: 1,
source: 'claude-code',
sessionId: 's-1',
messageId: 'msg-1',
turnIndex: 0,
ts: '2026-04-20T00:00:00.000Z',
model: 'claude-sonnet-4-6',
usage: {
input: 100,
output: 50,
reasoning: 0,
cacheRead: 1000,
cacheCreate5m: 0,
cacheCreate1h: 0,
},
toolCalls: [],
project: '/tmp/project',
...overrides,
};
}

interface CapturedOutput {
stdout: string;
stderr: string;
code: number;
}

async function captureRun(
flags: Record<string, string | true>,
positional: string[] = [],
): Promise<CapturedOutput> {
const origStdout = process.stdout.write.bind(process.stdout);
const origStderr = process.stderr.write.bind(process.stderr);
let stdout = '';
let stderr = '';
process.stdout.write = ((chunk: string | Uint8Array): boolean => {
stdout += typeof chunk === 'string' ? chunk : chunk.toString();
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array): boolean => {
stderr += typeof chunk === 'string' ? chunk : chunk.toString();
return true;
}) as typeof process.stderr.write;
let code: number;
try {
code = await runArchive({ flags, tags: {}, positional, passthrough: [] });
} finally {
process.stdout.write = origStdout;
process.stderr.write = origStderr;
}
return { stdout, stderr, code };
}

describe('burn archive CLI', () => {
let tmp: string;
const originalRelay = process.env['RELAYBURN_HOME'];

beforeEach(async () => {
tmp = await mkdtemp(path.join(tmpdir(), 'burn-archive-cli-'));
process.env['RELAYBURN_HOME'] = tmp;
});

after(async () => {
if (originalRelay !== undefined) process.env['RELAYBURN_HOME'] = originalRelay;
else delete process.env['RELAYBURN_HOME'];
await rm(tmp, { recursive: true, force: true });
});

it('archive status reports "not built yet" before the first build', async () => {
const out = await captureRun({}, ['status']);
assert.equal(out.code, 0);
assert.match(out.stdout, /not built yet/);
});

it('archive build materializes the ledger and status reflects row counts', async () => {
await appendTurns([
fakeTurn({ sessionId: 's-cli', messageId: 'cli-1' }),
fakeTurn({
sessionId: 's-cli',
messageId: 'cli-2',
turnIndex: 1,
ts: '2026-04-20T00:01:00.000Z',
}),
]);
await stamp({ sessionId: 's-cli' }, { workflowId: 'wf-cli' });

const buildOut = await captureRun({}, ['build']);
assert.equal(buildOut.code, 0);
assert.match(buildOut.stdout, /archive build complete/);
assert.match(buildOut.stdout, /2 turns applied/);

const statusOut = await captureRun({ json: true }, ['status']);
assert.equal(statusOut.code, 0);
const parsed = JSON.parse(statusOut.stdout) as {
exists: boolean;
upToDate: boolean;
rowCounts: { sessions: number; turns: number };
};
assert.equal(parsed.exists, true);
assert.equal(parsed.upToDate, true);
assert.equal(parsed.rowCounts.turns, 2);
assert.equal(parsed.rowCounts.sessions, 1);
});

it('archive rebuild is idempotent against the same ledger', async () => {
await appendTurns([fakeTurn({ sessionId: 's-rb', messageId: 'rb-1' })]);
await captureRun({}, ['build']);
const before = await captureRun({ json: true }, ['status']);
const beforeStatus = JSON.parse(before.stdout) as {
rowCounts: { turns: number };
};

const rebuildOut = await captureRun({}, ['rebuild']);
assert.equal(rebuildOut.code, 0);
assert.match(rebuildOut.stdout, /rebuilt archive/);

const after = await captureRun({ json: true }, ['status']);
const afterStatus = JSON.parse(after.stdout) as {
rowCounts: { turns: number };
};
assert.equal(afterStatus.rowCounts.turns, beforeStatus.rowCounts.turns);
});

it('archive with no subcommand prints help and exits 0', async () => {
const out = await captureRun({}, []);
assert.equal(out.code, 0);
assert.match(out.stdout, /burn archive/);
});

it('archive with unknown subcommand exits non-zero', async () => {
const out = await captureRun({}, ['nope']);
assert.notEqual(out.code, 0);
assert.match(out.stderr, /unknown subcommand/);
});
});
113 changes: 113 additions & 0 deletions packages/cli/src/commands/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
buildArchive,
getArchiveStatus,
rebuildArchive,
type BuildResult,
} from '@relayburn/ledger';

import { formatInt } from '../format.js';
import type { ParsedArgs } from '../args.js';

const ARCHIVE_HELP = `burn archive — derived analytics archive (SQLite read model)

Usage:
burn archive build Apply any ledger tail not yet materialized.
burn archive rebuild Drop the archive and rebuild from the ledger.
burn archive status Print schema version, row counts, and sync state.
burn archive --help Show this help.

The archive is a disposable read model derived from \`ledger.jsonl\`. Deleting
\`archive.sqlite\` and running \`burn archive rebuild\` always reproduces the
same state — the ledger remains the canonical event log.

See issue #40 for the broader plan (rewiring read commands onto the archive,
content-sidecar bridging, full subagent / tool-result event tables).
`;

export async function runArchive(args: ParsedArgs): Promise<number> {
if (args.flags['help'] === true) {
process.stdout.write(ARCHIVE_HELP);
return 0;
}
const sub = args.positional[0];
switch (sub) {
case undefined:
case 'help':
process.stdout.write(ARCHIVE_HELP);
return 0;
case 'build':
return runBuild(args);
case 'rebuild':
return runRebuild(args);
case 'status':
return runStatus(args);
default:
process.stderr.write(`burn archive: unknown subcommand: ${sub}\n\n${ARCHIVE_HELP}`);
return 1;
}
}

async function runBuild(args: ParsedArgs): Promise<number> {
const result = await buildArchive();
return printBuildResult(result, args, 'build');
}

async function runRebuild(args: ParsedArgs): Promise<number> {
const result = await rebuildArchive();
return printBuildResult(result, args, 'rebuild');
}

function printBuildResult(
result: BuildResult,
args: ParsedArgs,
mode: 'build' | 'rebuild',
): number {
if (args.flags['json'] === true) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return 0;
}
const lines: string[] = [];
if (mode === 'rebuild') {
lines.push('rebuilt archive from ledger');
} else {
lines.push('archive build complete');
}
lines.push(
` ${formatInt(result.turnsApplied)} turn${result.turnsApplied === 1 ? '' : 's'} applied,` +
` ${formatInt(result.sessionsTouched)} session${result.sessionsTouched === 1 ? '' : 's'} touched,` +
` ${formatInt(result.stampsApplied)} stamp${result.stampsApplied === 1 ? '' : 's'},` +
` ${formatInt(result.compactionsApplied)} compaction${result.compactionsApplied === 1 ? '' : 's'}`,
);
lines.push(` ${formatInt(result.scannedBytes)} bytes scanned from ledger tail`);
process.stdout.write(lines.join('\n') + '\n');
return 0;
}

async function runStatus(args: ParsedArgs): Promise<number> {
const status = await getArchiveStatus();
if (args.flags['json'] === true) {
process.stdout.write(JSON.stringify(status, null, 2) + '\n');
return 0;
}
const lines: string[] = [];
lines.push(`archive: ${status.archivePath}`);
if (!status.exists) {
lines.push(' status: not built yet — run `burn archive build`');
process.stdout.write(lines.join('\n') + '\n');
return 0;
}
lines.push(` schema version: ${status.archiveVersion}`);
lines.push(
` ledger cursor: ${formatInt(status.ledgerOffsetBytes)} / ${formatInt(status.ledgerSizeBytes)} bytes` +
(status.upToDate ? ' (up to date)' : ' (tail pending)'),
);
if (status.lastBuiltAt) lines.push(` last build: ${status.lastBuiltAt}`);
if (status.lastRebuildAt) lines.push(` last rebuild: ${status.lastRebuildAt}`);
lines.push(' rows:');
lines.push(` sessions: ${formatInt(status.rowCounts.sessions)}`);
lines.push(` turns: ${formatInt(status.rowCounts.turns)}`);
lines.push(` tool_calls: ${formatInt(status.rowCounts.toolCalls)}`);
lines.push(` compactions: ${formatInt(status.rowCounts.compactions)}`);
process.stdout.write(lines.join('\n') + '\n');
return 0;
}
4 changes: 4 additions & 0 deletions packages/ledger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Derived analytics archive** (#40, foundation PR). New `archive.sqlite` materialized read model alongside the canonical `ledger.jsonl`, exposed via `buildArchive()`, `rebuildArchive()`, `getArchiveStatus()`, and `openArchive()`. Schema covers `sessions`, `turns`, `tool_calls`, `compactions`, and a reserved `tool_result_events` table for the future content-sidecar bridge (#33). Stamps are folded into materialized columns (`workflow_id`, `agent_id`, `persona`, `tier`) plus a JSON blob, so consumers no longer have to fold the full stamp set on every query. Build is incremental keyed off `archive_state.ledger_offset_bytes`, and rebuild-from-zero is deterministic — deleting `archive.sqlite` and rebuilding always reproduces the same row counts. Backed by `node:sqlite` so no native build step. New `archivePath()` exported alongside the existing path helpers.

## [0.19.0] - 2026-04-26

### Added
Expand Down
Loading