pi-mega-compact 0.4.5 → 0.4.6

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.
Files changed (68) hide show
  1. package/dist/extensions/dashboard-server.js +450 -0
  2. package/dist/extensions/dashboard-server.test.js +111 -0
  3. package/dist/extensions/error-patterns.js +115 -0
  4. package/dist/extensions/mega-compact.js +782 -0
  5. package/dist/extensions/mega-compact.test.js +328 -0
  6. package/dist/extensions/openclaw-mega-compact.js +291 -0
  7. package/dist/src/adapt.js +106 -0
  8. package/dist/src/boundary.js +88 -0
  9. package/dist/src/boundary.test.js +53 -0
  10. package/dist/src/canary.js +118 -0
  11. package/dist/src/compact.js +250 -0
  12. package/dist/src/compact.test.js +78 -0
  13. package/dist/src/config/dedup.js +81 -0
  14. package/dist/src/config.js +12 -0
  15. package/dist/src/dedup/dedup.test.js +41 -0
  16. package/dist/src/dedup/digest.js +30 -0
  17. package/dist/src/dedup/l1-lsh.js +52 -0
  18. package/dist/src/dedup/l1-minhash.js +91 -0
  19. package/dist/src/dedup/l1-verify.js +54 -0
  20. package/dist/src/dedup/l1.test.js +50 -0
  21. package/dist/src/dedup/mmr.js +45 -0
  22. package/dist/src/dedup/normalize.js +39 -0
  23. package/dist/src/dedup/raptor/guardrails.js +83 -0
  24. package/dist/src/dedup/raptor/index.js +94 -0
  25. package/dist/src/dedup/raptor/kmeans.js +152 -0
  26. package/dist/src/dedup/raptor/raptor.test.js +205 -0
  27. package/dist/src/dedup/raptor/retrieval.js +81 -0
  28. package/dist/src/dedup/raptor/summarizer.js +85 -0
  29. package/dist/src/dedup/raptor/tree.js +177 -0
  30. package/dist/src/dedup/sprint12.test.js +219 -0
  31. package/dist/src/dedup/topk.js +60 -0
  32. package/dist/src/dedup-engine.test.js +447 -0
  33. package/dist/src/e2e.test.js +698 -0
  34. package/dist/src/embedder.js +102 -0
  35. package/dist/src/engine.js +137 -0
  36. package/dist/src/engine.test.js +111 -0
  37. package/dist/src/extractive.js +209 -0
  38. package/dist/src/extractive.test.js +130 -0
  39. package/dist/src/httpEmbedder.js +143 -0
  40. package/dist/src/log.js +47 -0
  41. package/dist/src/log.test.js +42 -0
  42. package/dist/src/minilm.js +92 -0
  43. package/dist/src/monitoring.js +131 -0
  44. package/dist/src/ratio.bench.test.js +897 -0
  45. package/dist/src/recall.integration.test.js +77 -0
  46. package/dist/src/recall.js +60 -0
  47. package/dist/src/recall.test.js +50 -0
  48. package/dist/src/sprint14.test.js +219 -0
  49. package/dist/src/store/backfill.js +189 -0
  50. package/dist/src/store/bloom.js +114 -0
  51. package/dist/src/store/compression.js +177 -0
  52. package/dist/src/store/compression.test.js +67 -0
  53. package/dist/src/store/integrity.js +44 -0
  54. package/dist/src/store/migrate.js +79 -0
  55. package/dist/src/store/migrate.test.js +139 -0
  56. package/dist/src/store/sprint10.test.js +186 -0
  57. package/dist/src/store/sqlite.js +574 -0
  58. package/dist/src/store.js +115 -0
  59. package/dist/src/store.test.js +142 -0
  60. package/dist/src/supersede.js +68 -0
  61. package/dist/src/supersede.test.js +36 -0
  62. package/dist/src/tokens.js +31 -0
  63. package/dist/src/types.js +8 -0
  64. package/dist/src/types.test.js +9 -0
  65. package/dist/src/vectorStore.js +465 -0
  66. package/dist/src/vectorStore.test.js +479 -0
  67. package/dist/src/wordpiece.js +129 -0
  68. package/package.json +4 -2
