stackmem 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env tsx
2
+ // Benchmark suite for stackmem's scoring algorithms, run against real
3
+ // Supabase data (not fixtures). Two Supabase queries total — one for all
4
+ // memories, one for all memory_links — everything else below is derived
5
+ // from those two result sets in memory, so re-running this doesn't hammer
6
+ // the backend. Run with: npx tsx src/benchmark.ts
7
+ import { execSync } from "node:child_process";
8
+ import nodeFs from "node:fs";
9
+ import nodePath from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { applyContextBudget, computeDecayScore, containmentScore, decideSaveAction, estimateTokens, extractEntities, scoreMemoryRelevance, } from "./scoring.js";
12
+ import { supabase } from "./storage.js";
13
+ const CONTEXT_BUDGET_TOKENS = 2000;
14
+ const REPO_ROOT = nodePath.join(nodePath.dirname(fileURLToPath(import.meta.url)), "..");
15
+ // Literal content prefixes the git hook template (buildHookLogicScript in
16
+ // src/cli.ts) generates. Any memory starting with one of these was
17
+ // unambiguously auto-captured, regardless of who ran the commit. Shared by
18
+ // section 4 (template-collision false positives) and section 6 (automation
19
+ // rate / captured-category listing).
20
+ const HOOK_TEMPLATE_PREFIXES = [
21
+ { label: "new auth-related file → discovery", prefix: "new auth-related file:" },
22
+ { label: "new config file → constraint", prefix: "new config:" },
23
+ { label: "schema change → discovery", prefix: "schema change:" },
24
+ { label: "removed dependency → rejection", prefix: "removed dependency:" },
25
+ { label: "added dependency → decision", prefix: "added dependency:" },
26
+ { label: "database change → discovery", prefix: "database change:" },
27
+ { label: "open TODO/FIXME/HACK/XXX comment → discovery", prefix: "open issue from commit:" },
28
+ ];
29
+ // --- deterministic sampling -------------------------------------------------
30
+ // A "publishable" benchmark should give the same numbers on every run, but
31
+ // the duplicate/contradiction tests below need a varied cross-section of
32
+ // real content rather than always the first N rows. A seeded PRNG gets
33
+ // both: varied sampling that's identical every time this file is run.
34
+ const BENCHMARK_SEED = 1337;
35
+ function seededRandom(seed) {
36
+ let state = seed | 0;
37
+ return () => {
38
+ state = (state + 0x6d2b79f5) | 0;
39
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
40
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
41
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
42
+ };
43
+ }
44
+ function seededShuffle(items, seed) {
45
+ const rng = seededRandom(seed);
46
+ const copy = [...items];
47
+ for (let i = copy.length - 1; i > 0; i--) {
48
+ const j = Math.floor(rng() * (i + 1));
49
+ [copy[i], copy[j]] = [copy[j], copy[i]];
50
+ }
51
+ return copy;
52
+ }
53
+ function generateAllPairs(items) {
54
+ const pairs = [];
55
+ for (let i = 0; i < items.length; i++) {
56
+ for (let j = i + 1; j < items.length; j++) {
57
+ pairs.push([items[i], items[j]]);
58
+ }
59
+ }
60
+ return pairs;
61
+ }
62
+ function pairKey(a, b) {
63
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
64
+ }
65
+ // --- shared data fetch -------------------------------------------------------
66
+ async function fetchAllMemories() {
67
+ const { data, error } = await supabase.from("memories").select("*");
68
+ if (error)
69
+ throw new Error(`Failed to fetch memories: ${error.message}`);
70
+ return data ?? [];
71
+ }
72
+ async function fetchAllLinks() {
73
+ const { data, error } = await supabase.from("memory_links").select("source_id, target_id, score");
74
+ if (error)
75
+ throw new Error(`Failed to fetch memory_links: ${error.message}`);
76
+ return data ?? [];
77
+ }
78
+ // --- shared: cm start's exact injection pipeline ------------------------------
79
+ // Mirrors cmdStart's exact pipeline (decay-sort, then applyContextBudget) so
80
+ // anything built on this measures the real injection path, not an idealized
81
+ // one. Shared by section 0 (stackmem's token count there must match section
82
+ // 1's, since they're both "what cm start actually injects for this project")
83
+ // and section 1 itself.
84
+ function getBudgetedMemoriesForProject(allMemories, project) {
85
+ const rows = allMemories.filter((m) => m.project === project && !m.resolved);
86
+ const scored = rows
87
+ .map((m) => ({
88
+ ...m,
89
+ decay_score: computeDecayScore(new Date(m.created_at), m.access_count ?? 0),
90
+ }))
91
+ .sort((a, b) => b.decay_score - a.decay_score);
92
+ return applyContextBudget(scored, CONTEXT_BUDGET_TOKENS);
93
+ }
94
+ // --- 0. comparative baseline --------------------------------------------------
95
+ const COMPARATIVE_PROJECT = "coding-memory";
96
+ const EXCLUDED_SCAN_DIRS = new Set(["node_modules", "dist", ".git"]);
97
+ function listTsFiles(dir) {
98
+ const results = [];
99
+ for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
100
+ if (EXCLUDED_SCAN_DIRS.has(entry.name))
101
+ continue;
102
+ const fullPath = nodePath.join(dir, entry.name);
103
+ if (entry.isDirectory())
104
+ results.push(...listTsFiles(fullPath));
105
+ else if (entry.isFile() && entry.name.endsWith(".ts"))
106
+ results.push(fullPath);
107
+ }
108
+ return results;
109
+ }
110
+ // What a developer would actually paste if they tried to hand-feed Claude
111
+ // the whole codebase for context, instead of using stackmem.
112
+ function benchmarkFullCodebaseDump() {
113
+ const files = listTsFiles(REPO_ROOT);
114
+ const totalTokens = files.reduce((sum, file) => sum + estimateTokens(nodeFs.readFileSync(file, "utf-8")), 0);
115
+ return { filesScanned: files.length, totalTokens };
116
+ }
117
+ function computeKeyFactsCoverage(budgeted) {
118
+ return {
119
+ techStack: budgeted.some((m) => /^tech stack:/i.test(m.content)),
120
+ activeConstraints: budgeted.some((m) => m.type === "constraint"),
121
+ recentDecisions: budgeted.some((m) => m.type === "decision"),
122
+ // cmdStart (src/cli.ts) only ever selects from the memories table —
123
+ // execution_log is never part of what `cm start` injects, so this is
124
+ // categorically false here regardless of how many real fixes exist in
125
+ // execution_log, not a sampling artifact of this benchmark.
126
+ knownFixes: false,
127
+ };
128
+ }
129
+ function benchmarkComparativeBaseline(allMemories) {
130
+ const dump = benchmarkFullCodebaseDump();
131
+ const budgeted = getBudgetedMemoriesForProject(allMemories, COMPARATIVE_PROJECT);
132
+ const tokens = budgeted.reduce((sum, m) => sum + estimateTokens(m.content), 0);
133
+ const coverage = computeKeyFactsCoverage(budgeted);
134
+ const factsCovered = Object.values(coverage).filter(Boolean).length;
135
+ return {
136
+ approachA: { tokens: 0, coveragePct: 0 },
137
+ approachB: dump,
138
+ approachC: { tokens, coverage, factsCovered, coveragePct: factsCovered / 4 },
139
+ };
140
+ }
141
+ function benchmarkTokenEfficiency(allMemories) {
142
+ const unresolved = allMemories.filter((m) => !m.resolved);
143
+ const projects = [...new Set(unresolved.map((m) => m.project))];
144
+ const perProject = [];
145
+ let totalRaw = 0;
146
+ let totalInjected = 0;
147
+ for (const project of projects) {
148
+ const rows = unresolved.filter((m) => m.project === project);
149
+ const rawTokens = rows.reduce((sum, m) => sum + estimateTokens(m.content), 0);
150
+ const budgeted = getBudgetedMemoriesForProject(allMemories, project);
151
+ const injectedTokens = budgeted.reduce((sum, m) => sum + estimateTokens(m.content), 0);
152
+ perProject.push({ project, rawTokens, injectedTokens });
153
+ totalRaw += rawTokens;
154
+ totalInjected += injectedTokens;
155
+ }
156
+ perProject.sort((a, b) => b.rawTokens - a.rawTokens);
157
+ return { perProject, totalRaw, totalInjected };
158
+ }
159
+ // --- 2. context relevance (precision@5) --------------------------------------
160
+ const CONTEXT_RELEVANCE_PROJECT = "coding-memory";
161
+ const CONTEXT_TASKS = [
162
+ {
163
+ description: "setting up npm publishing and versioning",
164
+ relevantIds: ["a5aab9dc-49b6-4bbc-aac2-5e65ace3e4ac"], // hosted Supabase backend, npm publish as stackmem
165
+ },
166
+ {
167
+ description: "fixing git hook to prevent recursion",
168
+ // No memory documents the [skip ci] recursion guard: the commit that
169
+ // introduced it used a "test:" prefix, which the quality filter drops.
170
+ relevantIds: [],
171
+ },
172
+ {
173
+ description: "adding Supabase database storage",
174
+ relevantIds: ["a5aab9dc-49b6-4bbc-aac2-5e65ace3e4ac"], // hosted Supabase backend, npm publish as stackmem
175
+ },
176
+ {
177
+ description: "implementing decay scoring algorithm",
178
+ // No memory documents computeDecayScore's implementation itself.
179
+ relevantIds: [],
180
+ },
181
+ {
182
+ description: "building cursor and windsurf support",
183
+ relevantIds: ["86006f9a-159a-4d88-bc94-018edc32cbf4"], // write .cursorrules and .windsurfrules on cm init
184
+ },
185
+ ];
186
+ function benchmarkContextRelevance(allMemories) {
187
+ const pool = allMemories.filter((m) => m.project === CONTEXT_RELEVANCE_PROJECT && !m.resolved);
188
+ const results = CONTEXT_TASKS.map((task) => {
189
+ const ranked = pool
190
+ .map((m) => ({ ...m, relevance: scoreMemoryRelevance(m.content, task.description) }))
191
+ .sort((a, b) => b.relevance - a.relevance);
192
+ const top5 = ranked.slice(0, 5);
193
+ const relevantSet = new Set(task.relevantIds);
194
+ const hits = top5.filter((m) => relevantSet.has(m.id)).length;
195
+ const top1Hit = ranked.length > 0 && relevantSet.has(ranked[0].id);
196
+ return {
197
+ task: task.description,
198
+ hits,
199
+ precisionAt5: hits / 5,
200
+ precisionAt1: top1Hit ? 1 : 0,
201
+ };
202
+ });
203
+ const average = results.reduce((sum, r) => sum + r.precisionAt5, 0) / results.length;
204
+ return { results, average };
205
+ }
206
+ function scoreClassification(tp, fp, tn, fn) {
207
+ const precision = tp + fp === 0 ? 0 : tp / (tp + fp);
208
+ const recall = tp + fn === 0 ? 0 : tp / (tp + fn);
209
+ const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
210
+ return { tp, fp, tn, fn, precision, recall, f1 };
211
+ }
212
+ const SAMPLE_SIZE = 20;
213
+ // The system refuses to persist true duplicate rows in the first place
214
+ // (decideSaveAction skips the insert), so there's no "same content saved
215
+ // twice" to mine directly from the table. The faithful equivalent is
216
+ // re-submitting a real memory's exact content as if saving it again —
217
+ // literally the scenario the duplicate check exists to catch.
218
+ function benchmarkDuplicateDetection(allMemories, unrelatedPairs) {
219
+ const positiveSample = seededShuffle(allMemories, BENCHMARK_SEED + 1).slice(0, SAMPLE_SIZE);
220
+ let tp = 0;
221
+ let fn = 0;
222
+ let fnViaNegationCarveout = 0;
223
+ let fnViaNoExtractableEntities = 0;
224
+ for (const memory of positiveSample) {
225
+ const existing = [{ id: memory.id, content: memory.content }];
226
+ const result = decideSaveAction(memory.content, existing);
227
+ if (result.action === "duplicate") {
228
+ tp++;
229
+ }
230
+ else {
231
+ fn++;
232
+ if (result.action === "superseded")
233
+ fnViaNegationCarveout++;
234
+ else if (extractEntities(memory.content).size === 0)
235
+ fnViaNoExtractableEntities++;
236
+ }
237
+ }
238
+ let tn = 0;
239
+ let fp = 0;
240
+ for (const [a, b] of unrelatedPairs) {
241
+ const existing = [{ id: b.id, content: b.content }];
242
+ const result = decideSaveAction(a.content, existing);
243
+ if (result.action === "duplicate")
244
+ fp++;
245
+ else
246
+ tn++;
247
+ }
248
+ return { ...scoreClassification(tp, fp, tn, fn), fnViaNegationCarveout, fnViaNoExtractableEntities };
249
+ }
250
+ // --- 4. contradiction detection accuracy --------------------------------------
251
+ // Prepending to the full original content guarantees the new entity set is
252
+ // a superset of the old one, so containment is ~1.0 by construction — this
253
+ // isolates whether the negation-word carve-out in decideSaveAction fires at
254
+ // all, not whether it can detect partial-overlap contradictions (e.g.
255
+ // "switched from Postgres to Supabase" against "using Postgres"), which is
256
+ // a harder, unverified case this benchmark doesn't cover.
257
+ const CONTRADICTION_PREFIXES = ["switched from ", "no longer using "];
258
+ function benchmarkContradictionDetection(allMemories, unrelatedPairs) {
259
+ const baseSample = seededShuffle(allMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
260
+ let tp = 0;
261
+ let fn = 0;
262
+ for (const memory of baseSample) {
263
+ for (const prefix of CONTRADICTION_PREFIXES) {
264
+ const existing = [{ id: memory.id, content: memory.content }];
265
+ const result = decideSaveAction(`${prefix}${memory.content}`, existing);
266
+ if (result.action === "superseded")
267
+ tp++;
268
+ else
269
+ fn++;
270
+ }
271
+ }
272
+ // Reuses the same unrelated-pair sample from section 3 as the negative
273
+ // set: if two unrelated memories get flagged "superseded" against each
274
+ // other, that's a false positive here just as "duplicate" was there.
275
+ let tn = 0;
276
+ let fp = 0;
277
+ let fpViaTemplateCollision = 0;
278
+ for (const [a, b] of unrelatedPairs) {
279
+ const existing = [{ id: b.id, content: b.content }];
280
+ const result = decideSaveAction(a.content, existing);
281
+ if (result.action === "superseded") {
282
+ fp++;
283
+ const sharedPrefix = HOOK_TEMPLATE_PREFIXES.find((p) => a.content.startsWith(p.prefix) && b.content.startsWith(p.prefix));
284
+ if (sharedPrefix)
285
+ fpViaTemplateCollision++;
286
+ }
287
+ else {
288
+ tn++;
289
+ }
290
+ }
291
+ return { ...scoreClassification(tp, fp, tn, fn), fpViaTemplateCollision };
292
+ }
293
+ // Baseline is every same-project pair that ISN'T linked, computed
294
+ // exhaustively rather than randomly sampled — at this data size (a few
295
+ // thousand possible pairs at most) exhaustive is cheap and gives a more
296
+ // stable number than a small random draw would.
297
+ function benchmarkSemanticLinking(allMemories, allLinks) {
298
+ const avgLinkScore = allLinks.length === 0 ? 0 : allLinks.reduce((sum, l) => sum + l.score, 0) / allLinks.length;
299
+ const linkedPairKeys = new Set(allLinks.map((l) => pairKey(l.source_id, l.target_id)));
300
+ const byProject = new Map();
301
+ for (const memory of allMemories) {
302
+ if (!byProject.has(memory.project))
303
+ byProject.set(memory.project, []);
304
+ byProject.get(memory.project).push(memory);
305
+ }
306
+ let baselineSum = 0;
307
+ let baselineCount = 0;
308
+ for (const rows of byProject.values()) {
309
+ const entitiesById = new Map(rows.map((m) => [m.id, extractEntities(m.content)]));
310
+ for (let i = 0; i < rows.length; i++) {
311
+ for (let j = i + 1; j < rows.length; j++) {
312
+ const key = pairKey(rows[i].id, rows[j].id);
313
+ if (linkedPairKeys.has(key))
314
+ continue;
315
+ baselineSum += containmentScore(entitiesById.get(rows[i].id), entitiesById.get(rows[j].id));
316
+ baselineCount++;
317
+ }
318
+ }
319
+ }
320
+ const avgRandomPairScore = baselineCount === 0 ? 0 : baselineSum / baselineCount;
321
+ const signalToNoiseRatio = avgRandomPairScore === 0 ? (avgLinkScore > 0 ? Infinity : 0) : avgLinkScore / avgRandomPairScore;
322
+ return {
323
+ avgLinkScore,
324
+ avgRandomPairScore,
325
+ signalToNoiseRatio,
326
+ linkCount: allLinks.length,
327
+ baselinePairCount: baselineCount,
328
+ };
329
+ }
330
+ // --- 6. git hook capture rate / automation rate -------------------------------
331
+ const HOOK_PATH = nodePath.join(REPO_ROOT, ".coding-memory", "post-commit-hook.mjs");
332
+ const AUTOMATION_PROJECT = "coding-memory";
333
+ function benchmarkAutomationRate(allMemories) {
334
+ const hookFound = nodeFs.existsSync(HOOK_PATH);
335
+ const hookSource = hookFound ? nodeFs.readFileSync(HOOK_PATH, "utf-8") : "";
336
+ const capturedCategories = HOOK_TEMPLATE_PREFIXES.filter((p) => hookSource.includes(p.prefix)).map((p) => p.label);
337
+ // The hook's 8th capture category — the raw commit message, saved as a
338
+ // decision when it passes the quality filter — isn't a template, so it's
339
+ // indistinguishable from a manual `cm save` by content shape alone. Cross
340
+ // -reference this repo's real git history instead: if a decision's
341
+ // content exactly matches a real commit message, it was hook-captured.
342
+ let commitMessages = new Set();
343
+ let gitLogAvailable = true;
344
+ try {
345
+ const log = execSync("git log --pretty=%B%x00", { cwd: REPO_ROOT, encoding: "utf-8" });
346
+ for (const message of log.split("\x00")) {
347
+ const trimmed = message.trim();
348
+ if (trimmed)
349
+ commitMessages.add(trimmed);
350
+ }
351
+ }
352
+ catch {
353
+ gitLogAvailable = false;
354
+ }
355
+ const projectMemories = allMemories.filter((m) => m.project === AUTOMATION_PROJECT);
356
+ let autoCount = 0;
357
+ for (const memory of projectMemories) {
358
+ const templateMatch = HOOK_TEMPLATE_PREFIXES.some((p) => memory.content.startsWith(p.prefix));
359
+ const commitMatch = memory.type === "decision" && commitMessages.has(memory.content.trim());
360
+ if (templateMatch || commitMatch)
361
+ autoCount++;
362
+ }
363
+ return {
364
+ totalMemories: projectMemories.length,
365
+ autoCount,
366
+ manualCount: projectMemories.length - autoCount,
367
+ automationRate: projectMemories.length === 0 ? 0 : autoCount / projectMemories.length,
368
+ hookFound,
369
+ capturedCategories,
370
+ gitLogAvailable,
371
+ };
372
+ }
373
+ // --- report formatting ---------------------------------------------------
374
+ const DIVIDER = "═".repeat(63);
375
+ function pct(ratio) {
376
+ return `${(ratio * 100).toFixed(1)}%`;
377
+ }
378
+ function pad(label, width = 28) {
379
+ return label.padEnd(width);
380
+ }
381
+ function printHeader() {
382
+ console.log(DIVIDER);
383
+ console.log("STACKMEM BENCHMARK REPORT");
384
+ console.log(DIVIDER);
385
+ console.log("");
386
+ }
387
+ function printTable(headers, rows) {
388
+ const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((row) => row[i].length)));
389
+ const border = (left, mid, right) => left + widths.map((w) => "─".repeat(w + 2)).join(mid) + right;
390
+ const formatRow = (cells) => "│" + cells.map((cell, i) => ` ${cell.padEnd(widths[i])} `).join("│") + "│";
391
+ console.log(border("┌", "┬", "┐"));
392
+ console.log(formatRow(headers));
393
+ console.log(border("├", "┼", "┤"));
394
+ for (const row of rows)
395
+ console.log(formatRow(row));
396
+ console.log(border("└", "┴", "┘"));
397
+ }
398
+ const KEY_FACT_LABELS = {
399
+ techStack: "tech stack",
400
+ activeConstraints: "active constraints",
401
+ recentDecisions: "recent decisions",
402
+ knownFixes: "known fixes",
403
+ };
404
+ async function main() {
405
+ const [allMemories, allLinks] = await Promise.all([fetchAllMemories(), fetchAllLinks()]);
406
+ printHeader();
407
+ // 0. Comparative baseline ----------------------------------------------------
408
+ const baseline = benchmarkComparativeBaseline(allMemories);
409
+ const subDivider = " " + "─".repeat(43);
410
+ console.log("0. COMPARATIVE BASELINE");
411
+ console.log(subDivider);
412
+ console.log(" Approach A (cold session):");
413
+ console.log(` ${pad("Tokens injected:", 20)}${baseline.approachA.tokens}`);
414
+ console.log(` ${pad("Context coverage:", 20)}${pct(baseline.approachA.coveragePct)}`);
415
+ console.log("");
416
+ console.log(" Approach B (full codebase dump):");
417
+ console.log(` ${pad("Files scanned:", 20)}${baseline.approachB.filesScanned}`);
418
+ console.log(` ${pad("Tokens required:", 20)}${baseline.approachB.totalTokens.toLocaleString()}`);
419
+ console.log(` ${pad("Context coverage:", 20)}~100% (structural)`);
420
+ console.log(" Problem: exceeds context window for");
421
+ console.log(" large projects, no semantic");
422
+ console.log(" ranking, includes noise");
423
+ console.log("");
424
+ console.log(" Approach C (stackmem):");
425
+ console.log(` ${pad("Tokens injected:", 20)}${baseline.approachC.tokens.toLocaleString()}`);
426
+ console.log(` ${pad("Context coverage:", 20)}${pct(baseline.approachC.coveragePct)} (semantic) — ${baseline.approachC.factsCovered}/4 key facts`);
427
+ const coveredFacts = Object.entries(baseline.approachC.coverage)
428
+ .filter(([, covered]) => covered)
429
+ .map(([key]) => KEY_FACT_LABELS[key]);
430
+ const missingFacts = Object.entries(baseline.approachC.coverage)
431
+ .filter(([, covered]) => !covered)
432
+ .map(([key]) => KEY_FACT_LABELS[key]);
433
+ console.log(` covered: ${coveredFacts.length > 0 ? coveredFacts.join(", ") : "(none)"}`);
434
+ console.log(` missing: ${missingFacts.length > 0 ? missingFacts.join(", ") : "(none)"}`);
435
+ const vsFullDump = baseline.approachB.totalTokens === 0
436
+ ? 0
437
+ : 1 - baseline.approachC.tokens / baseline.approachB.totalTokens;
438
+ console.log(` ${pad("vs full dump:", 20)}${pct(vsFullDump)} fewer tokens`);
439
+ console.log(` ${pad("vs cold session:", 20)}${baseline.approachC.factsCovered} facts injected`);
440
+ console.log(subDivider);
441
+ console.log("");
442
+ // 1. Token efficiency ------------------------------------------------------
443
+ const tokenResult = benchmarkTokenEfficiency(allMemories);
444
+ console.log("1. TOKEN EFFICIENCY");
445
+ for (const row of tokenResult.perProject) {
446
+ const saved = row.rawTokens === 0 ? 0 : 1 - row.injectedTokens / row.rawTokens;
447
+ console.log(` ${pad(row.project, 20)} raw ${row.rawTokens.toLocaleString().padStart(6)} injected ${row.injectedTokens
448
+ .toLocaleString()
449
+ .padStart(6)} saved ${pct(saved)}`);
450
+ }
451
+ console.log("");
452
+ const reductionRatio = tokenResult.totalInjected === 0 ? 0 : tokenResult.totalRaw / tokenResult.totalInjected;
453
+ const tokenSavings = tokenResult.totalRaw === 0 ? 0 : 1 - tokenResult.totalInjected / tokenResult.totalRaw;
454
+ console.log(` ${pad("Raw tokens (no budget):")}${tokenResult.totalRaw.toLocaleString()}`);
455
+ console.log(` ${pad("Injected tokens (budgeted):")}${tokenResult.totalInjected.toLocaleString()}`);
456
+ console.log(` ${pad("Reduction ratio:")}${reductionRatio.toFixed(2)}x`);
457
+ console.log(` ${pad("Token savings:")}${pct(tokenSavings)}`);
458
+ if (tokenSavings === 0) {
459
+ console.log(" (0% because every project's unresolved memories currently fit under");
460
+ console.log(" the 2000-token budget on their own — the cap hasn't had to bind yet.");
461
+ console.log(" It will show real savings once a project accumulates enough");
462
+ console.log(" live memories to exceed the budget.)");
463
+ }
464
+ console.log("");
465
+ // 2. Context relevance ------------------------------------------------------
466
+ const relevanceResult = benchmarkContextRelevance(allMemories);
467
+ console.log("2. CONTEXT RELEVANCE (Precision@5)");
468
+ relevanceResult.results.forEach((r, i) => {
469
+ console.log(` Task ${i + 1}: ${r.hits}/5 relevant (${pct(r.precisionAt5)}) — "${r.task}"`);
470
+ });
471
+ console.log("");
472
+ console.log(` ${pad("Average Precision@5:")}${pct(relevanceResult.average)}`);
473
+ const avgPrecisionAt1 = relevanceResult.results.reduce((sum, r) => sum + r.precisionAt1, 0) / relevanceResult.results.length;
474
+ console.log(` ${pad("Average Precision@1:")}${pct(avgPrecisionAt1)}`);
475
+ console.log(" (note: with only ~13 live memories, most tasks have at most one truly");
476
+ console.log(" relevant match, so the top-ranked result is usually right even when");
477
+ console.log(" padding ranks 2-5 with unrelated memories drags Precision@5 down.)");
478
+ console.log("");
479
+ // Shared sample for sections 3 and 4.
480
+ const unrelatedPairs = seededShuffle(generateAllPairs(allMemories), BENCHMARK_SEED).slice(0, SAMPLE_SIZE);
481
+ // 3. Duplicate detection ------------------------------------------------------
482
+ const dupResult = benchmarkDuplicateDetection(allMemories, unrelatedPairs);
483
+ console.log("3. DUPLICATE DETECTION");
484
+ console.log(` TP: ${dupResult.tp} FP: ${dupResult.fp} TN: ${dupResult.tn} FN: ${dupResult.fn}`);
485
+ console.log(` ${pad("Precision:")}${pct(dupResult.precision)}`);
486
+ console.log(` ${pad("Recall:")}${pct(dupResult.recall)}`);
487
+ console.log(` ${pad("F1 Score:")}${pct(dupResult.f1)}`);
488
+ if (dupResult.fn > 0) {
489
+ console.log(` FN breakdown (${dupResult.fn} missed self-duplicates):`);
490
+ console.log(` ${dupResult.fnViaNegationCarveout} via negation-word carve-out (content like "removed dependency: X" reads as superseding itself)`);
491
+ console.log(` ${dupResult.fnViaNoExtractableEntities} via no extractable entities (content under the 5-char entity minimum, e.g. "wip")`);
492
+ }
493
+ console.log("");
494
+ // 4. Contradiction detection ------------------------------------------------
495
+ const contradictionResult = benchmarkContradictionDetection(allMemories, unrelatedPairs);
496
+ console.log("4. CONTRADICTION DETECTION");
497
+ console.log(` TP: ${contradictionResult.tp} FP: ${contradictionResult.fp} TN: ${contradictionResult.tn} FN: ${contradictionResult.fn}`);
498
+ console.log(` ${pad("Precision:")}${pct(contradictionResult.precision)}`);
499
+ console.log(` ${pad("Recall:")}${pct(contradictionResult.recall)}`);
500
+ console.log(` ${pad("F1 Score:")}${pct(contradictionResult.f1)}`);
501
+ if (contradictionResult.fp > 0) {
502
+ console.log(` FP breakdown (${contradictionResult.fp} unrelated pairs flagged as contradictions):`);
503
+ console.log(` ${contradictionResult.fpViaTemplateCollision} via template collision (both share a hook-generated prefix like "removed dependency: X", so shared template words alone cross the contradiction threshold)`);
504
+ }
505
+ console.log("");
506
+ // 5. Semantic linking quality -------------------------------------------------
507
+ const linkingResult = benchmarkSemanticLinking(allMemories, allLinks);
508
+ console.log("5. SEMANTIC LINKING QUALITY");
509
+ console.log(` ${pad("Avg link score:")}${linkingResult.avgLinkScore.toFixed(2)} (n=${linkingResult.linkCount})`);
510
+ console.log(` ${pad("Avg random pair score:")}${linkingResult.avgRandomPairScore.toFixed(2)} (n=${linkingResult.baselinePairCount})`);
511
+ console.log(` ${pad("Signal-to-noise ratio:")}${Number.isFinite(linkingResult.signalToNoiseRatio) ? `${linkingResult.signalToNoiseRatio.toFixed(1)}x` : "∞"}`);
512
+ console.log("");
513
+ // 6. Automation rate ----------------------------------------------------------
514
+ const automationResult = benchmarkAutomationRate(allMemories);
515
+ console.log("6. AUTOMATION RATE");
516
+ if (automationResult.hookFound) {
517
+ console.log(` Hook captures ${automationResult.capturedCategories.length} categories:`);
518
+ for (const label of automationResult.capturedCategories) {
519
+ console.log(` - ${label}`);
520
+ }
521
+ console.log(" - raw commit message → decision (quality-filtered)");
522
+ }
523
+ else {
524
+ console.log(` (hook file not found at ${HOOK_PATH} — category list unavailable)`);
525
+ }
526
+ if (!automationResult.gitLogAvailable) {
527
+ console.log(" (git log unavailable — commit-message decisions counted as manual)");
528
+ }
529
+ console.log("");
530
+ console.log(` ${pad("Auto-captured memories:")}${pct(automationResult.automationRate)} (${automationResult.autoCount}/${automationResult.totalMemories})`);
531
+ console.log(` ${pad("Manually saved memories:")}${pct(1 - automationResult.automationRate)} (${automationResult.manualCount}/${automationResult.totalMemories})`);
532
+ console.log("");
533
+ // Head-to-head comparison -----------------------------------------------
534
+ console.log("APPROACH COMPARISON");
535
+ printTable(["", "Cold", "Full dump", "stackmem"], [
536
+ [
537
+ "Tokens used",
538
+ baseline.approachA.tokens.toLocaleString(),
539
+ baseline.approachB.totalTokens.toLocaleString(),
540
+ baseline.approachC.tokens.toLocaleString(),
541
+ ],
542
+ ["Key facts", "0", "~all", pct(baseline.approachC.coveragePct)],
543
+ ["Stale content", "N/A", "possible", "auto-updated"],
544
+ ["Setup required", "none", "manual", "cm init"],
545
+ ]);
546
+ console.log("");
547
+ console.log(DIVIDER);
548
+ }
549
+ main().catch((error) => {
550
+ console.error("Benchmark failed:", error instanceof Error ? error.message : error);
551
+ process.exit(1);
552
+ });
package/dist/cli.js CHANGED
@@ -244,7 +244,26 @@ async function cmdStart(project) {
244
244
  .sort((a, b) => b.decay_score - a.decay_score);
245
245
  const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
246
246
  const omittedCount = scoredMemories.length - budgetedMemories.length;
247
- const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
247
+ let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
248
+ const { data: fixData, error: fixError } = await supabase
249
+ .from("execution_log")
250
+ .select("*")
251
+ .eq("project", project)
252
+ .eq("resolved", false)
253
+ .order("created_at", { ascending: false })
254
+ .limit(5);
255
+ if (fixError)
256
+ fail(`Failed to load execution log: ${fixError.message}`);
257
+ // Fixes share the same 2000-token budget as memories, which already
258
+ // consumed their portion above — fixes only get whatever room is left.
259
+ const budgetedFixes = [];
260
+ for (const fix of fixData ?? []) {
261
+ const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
262
+ if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS)
263
+ break;
264
+ budgetedFixes.push(fix);
265
+ tokensUsed += tokens;
266
+ }
248
267
  const memoryIds = budgetedMemories.map((m) => m.id);
