Skip to content

Commit 75e2048

Browse files
author
StackMemory Bot (CLI)
committed
feat(q1): trace event API, provenance schema, cache CLI
- Add ASI-shaped TraceEvent type matching kickoff spec (score, feedback, provenance, cost, tokens) - Add TraceEventStore with SQLite persistence, filtered queries, batch recording, annotation - Add provenance columns to frames + anchors tables (source, derivation, confidence, superseded_by) - Populate provenance automatically on frame/anchor creation - Add `stackmemory cache stats/clear/search` CLI for terminal-printable token savings - 19 new tests for trace event store, all 2191 tests passing
1 parent 867738c commit 75e2048

6 files changed

Lines changed: 924 additions & 6 deletions

File tree

src/cli/commands/cache.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* Cache CLI — view content-hash cache stats and manage the cache
3+
*/
4+
5+
import { Command } from 'commander';
6+
import chalk from 'chalk';
7+
import * as path from 'path';
8+
import { existsSync } from 'fs';
9+
import Database from 'better-sqlite3';
10+
import { ContentCache } from '../../core/cache/index.js';
11+
12+
function findProjectDbPath(): string | undefined {
13+
// Walk up to find .git root
14+
let dir = process.cwd();
15+
while (dir !== '/') {
16+
const dbPath = path.join(dir, '.stackmemory', 'context.db');
17+
if (existsSync(dbPath)) return dbPath;
18+
dir = path.dirname(dir);
19+
}
20+
21+
const home = process.env['HOME'] || '/tmp';
22+
const homePath = path.join(home, '.stackmemory', 'context.db');
23+
if (existsSync(homePath)) return homePath;
24+
25+
return undefined;
26+
}
27+
28+
function getProjectDb(): Database.Database | undefined {
29+
const dbPath = findProjectDbPath();
30+
if (!dbPath) return undefined;
31+
return new Database(dbPath);
32+
}
33+
34+
export function createCacheCommand(): Command {
35+
const cmd = new Command('cache').description(
36+
'Content-hash cache — view token savings and manage cached content'
37+
);
38+
39+
// ── cache stats ──────────────────────────────────────────────────────
40+
41+
cmd
42+
.command('stats')
43+
.description('Show cache statistics and token savings')
44+
.option('--json', 'Output as JSON')
45+
.action((options: { json?: boolean }) => {
46+
const db = getProjectDb();
47+
if (!db) {
48+
console.log(chalk.yellow('No project database found'));
49+
return;
50+
}
51+
52+
try {
53+
const cache = new ContentCache(db);
54+
const stats = cache.getStats();
55+
56+
if (options.json) {
57+
console.log(JSON.stringify(stats, null, 2));
58+
return;
59+
}
60+
61+
console.log(chalk.bold('Content Cache Statistics\n'));
62+
console.log(` Entries: ${stats.totalEntries.toLocaleString()}`);
63+
console.log(
64+
` Tokens cached: ${stats.totalTokensCached.toLocaleString()}`
65+
);
66+
console.log(
67+
` Tokens saved: ${chalk.green(stats.totalTokensSaved.toLocaleString())}`
68+
);
69+
console.log(` Hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
70+
71+
if (stats.topSources.length > 0) {
72+
console.log(chalk.bold('\n Top sources by tokens saved:'));
73+
for (const src of stats.topSources.slice(0, 5)) {
74+
console.log(
75+
` ${chalk.dim(src.source)}: ${src.tokensSaved.toLocaleString()} tokens`
76+
);
77+
}
78+
}
79+
80+
// Estimate cost savings (~$3/M tokens for Claude input)
81+
const costSaved = (stats.totalTokensSaved / 1_000_000) * 3;
82+
if (costSaved > 0.01) {
83+
console.log(
84+
`\n Est. cost saved: ${chalk.green('$' + costSaved.toFixed(2))}`
85+
);
86+
}
87+
} finally {
88+
db.close();
89+
}
90+
});
91+
92+
// ── cache clear ──────────────────────────────────────────────────────
93+
94+
cmd
95+
.command('clear')
96+
.description('Clear the content cache')
97+
.option('--confirm', 'Skip confirmation prompt')
98+
.action((options: { confirm?: boolean }) => {
99+
if (!options.confirm) {
100+
console.log(
101+
chalk.yellow(
102+
'This will clear all cached content. Run with --confirm to proceed.'
103+
)
104+
);
105+
return;
106+
}
107+
108+
const db = getProjectDb();
109+
if (!db) {
110+
console.log(chalk.yellow('No project database found'));
111+
return;
112+
}
113+
try {
114+
const cache = new ContentCache(db);
115+
cache.clear();
116+
console.log(chalk.green('Cache cleared'));
117+
} finally {
118+
db.close();
119+
}
120+
});
121+
122+
// ── cache search ─────────────────────────────────────────────────────
123+
124+
cmd
125+
.command('search <query>')
126+
.description('Search cached content by keyword')
127+
.option('--limit <n>', 'Max results', '10')
128+
.action((query: string, options: { limit: string }) => {
129+
const db = getProjectDb();
130+
if (!db) {
131+
console.log(chalk.yellow('No project database found'));
132+
return;
133+
}
134+
135+
try {
136+
const cache = new ContentCache(db);
137+
const results = cache.search(query, parseInt(options.limit));
138+
139+
if (results.length === 0) {
140+
console.log(chalk.dim(`No cached content matching "${query}"`));
141+
return;
142+
}
143+
144+
console.log(chalk.bold(`${results.length} result(s):\n`));
145+
for (const entry of results) {
146+
const preview = entry.content.slice(0, 120).replace(/\n/g, ' ');
147+
console.log(
148+
` ${chalk.dim(entry.hash.slice(0, 8))} ${chalk.dim(`(${entry.tokenCount} tokens, ${entry.hitCount} hits)`)}`
149+
);
150+
console.log(` ${preview}${entry.content.length > 120 ? '...' : ''}`);
151+
console.log();
152+
}
153+
} finally {
154+
db.close();
155+
}
156+
});
157+
158+
return cmd;
159+
}

src/cli/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { createWikiCommand } from './commands/wiki.js';
7878
import { createLoopCommand } from './commands/loop.js';
7979
import { createSkillCommand } from './commands/skill.js';
8080
import { createPackCommand } from './commands/pack.js';
81+
import { createCacheCommand } from './commands/cache.js';
8182
import chalk from 'chalk';
8283
import * as fs from 'fs';
8384
import * as path from 'path';
@@ -141,6 +142,13 @@ function isTestEnv(): boolean {
141142
);
142143
}
143144

145+
function collectRepeatedOption(
146+
value: string,
147+
previous: string[] = []
148+
): string[] {
149+
return [...previous, value];
150+
}
151+
144152
// Check for updates on CLI startup
145153
UpdateChecker.checkForUpdates(VERSION, true).catch(() => {
146154
// Silently ignore errors
@@ -820,6 +828,7 @@ program.addCommand(createLoopCommand());
820828
program.addCommand(createRulesCommand());
821829
program.addCommand(createSkillCommand());
822830
program.addCommand(createPackCommand());
831+
program.addCommand(createCacheCommand());
823832

824833
// Register setup and diagnostic commands
825834
registerSetupCommands(program);
@@ -853,6 +862,12 @@ program
853862
.option('--implementer <name>', 'codex|claude', 'codex')
854863
.option('--max-iters <n>', 'Retry loop iterations', '2')
855864
.option('--audit-dir <path>', 'Persist spike results to directory')
865+
.option(
866+
'--verify <cmd>',
867+
'Verification command to run after each implementation attempt; repeatable',
868+
collectRepeatedOption,
869+
[]
870+
)
856871
.option('--record-frame', 'Record as real frame with anchors', false)
857872
.option('--record', 'Record plan & critique into StackMemory context', false)
858873
.option(
@@ -884,6 +899,7 @@ program
884899
maxIters: parseInt(opts.maxIters),
885900
dryRun: !opts.execute,
886901
auditDir: opts.auditDir,
902+
verificationCommands: opts.verify,
887903
recordFrame: Boolean(opts.recordFrame),
888904
record: Boolean(opts.record),
889905
deterministicFixture: Boolean(opts.deterministicFixture),
@@ -959,6 +975,12 @@ program
959975
.option('--implementer <name>', 'codex|claude', 'codex')
960976
.option('--max-iters <n>', 'Retry loop iterations', '2')
961977
.option('--audit-dir <path>', 'Persist spike results to directory')
978+
.option(
979+
'--verify <cmd>',
980+
'Verification command to run after each implementation attempt; repeatable',
981+
collectRepeatedOption,
982+
[]
983+
)
962984
.option('--record-frame', 'Record as real frame with anchors')
963985
.option('--record', 'Record plan & critique into StackMemory context')
964986
.option(
@@ -1017,6 +1039,7 @@ program
10171039
maxIters: parseInt(opts.maxIters),
10181040
dryRun,
10191041
auditDir: opts.auditDir,
1042+
verificationCommands: opts.verify,
10201043
recordFrame: Boolean(opts.recordFrame),
10211044
record: Boolean(opts.record),
10221045
deterministicFixture: Boolean(opts.deterministicFixture),

src/core/context/frame-database.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,29 @@ export class FrameDatabase {
170170
// Column already exists — safe to ignore
171171
}
172172

173+
// Migration: add provenance columns (v1.12 — invisible to users, populated on every write)
174+
const provenanceCols = [
175+
{ table: 'frames', col: 'prov_source', def: "TEXT DEFAULT ''" },
176+
{ table: 'frames', col: 'prov_derivation', def: "TEXT DEFAULT '[]'" },
177+
{ table: 'frames', col: 'prov_confidence', def: 'REAL DEFAULT 1.0' },
178+
{ table: 'frames', col: 'prov_superseded_by', def: 'TEXT' },
179+
{
180+
table: 'frames',
181+
col: 'prov_program_version',
182+
def: "TEXT DEFAULT ''",
183+
},
184+
{ table: 'anchors', col: 'prov_source', def: "TEXT DEFAULT ''" },
185+
{ table: 'anchors', col: 'prov_confidence', def: 'REAL DEFAULT 1.0' },
186+
{ table: 'anchors', col: 'prov_superseded_by', def: 'TEXT' },
187+
];
188+
for (const { table, col, def } of provenanceCols) {
189+
try {
190+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
191+
} catch {
192+
// Column already exists
193+
}
194+
}
195+
173196
// Create indexes for performance
174197
this.db.exec(`
175198
CREATE INDEX IF NOT EXISTS idx_frames_run ON frames(run_id);
@@ -292,8 +315,8 @@ export class FrameDatabase {
292315
insertFrame(frame: Omit<Frame, 'created_at' | 'closed_at'>): Frame {
293316
try {
294317
const stmt = this.db.prepare(`
295-
INSERT INTO frames (frame_id, run_id, project_id, parent_frame_id, depth, type, name, state, inputs, outputs, digest_json)
296-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
318+
INSERT INTO frames (frame_id, run_id, project_id, parent_frame_id, depth, type, name, state, inputs, outputs, digest_json, prov_source, prov_derivation, prov_confidence, prov_program_version)
319+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
297320
`);
298321

299322
const result = stmt.run(
@@ -307,7 +330,11 @@ export class FrameDatabase {
307330
frame.state,
308331
JSON.stringify(frame.inputs),
309332
JSON.stringify(frame.outputs),
310-
JSON.stringify(frame.digest_json)
333+
JSON.stringify(frame.digest_json),
334+
frame.type, // prov_source: frame type as source
335+
JSON.stringify([`frame:${frame.type}`]), // prov_derivation
336+
1.0, // prov_confidence: default full confidence
337+
process.env['npm_package_version'] || '' // prov_program_version
311338
);
312339

313340
if (result.changes === 0) {
@@ -588,8 +615,8 @@ export class FrameDatabase {
588615
insertAnchor(anchor: Omit<Anchor, 'created_at'>): Anchor {
589616
try {
590617
const stmt = this.db.prepare(`
591-
INSERT INTO anchors (anchor_id, frame_id, project_id, type, text, priority, metadata)
592-
VALUES (?, ?, ?, ?, ?, ?, ?)
618+
INSERT INTO anchors (anchor_id, frame_id, project_id, type, text, priority, metadata, prov_source, prov_confidence)
619+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
593620
`);
594621

595622
const result = stmt.run(
@@ -599,7 +626,9 @@ export class FrameDatabase {
599626
anchor.type,
600627
anchor.text,
601628
anchor.priority,
602-
JSON.stringify(anchor.metadata)
629+
JSON.stringify(anchor.metadata),
630+
anchor.type, // prov_source: anchor type (DECISION, EVENT, etc.)
631+
1.0 // prov_confidence: default full confidence
603632
);
604633

605634
if (result.changes === 0) {

0 commit comments

Comments
 (0)