@@ -0,0 +1,77 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { VectorStore } from "./vectorStore.js";
7
+ import { compactSession } from "./engine.js";
8
+ import { recallAndInline } from "./recall.js";
9
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-resume-"));
10
+ let counter = 0;
11
+ /** Two instances, SAME disk dir — simulates a fresh process / resumed session. */
12
+ function storeForDir(dir) {
13
+ return new VectorStore({ dedupSim: 0.9, stateDir: dir });
14
+ }
15
+ function msg(role, text, toolName) {
16
+ return toolName ? { role, text, toolName, input: text, output: text } : { role, text };
17
+ }
18
+ const SESS = "sess_resume";
19
+ /**
20
+ * Simulate the extension's `recentUserQuery`: the resume query is built from the
21
+ * newest user message in the (re-loaded) session. We model "newest user msg"
22
+ * directly rather than going through pi's session manager.
23
+ */
24
+ function latestUserQuery(messages) {
25
+ for (let i = messages.length - 1; i >= 0; i--) {
26
+ if (messages[i].role === "user")
27
+ return messages[i].text;
28
+ }
29
+ return "";
30
+ }
31
+ test("resume contract: compact in one process, recall in a fresh one from disk", () => {
32
+ const dir = join(baseTmp, `run-${counter++}`);
33
+ // --- Process 1: the original session compacts ---
34
+ const writer = storeForDir(dir);
35
+ const session = [
36
+ msg("user", "investigated src/compact.ts and added a truncate helper"),
37
+ msg("assistant", "added truncate", "Edit"),
38
+ msg("user", "then wired it into the summary pipeline"),
39
+ msg("assistant", "wired it in", "Edit"),
40
+ ];
41
+ const ran = compactSession({ sessionId: SESS, messages: session, keepFrom: session.length, timestamp: 1 }, writer);
42
+ assert.equal(ran.skipped, false);
43
+ assert.ok(ran.checkpointId, "a checkpoint was persisted");
44
+ // --- Process 2: pi restarts, session resumes from disk ---
45
+ const reader = storeForDir(dir); // brand new instance, same dir
46
+ const resumeQuery = latestUserQuery(session); // newest user msg
47
+ const r = recallAndInline({ sessionId: SESS, query: resumeQuery, limit: 3, source: "resume" }, reader);
48
+ assert.equal(r.empty, false, "resume must re-surface the compacted context");
49
+ assert.equal(r.toInject.length, 1);
50
+ assert.equal(r.toInject[0].checkpoint.checkpointId, ran.checkpointId);
51
+ assert.ok(r.block.includes("Recalled context"), "block is model-visible system-prompt text");
52
+ });
53
+ test("resume contract: nothing to recall for a brand-new session", () => {
54
+ const dir = join(baseTmp, `run-${counter++}`);
55
+ const fresh = storeForDir(dir);
56
+ const r = recallAndInline({ sessionId: "sess_never_seen", query: "anything at all", limit: 3, source: "resume" }, fresh);
57
+ assert.equal(r.empty, true);
58
+ assert.equal(r.block, "");
59
+ });
60
+ test("resume contract: re-inject is deduped after the first recall", () => {
61
+ const dir = join(baseTmp, `run-${counter++}`);
62
+ const writer = storeForDir(dir);
63
+ const session = [
64
+ msg("user", "built the trigram embedder for the vector store"),
65
+ msg("assistant", "built it", "Edit"),
66
+ ];
67
+ compactSession({ sessionId: SESS, messages: session, keepFrom: 2, timestamp: 1 }, writer);
68
+ const reader = storeForDir(dir);
69
+ const q = latestUserQuery(session);
70
+ const first = recallAndInline({ sessionId: SESS, query: q, limit: 3, source: "resume" }, reader);
71
+ const second = recallAndInline({ sessionId: SESS, query: q, limit: 3, source: "resume" }, reader);
72
+ assert.equal(first.empty, false);
73
+ assert.equal(second.empty, true, "second resume does not re-inject (sentinel)");
74
+ });
75
+ test("cleanup", () => {
76
+ rmSync(baseTmp, { recursive: true, force: true });
77
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * recall.ts — Layer 5 (RECALL / INLINE): the unified injection path.
3
+ *
4
+ * ONE vector store, THREE entry points, ONE dedup engine. Every way context
5
+ * gets re-injected into the window (auto-inline on resume, on-demand
6
+ * /recall-context, and the dedup sentinel) goes through `recallAndInline`.
7
+ * It always does: search -> dedupe -> inject. The only thing that differs per
8
+ * entry point is *what triggers it* and *what query it uses*.
9
+ *
10
+ * Injection respects PREVENT-PI-003: pi has no `system` message role, so we
11
+ * prepend our recall block to the system prompt via the `before_agent_start`
12
+ * hook's `systemPrompt` result (the extension wires that). This module is
13
+ * pi-agnostic: it returns an injectable text block and records injections; the
14
+ * extension decides where it lands.
15
+ */
16
+ import { recall as searchRecall } from "./engine.js";
17
+ /** Wrap a recall block so the model reads it as restored compacted context. */
18
+ export function formatRecallBlock(hits) {
19
+ if (hits.length === 0)
20
+ return "";
21
+ const parts = hits.map((h, i) => {
22
+ const score = (h.score * 100).toFixed(0);
23
+ return (`### Recalled context [${i + 1}] (relevance ${score}%)\n` +
24
+ `${h.checkpoint.summary.trim()}\n` +
25
+ (h.checkpoint.filesModified.length
26
+ ? `Key files: ${h.checkpoint.filesModified.join(", ")}.\n`
27
+ : ""));
28
+ });
29
+ return ("The following compacted context was recalled from earlier in this session " +
30
+ "and is relevant to the current request. Treat it as background you already know:\n\n" +
31
+ parts.join("\n"));
32
+ }
33
+ /**
34
+ * Run the unified recall+dudupe+prepare-inject pipeline. Does NOT touch pi;
35
+ * it records injections via `markInjected` so the next call dedupes. The
36
+ * `store` is passed by the extension (defaults to the engine's default store).
37
+ */
38
+ export function recallAndInline(opts, store) {
39
+ const limit = opts.limit ?? 3;
40
+ const skip = opts.skipInjected ?? true;
41
+ const { hits } = searchRecall({ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: false }, store);
42
+ // Shared dedup: drop checkpoints already injected this session, then mark the
43
+ // survivors so repeated triggers are free. (Cosine near-dup collapse already
44
+ // happened inside store.search.)
45
+ const toInject = [];
46
+ for (const h of hits) {
47
+ if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId))
48
+ continue;
49
+ toInject.push(h);
50
+ store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
51
+ }
52
+ const block = formatRecallBlock(toInject);
53
+ const report = toInject.map((h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`);
54
+ return {
55
+ toInject,
56
+ report,
57
+ block,
58
+ empty: toInject.length === 0,
59
+ };
60
+ }
@@ -0,0 +1,50 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { VectorStore } from "./vectorStore.js";
7
+ import { compactSession } from "./engine.js";
8
+ import { recallAndInline, formatRecallBlock } from "./recall.js";
9
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-recall-"));
10
+ let counter = 0;
11
+ function store() {
12
+ return new VectorStore({ dedupSim: 0.9, stateDir: join(baseTmp, `run-${counter++}`) });
13
+ }
14
+ function msg(role, text, toolName) {
15
+ return toolName ? { role, text, toolName, input: text, output: text } : { role, text };
16
+ }
17
+ const SESS = "sess_recall";
18
+ test("recallAndInline injects new hits and marks them injected", () => {
19
+ const s = store();
20
+ compactSession({ sessionId: SESS, messages: [msg("user", "investigated src/vectorStore.ts embedding"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
21
+ compactSession({ sessionId: SESS, messages: [msg("user", "fixed the dedupe race in store.ts"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
22
+ const r1 = recallAndInline({ sessionId: SESS, query: "vectorStore embedding", limit: 1, source: "command" }, s);
23
+ assert.equal(r1.empty, false);
24
+ assert.equal(r1.toInject.length, 1);
25
+ assert.ok(r1.block.includes("Recalled context"));
26
+ // Second call with the same query must NOT re-inject (shared dedup).
27
+ const r2 = recallAndInline({ sessionId: SESS, query: "vectorStore embedding", limit: 1, source: "command" }, s);
28
+ assert.equal(r2.empty, true);
29
+ assert.equal(r2.toInject.length, 0);
30
+ });
31
+ test("recallAndInline skipInjected=false re-returns hits", () => {
32
+ const s = store();
33
+ compactSession({ sessionId: SESS, messages: [msg("user", "configured the fast gate threshold"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
34
+ const r1 = recallAndInline({ sessionId: SESS, query: "fast gate threshold", limit: 5, source: "resume" }, s);
35
+ const r2 = recallAndInline({ sessionId: SESS, query: "fast gate threshold", limit: 5, source: "resume", skipInjected: false }, s);
36
+ assert.equal(r1.toInject.length, 1);
37
+ assert.equal(r2.toInject.length, 1);
38
+ });
39
+ test("formatRecallBlock is empty for no hits", () => {
40
+ assert.equal(formatRecallBlock([]), "");
41
+ });
42
+ test("recallAndInline empty when store has nothing for query", () => {
43
+ const s = store();
44
+ const r = recallAndInline({ sessionId: SESS, query: "no such topic exists here", limit: 5, source: "command" }, s);
45
+ assert.equal(r.empty, true);
46
+ assert.equal(r.block, "");
47
+ });
48
+ test("cleanup", () => {
49
+ rmSync(baseTmp, { recursive: true, force: true });
50
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * sprint14.test.ts — Sprint 14 full-pipeline wiring (flags, backfill, monitoring, canary).
3
+ * Hermetic: isolated state dirs, no network, no remote.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { VectorStore } from "./vectorStore.js";
11
+ import { defaultEmbedder } from "./embedder.js";
12
+ import { loadDedupConfig } from "./config/dedup.js";
13
+ import { loadMetrics, saveMetrics, recordDecision, evaluateAlerts, fpRate, p95, } from "./monitoring.js";
14
+ import { backfillPhase, backfillRaptor } from "./store/backfill.js";
15
+ import { listCheckpoints, closeStore } from "./store/sqlite.js";
16
+ import { CanaryController, runCanary } from "./canary.js";
17
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-s14-"));
18
+ function cfg(over = {}) {
19
+ return { ...loadDedupConfig(), ...over };
20
+ }
21
+ function store(over = {}, eventsPath) {
22
+ const dir = join(baseTmp, `run-${Math.floor(performance.now() * 1000)}-${Math.random()}`);
23
+ return new VectorStore({ stateDir: dir, config: cfg(over), eventsPath });
24
+ }
25
+ // --- 1. Flag matrix: 16 combos don't crash add()/search() -------------------
26
+ test("flag matrix: all 16 L0/L1/L2/RAPTOR enable combos are safe", () => {
27
+ const flags = [false, true];
28
+ let combos = 0;
29
+ for (const l0 of flags)
30
+ for (const l1 of flags)
31
+ for (const l2 of flags)
32
+ for (const raptor of flags) {
33
+ combos++;
34
+ const s = store({ L0_ENABLED: l0, L1_ENABLED: l1, L2_ENABLED: l2, RAPTOR_ENABLED: raptor });
35
+ s.add({ sessionId: "s", summary: "x", regionText: `region A for combo ${combos} about the cache`, timestamp: 1 });
36
+ const r2 = s.add({ sessionId: "s", summary: "x", regionText: `region B for combo ${combos} about the parser`, timestamp: 2 });
37
+ // search must not throw under any combination
38
+ const hits = s.search("s", "cache", 3);
39
+ assert.ok(Array.isArray(hits));
40
+ // With all tiers off, the second add is always "new" (no collapse).
41
+ if (!l0 && !l1 && !l2)
42
+ assert.equal(r2.deduped, false);
43
+ }
44
+ assert.equal(combos, 16);
45
+ });
46
+ // --- 2. MARK_ONLY_L1 records but doesn't collapse ---------------------------
47
+ test("MARK_ONLY_L1: L1 match is recorded but not collapsed (new checkpoint)", () => {
48
+ // Disable L2 so we isolate L1 behavior (L2 cosine would otherwise catch the near-dup).
49
+ const s = store({ L1_ENABLED: true, MARK_ONLY_L1: true, L2_ENABLED: false });
50
+ const a = s.add({ sessionId: "s", summary: "x", regionText: "the parser optimized the hot loop", timestamp: 1 });
51
+ const b = s.add({ sessionId: "s", summary: "x", regionText: "the parser optimized the hot loops", timestamp: 2 });
52
+ assert.equal(a.deduped, false);
53
+ // MARK_ONLY → b is NOT collapsed into a; both stored as active.
54
+ assert.equal(b.deduped, false);
55
+ const all = listCheckpoints("s", s.stateDir);
56
+ assert.equal(all.length, 2);
57
+ assert.ok(all.every((c) => c.dedupStatus === "active"));
58
+ });
59
+ test("MARK_ONLY_L1 off: L1 match IS collapsed", () => {
60
+ const s = store({ L1_ENABLED: true, MARK_ONLY_L1: false });
61
+ s.add({ sessionId: "s", summary: "x", regionText: "the parser optimized the hot loop", timestamp: 1 });
62
+ const b = s.add({ sessionId: "s", summary: "x", regionText: "the parser optimized the hot loops", timestamp: 2 });
63
+ assert.equal(b.deduped, true);
64
+ assert.equal(b.reason, "l1MinHash");
65
+ });
66
+ // --- 3. Backfill resumes after interrupt -------------------------------------
67
+ test("backfill L1 resumes after simulated interrupt", () => {
68
+ const dir = join(baseTmp, `bf-${Math.floor(performance.now())}`);
69
+ // Seed 12 checkpoints, 2 per batch of 5.
70
+ const s = new VectorStore({ stateDir: dir, config: cfg() });
71
+ const texts = [
72
+ "The walrus drifted past the lighthouse while the baker kneaded sourdough at dawn",
73
+ "Quantum entanglement linked the two photons across the lab in a cryogenic chamber",
74
+ "A medieval scribe copied the gospel by candlelight atop a windswept cliff",
75
+ "The rover sampled basalt from the crater and transmitted spectra to mission control",
76
+ "Jazz musicians improvised a syncopated triangle rhythm beneath the streetlamp",
77
+ "The glacier calved a towering iceberg into the fjord with a thunderous crack",
78
+ "A botanist cataloged the orchid species thriving in the cloud forest canopy",
79
+ "The blacksmith forged a horseshoe while sparks danced across the anvil",
80
+ "Astronomers imaged a distant nebula glowing with newborn stellar furnaces",
81
+ "The ferry crossed the strait as gulls wheeled above the churning wake",
82
+ "A weaver threaded crimson silk through the loom in the mountain village",
83
+ "The surgeon sutured the incision with steady hands under the theatre lights",
84
+ ];
85
+ for (let i = 0; i < 12; i++) {
86
+ s.add({ sessionId: "sess_bf", summary: `n${i}`, regionText: texts[i], timestamp: i });
87
+ }
88
+ // Interrupt after batch 1 (5 rows).
89
+ const r1 = backfillPhase("L1", "sess_bf", dir, { batchSize: 5, interruptAfterBatches: 1 });
90
+ assert.equal(r1.interrupted, true);
91
+ assert.equal(r1.processed, 5);
92
+ assert.ok(r1.cursor === "chkpt_005" || r1.cursor === "chkpt_05");
93
+ // Resume: should continue from the cursor → process the remaining 7.
94
+ const r2 = backfillPhase("L1", "sess_bf", dir, { batchSize: 5 });
95
+ assert.equal(r2.interrupted, false);
96
+ assert.equal(r2.processed, 12); // total across both runs (cursor-based resume)
97
+ closeStore(dir);
98
+ });
99
+ // --- 4. Alert fires on injected FP spike -------------------------------------
100
+ test("alert: FP spike breaches threshold → MARK_ONLY flagged + warning", () => {
101
+ const config = cfg();
102
+ const m = loadMetrics("/dev/null");
103
+ // Inject 100 L1 decisions, 20 false positives → 20% > FP_RATE_L1L2 (5%).
104
+ for (let i = 0; i < 100; i++) {
105
+ recordDecision(m, "L1", i < 20 ? "deduped" : "new", 5, i < 20);
106
+ }
107
+ const res = evaluateAlerts(m, config);
108
+ assert.ok(res.breached.includes("L1"));
109
+ assert.ok(res.warnings.some((w) => w.includes("DEDUP FP BREACH tier=L1")));
110
+ assert.ok(fpRate(m, "L1") > config.FP_RATE_L1L2);
111
+ });
112
+ test("alert: clean run does NOT breach", () => {
113
+ const config = cfg();
114
+ const m = loadMetrics("/dev/null");
115
+ for (let i = 0; i < 100; i++)
116
+ recordDecision(m, "L1", "new", 5, false);
117
+ const res = evaluateAlerts(m, config);
118
+ assert.equal(res.breached.length, 0);
119
+ });
120
+ // --- 5. Canary auto-disables a tier whose p95 exceeds budget ---------------
121
+ test("canary: auto-disables a tier whose p95 exceeds budget", () => {
122
+ // Feed where L2 always has high latency (breaches P95_BUDGET_MS).
123
+ const feed = (_step, c) => {
124
+ const m = loadMetrics("/dev/null");
125
+ if (c.L0_ENABLED)
126
+ recordDecision(m, "L0", "new", 1, false);
127
+ if (c.L1_ENABLED)
128
+ recordDecision(m, "L1", "new", 1, false);
129
+ if (c.L2_ENABLED)
130
+ recordDecision(m, "L2", "new", 500, false); // > 100ms budget
131
+ if (c.RAPTOR_ENABLED)
132
+ recordDecision(m, "RAPTOR", "new", 1, false);
133
+ return m;
134
+ };
135
+ const { controller, disabled } = runCanary(feed, cfg({ P95_BUDGET_MS: 100 }));
136
+ assert.ok(disabled.includes("L2"), `expected L2 auto-disabled, got ${JSON.stringify(disabled)}`);
137
+ assert.equal(controller.config.L2_ENABLED, false);
138
+ // Lower tiers should still be enabled.
139
+ assert.equal(controller.config.L0_ENABLED, true);
140
+ });
141
+ test("canary: sequential enablement order L0→L1→L2→RAPTOR", () => {
142
+ const c = new CanaryController(cfg());
143
+ assert.deepEqual([...c.getState().enabled], ["L0"]);
144
+ const t1 = c.stepForward();
145
+ assert.equal(t1, "L1");
146
+ assert.equal(c.stepForward(), "L2");
147
+ assert.equal(c.stepForward(), "RAPTOR");
148
+ assert.equal(c.stepForward(), null); // all enabled
149
+ });
150
+ // --- 6. Monitoring: structured decision events written ----------------------
151
+ test("monitoring: add() writes structured decision events to events.log", () => {
152
+ const dir = join(baseTmp, `mon-${Math.floor(performance.now())}`);
153
+ const eventsPath = join(dir, "events.log");
154
+ const s = new VectorStore({
155
+ stateDir: dir,
156
+ config: cfg(),
157
+ eventsPath,
158
+ });
159
+ s.add({ sessionId: "s", summary: "x", regionText: "unique region alpha one", timestamp: 1 });
160
+ s.add({ sessionId: "s", summary: "x", regionText: "unique region alpha one", timestamp: 2 }); // L0 content dup
161
+ assert.ok(existsSync(eventsPath));
162
+ const lines = readFileSync(eventsPath, "utf-8").trim().split("\n").filter(Boolean);
163
+ assert.ok(lines.length >= 2);
164
+ const ev = JSON.parse(lines[0]);
165
+ assert.ok(["L0"].includes(ev.tier));
166
+ assert.ok(["new", "deduped", "mark_only"].includes(ev.result));
167
+ closeStore(dir);
168
+ });
169
+ // --- 7. RAPTOR backfill builds + persists a tree ---------------------------
170
+ test("backfill RAPTOR builds + persists a tree for a session", () => {
171
+ const dir = join(baseTmp, `raptorbf-${Math.floor(performance.now())}`);
172
+ const s = new VectorStore({ stateDir: dir, config: cfg() });
173
+ const texts = [
174
+ "The whale breached beside the research vessel near the polar ice shelf",
175
+ "A potter shaped the clay vessel on the spinning wheel at the riverside studio",
176
+ "The comet streaked across the pre dawn sky witnessed by the hilltop observatory",
177
+ "Lumberjacks felled the ancient cedar while the river carried the logs downstream",
178
+ "The chemist titrated the solution until the indicator turned faint violet",
179
+ "A flock of cranes migrated northward over the thawing wetland at first light",
180
+ "The locksmith picked the stubborn tumbler and opened the oak cabinet",
181
+ "Geologists hammered the schist sample from the canyon wall into the satchel",
182
+ "The chocolatier tempered the couverture until it snapped with a clean gloss",
183
+ "A fisher cast the line into the mist where the trout rose to the fly",
184
+ "The archivist unsealed the parchment scroll recovered from the coastal ruin",
185
+ "Beekeepers harvested the golden comb while the orchard blossoms drifted down",
186
+ "The pilot navigated the canyon winds using only the instrument panel glow",
187
+ "A tailor stitched the velvet cuff with silk thread by the window",
188
+ "The miner extracted the quartz crystal from the vein deep in the shaft",
189
+ "Cartographers plotted the uncharted island onto the worn leather map",
190
+ "The gardener pruned the rosebush and tied the canes to the cedar trellis",
191
+ "A violinist tuned the gut strings until the chamber rang pure and bright",
192
+ "The diver surfaced with the amphora lifted from the sunken galleon",
193
+ "Shepherds guided the flock across the high pasture toward the stone bothy",
194
+ ];
195
+ for (let i = 0; i < 20; i++) {
196
+ s.add({ sessionId: "sess_rb", summary: `n${i}`, regionText: texts[i], timestamp: i });
197
+ }
198
+ const res = backfillRaptor("sess_rb", dir, defaultEmbedder());
199
+ assert.ok(res.processed > 0);
200
+ // Sanity: source checkpoints remain intact (backfill is additive).
201
+ const nodes = listCheckpoints("sess_rb", dir).length;
202
+ assert.ok(nodes >= 20);
203
+ closeStore(dir);
204
+ });
205
+ // --- 8. dashboard.json metrics round-trip -----------------------------------
206
+ test("metrics: dashboard.json persists + p95 computed", () => {
207
+ const path = join(baseTmp, `dash-${Math.floor(performance.now())}.json`);
208
+ const m = loadMetrics(path);
209
+ for (let i = 0; i < 10; i++)
210
+ recordDecision(m, "L2", "new", i * 10, false);
211
+ saveMetrics(path, m);
212
+ const reloaded = loadMetrics(path);
213
+ assert.equal(reloaded.decisions.L2, 10);
214
+ assert.equal(p95(reloaded.latency.L2), 90); // 95th pct of [0,10,..,90]
215
+ });
216
+ // --- cleanup ----------------------------------------------------------------
217
+ test("Sprint 14 cleanup", () => {
218
+ rmSync(baseTmp, { recursive: true, force: true });
219
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * backfill.ts — resumable / idempotent hash backfill (Sprint 10).
3
+ *
4
+ * Purpose: populate `content_hash` / `content_hash2` / `content_hash_version` /
5
+ * `normalized_text` for any rows left with null hashes (e.g. pre-Sprint-9 data
6
+ * or rows that degraded to "store without dedup" under the QA #13 timeout).
7
+ * Structured so Sprint 11 can plug in its own MinHash/LSH phase after the
8
+ * content hashes land.
9
+ *
10
+ * Properties (QA #1 / QA #14):
11
+ * - Resumable: progress stored in a `backfill_progress` table (last processed id).
12
+ * - Idempotent: ON CONFLICT DO NOTHING + partial UNIQUE on (session_id, content_hash)
13
+ * make a second run a no-op where it safely can.
14
+ * - Batched: 1000 rows/commit to bound lock time; throttle between batches.
15
+ *
16
+ * SQLite is the source of truth; this touches no network (PREVENT-PI-004).
17
+ */
18
+ import { openStore } from "./sqlite.js";
19
+ import { computeContentDigest } from "../dedup/digest.js";
20
+ import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "../dedup/l1-minhash.js";
21
+ import { lshBands } from "../dedup/l1-lsh.js";
22
+ import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree } from "./sqlite.js";
23
+ import { buildRaptorTree } from "../dedup/raptor/tree.js";
24
+ import { defaultEmbedder } from "../embedder.js";
25
+ import { getStateDir } from "../store.js";
26
+ const BATCH = 1000;
27
+ const THROTTLE_MS = 0; // synchronous backfill; no cross-process yield needed
28
+ function ensureProgressTable(db) {
29
+ db.exec(`
30
+ CREATE TABLE IF NOT EXISTS backfill_progress (
31
+ name TEXT PRIMARY KEY,
32
+ last_session_id TEXT, -- session of the highest (session_id, id) scanned
33
+ last_id TEXT, -- highest context_chunks.id scanned within that session
34
+ updated INTEGER,
35
+ duplicates_resolved INTEGER
36
+ );
37
+ `);
38
+ }
39
+ function progress(db) {
40
+ const row = db
41
+ .prepare("SELECT last_session_id, last_id, updated, duplicates_resolved FROM backfill_progress WHERE name='content_hashes'")
42
+ .get();
43
+ return { lastSid: row?.last_session_id ?? null, lastId: row?.last_id ?? null, updated: row?.updated ?? 0, dups: row?.duplicates_resolved ?? 0 };
44
+ }
45
+ /**
46
+ * Backfill content hashes for all rows missing them, from the last scanned
47
+ * (session_id, id) forward. Returns counts; fully idempotent and resumable.
48
+ */
49
+ export function backfillContentHashes(stateDir = getStateDir()) {
50
+ const db = openStore(stateDir);
51
+ ensureProgressTable(db);
52
+ const start = progress(db);
53
+ // Rows needing hashing: null content_hash, ordered by (session_id, id) for a
54
+ // stable, resumable cursor (ids are only unique per session).
55
+ const pending = db
56
+ .prepare(`SELECT id, session_id, summary FROM context_chunks
57
+ WHERE content_hash IS NULL
58
+ AND (session_id > COALESCE(?, '')
59
+ OR (session_id = COALESCE(?, '') AND id > COALESCE(?, '')))
60
+ ORDER BY session_id ASC, id ASC LIMIT ?`)
61
+ .all(start.lastSid, start.lastSid, start.lastId, BATCH);
62
+ let updated = start.updated;
63
+ let duplicatesResolved = start.dups;
64
+ let processed = 0;
65
+ let lastSid = start.lastSid;
66
+ let lastId = start.lastId;
67
+ const tx = db.transaction((rows) => {
68
+ const lookup = db.prepare("SELECT id FROM context_chunks WHERE session_id = ? AND content_hash = ? AND content_hash2 = ? AND id != ? LIMIT 1");
69
+ const update = db.prepare(`UPDATE context_chunks
70
+ SET content_hash=?, content_hash2=?, content_hash_version=?, normalized_text=?,
71
+ dedup_status='active'
72
+ WHERE id=?`);
73
+ for (const row of rows) {
74
+ const digest = computeContentDigest(row.summary ?? "");
75
+ // Keep the oldest row on a collision (partial UNIQUE would reject the
76
+ // newer insert); mark the newer one as superseded-without-store.
77
+ const clash = lookup.get(row.session_id, digest.contentHash, digest.contentHash2, row.id);
78
+ if (clash) {
79
+ db.prepare("UPDATE context_chunks SET dedup_status='dup-resolved' WHERE id=?").run(row.id);
80
+ duplicatesResolved++;
81
+ }
82
+ else {
83
+ update.run(digest.contentHash, digest.contentHash2, digest.contentHashVersion, digest.normalizedText, row.id);
84
+ updated++;
85
+ }
86
+ lastSid = row.session_id;
87
+ lastId = row.id;
88
+ processed++;
89
+ }
90
+ });
91
+ if (pending.length > 0) {
92
+ tx(pending);
93
+ db.prepare("INSERT INTO backfill_progress(name, last_session_id, last_id, updated, duplicates_resolved) VALUES('content_hashes',?,?,?,?) ON CONFLICT(name) DO UPDATE SET last_session_id=excluded.last_session_id, last_id=excluded.last_id, updated=excluded.updated, duplicates_resolved=excluded.duplicates_resolved").run(lastSid, lastId, updated, duplicatesResolved);
94
+ }
95
+ if (THROTTLE_MS > 0) {
96
+ // No-op in this synchronous build; placeholder for future streaming backfill.
97
+ }
98
+ return { processed, updated, duplicatesResolved };
99
+ }
100
+ /** True when no rows remain pending (backfill complete for this state dir). */
101
+ export function isBackfillComplete(stateDir = getStateDir()) {
102
+ const db = openStore(stateDir);
103
+ const row = db
104
+ .prepare("SELECT COUNT(*) AS c FROM context_chunks WHERE content_hash IS NULL")
105
+ .get();
106
+ return row.c === 0;
107
+ }
108
+ // ---- Sprint 14: L1 / L2 / RAPTOR phase backfill (resumable) ---------------
109
+ function phaseCursor(db, phase) {
110
+ ensureProgressTable(db);
111
+ const row = db
112
+ .prepare("SELECT last_id, updated AS processed FROM backfill_progress WHERE name = ?")
113
+ .get(`phase_${phase}`);
114
+ return { lastId: row?.last_id ?? null, processed: row?.processed ?? 0 };
115
+ }
116
+ function savePhaseCursor(db, phase, lastId, processed) {
117
+ db.prepare(`INSERT INTO backfill_progress(name, last_session_id, last_id, updated, duplicates_resolved)
118
+ VALUES(?, NULL, ?, ?, 0)
119
+ ON CONFLICT(name) DO UPDATE SET last_id=excluded.last_id, updated=excluded.updated`).run(`phase_${phase}`, lastId, processed);
120
+ }
121
+ /**
122
+ * Backfill L1 (MinHash sigs + LSH buckets) or L2 (MinHash sigs only) derived
123
+ * data for a session, in batches, resumable from the persisted cursor. Pass
124
+ * `interruptAfterBatches` to simulate a crash for resume testing.
125
+ */
126
+ export function backfillPhase(phase, sessionId, stateDir, opts = {}) {
127
+ const db = openStore(stateDir);
128
+ const batchSize = opts.batchSize ?? BATCH;
129
+ const all = listCheckpoints(sessionId, stateDir).sort((a, b) => a.checkpointId.localeCompare(b.checkpointId));
130
+ const { lastId } = phaseCursor(db, phase);
131
+ let { processed } = phaseCursor(db, phase);
132
+ const startIndex = lastId ? all.findIndex((c) => c.checkpointId === lastId) + 1 : 0;
133
+ let batches = 0;
134
+ let interrupted = false;
135
+ let cursor = lastId ?? undefined;
136
+ for (let i = Math.max(0, startIndex); i < all.length; i += batchSize) {
137
+ const batch = all.slice(i, i + batchSize);
138
+ const tx = db.transaction(() => {
139
+ for (const cp of batch) {
140
+ const sig = minhashSignature(cp.normalizedText ?? cp.summary ?? "");
141
+ if (sig.length === NUM_HASHES) {
142
+ upsertMinhashSignature(cp.checkpointId, sessionId, SIGNATURE_VERSION, sig, stateDir);
143
+ if (phase === "L1") {
144
+ insertLshBuckets(cp.checkpointId, sessionId, SIGNATURE_VERSION, lshBands(sig, sessionId, SIGNATURE_VERSION), stateDir);
145
+ }
146
+ }
147
+ cursor = cp.checkpointId;
148
+ processed++;
149
+ }
150
+ });
151
+ tx();
152
+ savePhaseCursor(db, phase, cursor ?? null, processed);
153
+ batches++;
154
+ if (THROTTLE_MS > 0) {
155
+ const end = Date.now() + THROTTLE_MS;
156
+ while (Date.now() < end) { /* throttle */ }
157
+ }
158
+ if (opts.interruptAfterBatches && batches >= opts.interruptAfterBatches) {
159
+ interrupted = true;
160
+ break;
161
+ }
162
+ }
163
+ return { phase, processed, batches, interrupted, cursor };
164
+ }
165
+ /**
166
+ * Backfill the RAPTOR tree for a session (single pass over all leaves). Builds
167
+ * + persists raptor_nodes. Not batched — the builder has its own budget cap.
168
+ */
169
+ export function backfillRaptor(sessionId, stateDir, embedder = defaultEmbedder()) {
170
+ const all = listCheckpoints(sessionId, stateDir).sort((a, b) => a.checkpointId.localeCompare(b.checkpointId));
171
+ const leaves = all.map((cp) => {
172
+ const text = cp.normalizedText ?? cp.summary ?? "";
173
+ return {
174
+ id: cp.checkpointId,
175
+ messages: [{ role: "user", text }],
176
+ sourceText: text,
177
+ embedding: embedder.embed(text),
178
+ };
179
+ });
180
+ if (leaves.length === 0) {
181
+ return { phase: "RAPTOR", processed: 0, batches: 0, interrupted: false, cursor: undefined };
182
+ }
183
+ const tree = buildRaptorTree(leaves, { embedder });
184
+ saveRaptorTree(sessionId, tree, stateDir);
185
+ const db = openStore(stateDir);
186
+ ensureProgressTable(db);
187
+ savePhaseCursor(db, "RAPTOR", leaves[leaves.length - 1].id, leaves.length);
188
+ return { phase: "RAPTOR", processed: leaves.length, batches: 1, interrupted: false, cursor: leaves[leaves.length - 1].id };
189
+ }