249
268
  const linkedIdsByMemory = new Map();
250
269
  const idList = memoryIds.join(",");
@@ -278,6 +297,15 @@ async function cmdStart(project) {
278
297
  }
279
298
  console.log("");
280
299
  }
300
+ if (budgetedFixes.length > 0) {
301
+ console.log(`Past Fixes (${budgetedFixes.length})`);
302
+ console.log("-".repeat(14));
303
+ for (const fix of budgetedFixes) {
304
+ console.log(` Problem: ${fix.problem}`);
305
+ console.log(` Solution: ${fix.solution ?? "(pending)"}`);
306
+ console.log("");
307
+ }
308
+ }
281
309
  console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
282
310
  if (omittedCount > 0) {
283
311
  console.log(`${omittedCount} memories omitted (budget exceeded)`);
package/dist/index.js CHANGED
@@ -62,6 +62,20 @@ server.registerTool("session_start", {
62
62
  ...m,
63
63
  linked_memory_ids: Array.from(linkedIdsByMemory.get(m.id) ?? []),
64
64
  }));
65
+ const { data: fixData, error: fixError } = await supabase
66
+ .from("execution_log")
67
+ .select("*")
68
+ .eq("project", project)
69
+ .eq("resolved", false)
70
+ .order("created_at", { ascending: false })
71
+ .limit(5);
72
+ if (fixError) {
73
+ throw new Error(`Failed to load execution log: ${fixError.message}`);
74
+ }
75
+ const fixes = (fixData ?? []).map((fix) => ({
76
+ problem: fix.problem,
77
+ solution: fix.solution ?? "pending",
78
+ }));
65
79
  if (budgetedMemories.length > 0) {
66
80
  const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
67
81
  .from("memories")
@@ -79,6 +93,7 @@ server.registerTool("session_start", {
79
93
  content: [{ type: "text", text: budgetText }],
80
94
  structuredContent: {
81
95
  memories: memoriesWithLinks,
96
+ fixes,
82
97
  context_budget: {
83
98
  budget_tokens: CONTEXT_BUDGET_TOKENS,
84
99
  tokens_used: tokensUsed,
package/dist/scoring.js CHANGED
@@ -52,6 +52,11 @@ function containsNegationWord(text) {
52
52
  const lower = text.toLowerCase();
53
53
  return NEGATION_WORDS.some((word) => lower.includes(word));
54
54
  }
55
+ // Hook-generated dependency-change templates (e.g. "removed dependency: X")
56
+ // are structural facts, not decisions — "removed" trips the negation-word
57
+ // check below, but there's nothing for them to supersede/contradict, so
58
+ // they're exempted up front rather than ever entering the negation branch.
59
+ const DEPENDENCY_TEMPLATE_RE = /^(removed|added) dependency:/i;
55
60
  // Decides whether a new memory is a near-duplicate of an existing one
56
61
  // (skip it), supersedes an existing one (resolve the old, insert the new),
57
62
  // or is genuinely new. A negation word (e.g. "switched", "no longer")
@@ -62,7 +67,7 @@ function containsNegationWord(text) {
62
67
  // at the first hit, so the result doesn't depend on row order.
63
68
  export function decideSaveAction(newContent, existingMemories) {
64
69
  const newEntities = extractEntities(newContent);
65
- const hasNegation = containsNegationWord(newContent);
70
+ const hasNegation = DEPENDENCY_TEMPLATE_RE.test(newContent) ? false : containsNegationWord(newContent);
66
71
  let bestDuplicate = null;
67
72
  let bestSupersede = null;
68
73
  for (const existing of existingMemories) {
package/dist/tools.js CHANGED
@@ -1 +1,2 @@
1
+ const test = 1;
1
2
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",