pi-mega-compact 0.8.24 → 0.8.26

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 (111) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/mega-compact-s38.test.js +263 -14
  3. package/dist/extensions/mega-compact.js +15 -0
  4. package/dist/extensions/mega-config.js +3 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  6. package/dist/extensions/mega-events/context-handler.js +45 -7
  7. package/dist/extensions/mega-events/error-classifier.js +125 -18
  8. package/dist/extensions/mega-pipeline/compact.js +24 -13
  9. package/dist/extensions/mega-pipeline/recall.js +31 -2
  10. package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
  12. package/dist/extensions/mega-runtime/runtime.js +58 -5
  13. package/dist/src/boundary.js +79 -43
  14. package/dist/src/boundary.test.js +119 -2
  15. package/dist/src/canary.js +10 -0
  16. package/dist/src/config/dedup.js +14 -0
  17. package/dist/src/config.js +3 -1
  18. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  19. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  20. package/dist/src/dedup/raptor/index.js +38 -0
  21. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  22. package/dist/src/dedup/raptor/multilevel.js +17 -5
  23. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  24. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  25. package/dist/src/dedup/raptor/retrieval.js +14 -2
  26. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  27. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  28. package/dist/src/dedup/raptor/summarizer.js +1 -0
  29. package/dist/src/dedup/raptor/tree.js +16 -2
  30. package/dist/src/engine.js +18 -2
  31. package/dist/src/httpEmbedder.js +96 -6
  32. package/dist/src/httpEmbedder.test.js +277 -0
  33. package/dist/src/mechanical-fix.test.js +65 -0
  34. package/dist/src/raptor-inject-summaries.test.js +162 -0
  35. package/dist/src/recall.js +153 -24
  36. package/dist/src/recall.test.js +179 -4
  37. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  38. package/dist/src/store/sqlite/maintenance.js +2 -2
  39. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  40. package/dist/src/store/sqlite/memories.js +5 -5
  41. package/dist/src/store/sqlite/meta.js +1 -1
  42. package/dist/src/store/sqlite/raptor.js +56 -17
  43. package/dist/src/store/sqlite/raptor.test.js +106 -0
  44. package/dist/src/store/sqlite/schema.js +90 -1
  45. package/dist/src/store/sqlite/session-state.js +9 -3
  46. package/dist/src/store/sqlite/stats.js +9 -5
  47. package/dist/src/store/sqlite/turns.js +179 -0
  48. package/dist/src/store/sqlite/turns.test.js +183 -0
  49. package/dist/src/store/sqlite/utils.js +15 -4
  50. package/dist/src/store/sqlite.js +1 -0
  51. package/dist/src/store.js +2 -2
  52. package/dist/src/vector-search-cache.test.js +157 -0
  53. package/dist/src/vector-search.js +107 -15
  54. package/dist/src/vectorStore.js +36 -8
  55. package/extensions/mega-compact-s38.test.ts +259 -14
  56. package/extensions/mega-compact.ts +15 -0
  57. package/extensions/mega-config.ts +18 -0
  58. package/extensions/mega-dashboard.ts +10 -1
  59. package/extensions/mega-events/agent-handlers.ts +211 -26
  60. package/extensions/mega-events/context-handler.ts +43 -7
  61. package/extensions/mega-events/error-classifier.ts +125 -17
  62. package/extensions/mega-pipeline/compact.ts +28 -16
  63. package/extensions/mega-pipeline/recall.ts +34 -2
  64. package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
  65. package/extensions/mega-runtime/helpers.ts +25 -1
  66. package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
  67. package/extensions/mega-runtime/runtime.ts +69 -23
  68. package/package.json +1 -1
  69. package/src/boundary.test.ts +128 -2
  70. package/src/boundary.ts +75 -39
  71. package/src/canary.ts +10 -0
  72. package/src/config/dedup.ts +25 -0
  73. package/src/config.ts +3 -1
  74. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  75. package/src/dedup/raptor/buildHistory.ts +259 -0
  76. package/src/dedup/raptor/index.ts +38 -0
  77. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  78. package/src/dedup/raptor/multilevel.test.ts +47 -0
  79. package/src/dedup/raptor/multilevel.ts +18 -8
  80. package/src/dedup/raptor/raptor.test.ts +59 -0
  81. package/src/dedup/raptor/retrieval.test.ts +118 -0
  82. package/src/dedup/raptor/retrieval.ts +14 -2
  83. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  84. package/src/dedup/raptor/summarizer.ts +1 -0
  85. package/src/dedup/raptor/tree.ts +17 -2
  86. package/src/engine.ts +32 -3
  87. package/src/httpEmbedder.test.ts +286 -0
  88. package/src/httpEmbedder.ts +98 -8
  89. package/src/mechanical-fix.test.ts +70 -0
  90. package/src/raptor-inject-summaries.test.ts +228 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +462 -265
  93. package/src/store/sqlite/dedup-mirror.ts +35 -18
  94. package/src/store/sqlite/maintenance.ts +2 -2
  95. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  96. package/src/store/sqlite/memories.ts +5 -5
  97. package/src/store/sqlite/meta.ts +1 -1
  98. package/src/store/sqlite/raptor.test.ts +139 -0
  99. package/src/store/sqlite/raptor.ts +135 -81
  100. package/src/store/sqlite/schema.ts +90 -1
  101. package/src/store/sqlite/session-state.ts +9 -3
  102. package/src/store/sqlite/stats.ts +10 -8
  103. package/src/store/sqlite/turns.test.ts +218 -0
  104. package/src/store/sqlite/turns.ts +302 -0
  105. package/src/store/sqlite/utils.ts +14 -4
  106. package/src/store/sqlite.ts +1 -0
  107. package/src/store.ts +9 -2
  108. package/src/vector-search-cache.test.ts +190 -0
  109. package/src/vector-search.ts +273 -156
  110. package/src/vectorStore.ts +443 -382
  111. package/extensions/mega-runtime/reset-runtime.ts +0 -80
@@ -21,6 +21,9 @@ export interface DedupMirrorRowDB {
21
21
  /**
22
22
  * Upsert a row into dedup_mirror. If the hash already exists, increment ref_count.
23
23
  * Returns true if this was a NEW unique content (first insert), false if it was a duplicate.
24
+ *
25
+ * F3 fix: uses INSERT ... ON CONFLICT DO UPDATE (single atomic statement) instead of
26
+ * a check-then-act race-prone SELECT + UPDATE/INSERT sequence.
24
27
  */
25
28
  export function upsertDedupMirror(
26
29
  db: DatabaseSync,
@@ -29,29 +32,35 @@ export function upsertDedupMirror(
29
32
  seq: number,
30
33
  ): boolean {
31
34
  const now = Date.now();
32
- const existing = db
33
- .prepare(`SELECT content_hash FROM dedup_mirror WHERE content_hash = @hash`)
34
- .get({ "@hash": contentHash }) as { content_hash: string } | undefined;
35
- if (existing) {
36
- db.prepare(`UPDATE dedup_mirror SET ref_count = ref_count + 1 WHERE content_hash = @hash`).run({
37
- "@hash": contentHash,
38
- });
39
- return false;
40
- }
41
- db.prepare(
35
+ // Atomic upsert: on conflict, increment ref_count in-place (no check-then-act
36
+ // race). RETURNING ref_count distinguishes the two paths in one statement:
37
+ // inserted rows report ref_count=1, conflict-updated rows report ref_count>1.
38
+ const row = db.prepare(
42
39
  `INSERT INTO dedup_mirror (content_hash, content_bytes, ref_count, first_seen_seq, created_at)
43
- VALUES (@hash, @bytes, 1, @seq, @now)`,
44
- ).run({
40
+ VALUES (@hash, @bytes, 1, @seq, @now)
41
+ ON CONFLICT(content_hash) DO UPDATE SET
42
+ ref_count = ref_count + 1,
43
+ content_bytes = excluded.content_bytes
44
+ RETURNING ref_count`,
45
+ ).get({
45
46
  "@hash": contentHash,
46
47
  "@bytes": contentBytes,
47
48
  "@seq": seq,
48
49
  "@now": now,
49
- });
50
- return true;
50
+ }) as { ref_count: number } | undefined;
51
+ return (row?.ref_count ?? 1) === 1;
51
52
  }
52
53
 
53
54
  /**
54
55
  * Get dedup ratio for a session: total bytes vs unique bytes.
56
+ *
57
+ * F2 fix: both total and unique bytes are now scoped to the session, via a JOIN
58
+ * of raw_transcript.content_ref → dedup_mirror. The ratio is meaningful: how much
59
+ * smaller the session's storage footprint is compared to naive inline storage.
60
+ *
61
+ * NOTE: for sessions with NO dedup pipeline runs yet (all content_ref NULL),
62
+ * uniqueBytes falls back to the raw_transcript bytes (ratio=1), which is correct
63
+ * since nothing has been deduplicated yet.
55
64
  */
56
65
  export function getDedupRatio(
57
66
  db: DatabaseSync,
@@ -64,12 +73,20 @@ export function getDedupRatio(
64
73
  WHERE session_id = @session_id`,
65
74
  )
66
75
  .get({ "@session_id": sessionId }) as { total: number };
76
+ // F2 fix: session-scoped unique bytes via JOIN on content_ref.
77
+ // A row contributes its dedup_mirror bytes exactly once even when content_ref
78
+ // is NULL (fallback: use the raw_transcript bytes for that row, which is
79
+ // accurate when dedup hasn't run yet for the session).
67
80
  const uniqueRow = db
68
81
  .prepare(
69
- `SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS unique_bytes
70
- FROM dedup_mirror`,
82
+ `SELECT COALESCE(SUM(LENGTH(
83
+ COALESCE(dm.content_bytes, rt.content_bytes)
84
+ )), 0) AS unique_bytes
85
+ FROM raw_transcript rt
86
+ LEFT JOIN dedup_mirror dm ON rt.content_ref = dm.content_hash
87
+ WHERE rt.session_id = @session_id`,
71
88
  )
72
- .get() as { unique_bytes: number };
89
+ .get({ "@session_id": sessionId }) as { unique_bytes: number };
73
90
  const totalBytes = totalRow.total;
74
91
  const uniqueBytes = uniqueRow.unique_bytes;
75
92
  const ratio = uniqueBytes > 0 ? totalBytes / uniqueBytes : 1;
@@ -111,4 +128,4 @@ export function updateRawTranscriptRef(
111
128
  "@sid": sessionId,
112
129
  "@seq": seq,
113
130
  });
114
- }
131
+ }
@@ -36,7 +36,7 @@ const DB_TABLE_NAMES = [
36
36
  "checkpoint_epochs",
37
37
  "dedup_mirror",
38
38
  "memories",
39
- "dedup_stats",
39
+ "meta",
40
40
  "daily_log",
41
41
  ] as const;
42
42
 
@@ -136,7 +136,7 @@ export function pruneOldRows(stateDir: string = getStateDir(), daysOld = 30): Ma
136
136
  // dedup_mirror: cascade-delete orphan rows whose ref_count has dropped to 0
137
137
  // after the raw_transcript deletes. Safe even if FK is off (raw_transcript has
138
138
  // no FK to dedup_mirror; ref_count is maintained by the dedup pipeline).
139
- const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE ref_count <= 0`).run() as {
139
+ const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE content_hash NOT IN (SELECT DISTINCT content_ref FROM raw_transcript WHERE content_ref IS NOT NULL)`).run() as {
140
140
  changes?: number;
141
141
  } | undefined;
142
142
  const dedupDeleted = delDedup?.changes ?? 0;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * mechanical-fix.test.ts — focused unit tests for the SQLite-side mechanical-fix
3
+ * batch: safeJson (utils.ts), storeStats numeric MAX + dedup_status filter
4
+ * (stats.ts), addTokensSaved CAST AS REAL (meta.ts), evictMemoryLru named
5
+ * @param bindings (memories.ts). Pi-agnostic; uses isolated state dirs (G7).
6
+ */
7
+ import { describe, it, before, after } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { safeJson, closeStore } from "./utils.js";
13
+ import { storeStats } from "./stats.js";
14
+ import { upsertCheckpoint, setDedupStatus } from "./checkpoints.js";
15
+ import { addTokensSaved, getTokensSaved } from "./meta.js";
16
+ import { addMemory, listMemories } from "./memories.js";
17
+ import type { StoredCheckpoint } from "../../store.js";
18
+
19
+ function makeCp(id: string, sessionId: string): StoredCheckpoint {
20
+ return {
21
+ checkpointId: id,
22
+ sessionId,
23
+ summary: `summary-${id}`,
24
+ keyDecisions: [],
25
+ nextSteps: [],
26
+ filesModified: [],
27
+ tokenEstimate: 100,
28
+ regionHash: `hash-${id}`,
29
+ embedding: [],
30
+ timestamp: Date.now(),
31
+ };
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // safeJson (utils.ts)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("mechanical-fix: safeJson (utils.ts)", () => {
39
+ it("returns fallback on null, undefined, empty, and corrupt JSON", () => {
40
+ assert.deepEqual(safeJson<string[]>(null, []), []);
41
+ assert.deepEqual(safeJson<string[]>(undefined, []), []);
42
+ assert.deepEqual(safeJson<string[]>("", []), []);
43
+ assert.deepEqual(safeJson<string[]>("not json", ["fb"]), ["fb"]);
44
+ assert.deepEqual(safeJson<string[]>('["a","b"]', []), ["a", "b"]);
45
+ assert.equal(safeJson<number>(null, 42), 42);
46
+ assert.deepEqual(safeJson<Record<string, number>>('{"k":1}', {}), { k: 1 });
47
+ });
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // storeStats (stats.ts) — numeric MAX(id) + dedup_status filter
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe("mechanical-fix: storeStats (stats.ts)", () => {
55
+ let dir: string;
56
+ before(() => {
57
+ dir = mkdtempSync(join(tmpdir(), "mc-mech-stats-"));
58
+ process.env.MEGACOMPACT_STATE_DIR = dir;
59
+ });
60
+ after(() => {
61
+ closeStore(dir);
62
+ delete process.env.MEGACOMPACT_STATE_DIR;
63
+ rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ it("returns numerically-max checkpoint id (not lexicographic) for 100+ checkpoints", () => {
67
+ const sid = "sess_stats";
68
+ // Insert chkpt_001..chkpt_100 (100 checkpoints) + chkpt_999 + chkpt_1000.
69
+ // Numeric max = 1000 → "chkpt_1000"; lexicographic max would be "chkpt_999"
70
+ // (because '9' > '1' at position 6 when comparing 8 vs 9-char strings).
71
+ for (let i = 1; i <= 100; i++) {
72
+ upsertCheckpoint(makeCp(`chkpt_${String(i).padStart(3, "0")}`, sid), dir);
73
+ }
74
+ upsertCheckpoint(makeCp("chkpt_999", sid), dir);
75
+ upsertCheckpoint(makeCp("chkpt_1000", sid), dir);
76
+
77
+ const s = storeStats(sid, dir);
78
+ assert.equal(s.checkpointCount, 102);
79
+ assert.equal(s.lastCheckpointId, "chkpt_1000");
80
+ assert.equal(s.lastSummary, "summary-chkpt_1000");
81
+ });
82
+
83
+ it("excludes dedup_status='removed' rows from counts", () => {
84
+ const sid = "sess_dedup";
85
+ upsertCheckpoint(makeCp("chkpt_001", sid), dir);
86
+ upsertCheckpoint(makeCp("chkpt_002", sid), dir);
87
+ setDedupStatus("chkpt_001", sid, "removed", dir);
88
+ const s = storeStats(sid, dir);
89
+ assert.equal(s.checkpointCount, 1);
90
+ assert.equal(s.lastCheckpointId, "chkpt_002");
91
+ });
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // addTokensSaved (meta.ts) — CAST AS REAL preserves fractional values
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("mechanical-fix: addTokensSaved (meta.ts)", () => {
99
+ let dir: string;
100
+ before(() => {
101
+ dir = mkdtempSync(join(tmpdir(), "mc-mech-meta-"));
102
+ process.env.MEGACOMPACT_STATE_DIR = dir;
103
+ });
104
+ after(() => {
105
+ closeStore(dir);
106
+ delete process.env.MEGACOMPACT_STATE_DIR;
107
+ rmSync(dir, { recursive: true, force: true });
108
+ });
109
+
110
+ it("preserves fractional values (CAST AS REAL, not INTEGER)", () => {
111
+ addTokensSaved(10.5, dir);
112
+ assert.equal(getTokensSaved(dir), 10.5);
113
+ addTokensSaved(10.5, dir);
114
+ // With INTEGER: CAST("10.5" AS INTEGER)=10, 10+10.5=20.5.
115
+ // With REAL: CAST("10.5" AS REAL)=10.5, 10.5+10.5=21.
116
+ assert.equal(getTokensSaved(dir), 21);
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // evictMemoryLru (memories.ts) — named @param bindings
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("mechanical-fix: evictMemoryLru (memories.ts)", () => {
125
+ let dir: string;
126
+ before(() => {
127
+ dir = mkdtempSync(join(tmpdir(), "mc-mech-mem-"));
128
+ process.env.MEGACOMPACT_STATE_DIR = dir;
129
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "3";
130
+ });
131
+ after(() => {
132
+ closeStore(dir);
133
+ delete process.env.MEGACOMPACT_STATE_DIR;
134
+ delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
135
+ rmSync(dir, { recursive: true, force: true });
136
+ });
137
+
138
+ it("evicts oldest rows past cap (repo-scoped, @repo named binding)", () => {
139
+ const repo = "test-repo";
140
+ for (let i = 0; i < 5; i++) {
141
+ addMemory({ content: `memory-${i}` }, repo, dir);
142
+ }
143
+ const memories = listMemories(repo, 100, dir);
144
+ assert.equal(memories.length, 3);
145
+ // Oldest two (memory-0, memory-1) evicted; memory-2, 3, 4 survive.
146
+ const contents = memories.map((m) => m.content).sort();
147
+ assert.deepEqual(contents, ["memory-2", "memory-3", "memory-4"]);
148
+ });
149
+
150
+ it("handles null-repo scope (repo IS NULL branch, @over only)", () => {
151
+ for (let i = 0; i < 4; i++) {
152
+ addMemory({ content: `nullmem-${i}` }, null, dir);
153
+ }
154
+ // listMemories(null, ...) returns ALL rows (no repo filter); narrow to
155
+ // null-repo rows to verify the IS NULL eviction branch in isolation.
156
+ const nullMemories = listMemories(null, 100, dir).filter((m) => m.repo === null);
157
+ assert.equal(nullMemories.length, 3);
158
+ // Oldest (nullmem-0) evicted.
159
+ const contents = nullMemories.map((m) => m.content).sort();
160
+ assert.deepEqual(contents, ["nullmem-1", "nullmem-2", "nullmem-3"]);
161
+ });
162
+ });
@@ -53,10 +53,10 @@ function evictMemoryLru(repo: string | null, stateDir: string): void {
53
53
  const maxRows = memoryMaxRows();
54
54
  // SQLite `= NULL` is never true, so the null-repo scope (memories are
55
55
  // stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
56
- const where = repo == null ? "repo IS NULL" : "repo = ?";
56
+ const where = repo == null ? "repo IS NULL" : "repo = @repo";
57
57
  const countRow = repo == null
58
58
  ? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
59
- : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
59
+ : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get({ "@repo": repo });
60
60
  const count = (countRow as { n: number }).n;
61
61
  const over = count - maxRows;
62
62
  if (over <= 0) return;
@@ -67,10 +67,10 @@ function evictMemoryLru(repo: string | null, stateDir: string): void {
67
67
  `DELETE FROM memories WHERE ${where} AND id IN (
68
68
  SELECT id FROM memories WHERE ${where}
69
69
  ORDER BY COALESCE(last_referenced, last_recalled_at, created_at) ASC, id ASC
70
- LIMIT ?
70
+ LIMIT @over
71
71
  )`;
72
- if (repo == null) db.prepare(sql).run(over);
73
- else db.prepare(sql).run(repo, repo, over);
72
+ if (repo == null) db.prepare(sql).run({ "@over": over });
73
+ else db.prepare(sql).run({ "@over": over, "@repo": repo });
74
74
  }
75
75
 
76
76
  export interface MemoryRecord {
@@ -33,7 +33,7 @@ export function addTokensSaved(delta: number, stateDir: string = getStateDir()):
33
33
  const db = openStore(stateDir);
34
34
  db.prepare(
35
35
  `INSERT INTO meta(key, value) VALUES('tokens_saved', ?)
36
- ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`,
36
+ ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS REAL) + ? AS TEXT)`,
37
37
  ).run(String(delta), delta);
38
38
  }
39
39
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * raptor.test.ts — regression tests for RAPTOR node persistence.
3
+ *
4
+ * Covers:
5
+ * 1. saveRaptorTree atomicity (withTx + openStore nesting safety): rebuilding
6
+ * a DIFFERENT tree for the same session leaves no stale nodes from the
7
+ * first tree.
8
+ * 2. listRaptorNodes robustness: corrupt `children` JSON in a row does not
9
+ * throw and that row's children come back as [].
10
+ *
11
+ * No network. Uses node:sqlite directly via openStore.
12
+ */
13
+
14
+ import { test, beforeEach, afterEach } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdtempSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { openStore, closeStore } from "./utils.js";
20
+ import { saveRaptorTree, listRaptorNodes, type StoredRaptorNode } from "./raptor.js";
21
+
22
+ let tmpDir: string;
23
+
24
+ beforeEach(() => {
25
+ tmpDir = mkdtempSync(join(tmpdir(), "mc-raptor-persist-"));
26
+ });
27
+
28
+ afterEach(() => {
29
+ closeStore(tmpDir);
30
+ rmSync(tmpDir, { recursive: true, force: true });
31
+ });
32
+
33
+ /** Build a simple tree map with the minimal shape saveRaptorTree expects. */
34
+ function makeTree(nodeSpecs: { id: string; level: number; children: string[] }[]) {
35
+ const nodes = new Map();
36
+ for (const spec of nodeSpecs) {
37
+ nodes.set(spec.id, {
38
+ id: spec.id,
39
+ level: spec.level,
40
+ parentId: spec.level === 0 ? null : "root",
41
+ children: spec.children,
42
+ summary: `summary for ${spec.id}`,
43
+ embedding: [1, 0, 0],
44
+ qualityMarker: "low",
45
+ tokenEstimate: 10,
46
+ });
47
+ }
48
+ return { nodes };
49
+ }
50
+
51
+ // ── Test 1: rebuild + save a different tree → no stale nodes ──────────────────
52
+
53
+ test("saveRaptorTree: rebuilding a different tree for the same session leaves no stale nodes", () => {
54
+ const sid = "sess_replace";
55
+ const builtAt1 = 1000;
56
+ const builtAt2 = 2000;
57
+
58
+ // Tree A: nodes alpha, beta, root.
59
+ const treeA = makeTree([
60
+ { id: "alpha", level: 1, children: ["leaf_0", "leaf_1"] },
61
+ { id: "beta", level: 1, children: ["leaf_2", "leaf_3"] },
62
+ { id: "root", level: 2, children: ["leaf_0", "leaf_1", "leaf_2", "leaf_3"] },
63
+ ]);
64
+
65
+ saveRaptorTree(sid, treeA, builtAt1, tmpDir);
66
+
67
+ const afterA = listRaptorNodes(sid, tmpDir);
68
+ assert.equal(afterA.length, 3, "tree A has 3 nodes");
69
+ assert.deepEqual(
70
+ afterA.map((n) => n.id).sort(),
71
+ ["alpha", "beta", "root"],
72
+ "tree A node ids match",
73
+ );
74
+
75
+ // Tree B: completely different nodes gamma, delta, root.
76
+ const treeB = makeTree([
77
+ { id: "gamma", level: 1, children: ["leaf_4", "leaf_5"] },
78
+ { id: "delta", level: 1, children: ["leaf_6", "leaf_7"] },
79
+ { id: "root", level: 2, children: ["leaf_4", "leaf_5", "leaf_6", "leaf_7"] },
80
+ ]);
81
+
82
+ saveRaptorTree(sid, treeB, builtAt2, tmpDir);
83
+
84
+ const afterB = listRaptorNodes(sid, tmpDir);
85
+ assert.equal(afterB.length, 3, "tree B has 3 nodes (stale A nodes gone)");
86
+
87
+ // No stale node ids from tree A survive.
88
+ const idsAfterB = new Set(afterB.map((n) => n.id));
89
+ assert.ok(!idsAfterB.has("alpha"), "stale node alpha is gone");
90
+ assert.ok(!idsAfterB.has("beta"), "stale node beta is gone");
91
+ assert.ok(idsAfterB.has("gamma"), "new node gamma present");
92
+ assert.ok(idsAfterB.has("delta"), "new node delta present");
93
+ assert.ok(idsAfterB.has("root"), "root present (upserted, not duplicated)");
94
+
95
+ // builtAt was updated to the new timestamp.
96
+ for (const n of afterB) {
97
+ assert.equal(n.builtAt, builtAt2, `node ${n.id} builtAt updated to tree B timestamp`);
98
+ }
99
+ });
100
+
101
+ // ── Test 2: corrupt children JSON → no throw, children = [] ──────────────────
102
+
103
+ test("listRaptorNodes: corrupt children JSON does not throw and returns []", () => {
104
+ const sid = "sess_corrupt";
105
+ const builtAt = 5000;
106
+
107
+ const tree = makeTree([
108
+ { id: "good", level: 1, children: ["leaf_0", "leaf_1"] },
109
+ { id: "bad", level: 1, children: ["leaf_2", "leaf_3"] },
110
+ { id: "root", level: 2, children: ["leaf_0", "leaf_1", "leaf_2", "leaf_3"] },
111
+ ]);
112
+
113
+ saveRaptorTree(sid, tree, builtAt, tmpDir);
114
+
115
+ // Corrupt the `children` JSON for the "bad" row directly in SQLite.
116
+ const db = openStore(tmpDir);
117
+ db.prepare(
118
+ "UPDATE raptor_nodes SET children = ? WHERE session_id = ? AND id = ?",
119
+ ).run("{not valid json", sid, "bad");
120
+
121
+ // listRaptorNodes must not throw.
122
+ let nodes: StoredRaptorNode[];
123
+ assert.doesNotThrow(() => {
124
+ nodes = listRaptorNodes(sid, tmpDir);
125
+ }, "listRaptorNodes must not throw on corrupt children JSON");
126
+
127
+ const badNode = nodes!.find((n) => n.id === "bad");
128
+ assert.ok(badNode, "bad node still present");
129
+ assert.deepEqual(badNode!.children, [], "corrupt children parsed to []");
130
+
131
+ // The good node is unaffected.
132
+ const goodNode = nodes!.find((n) => n.id === "good");
133
+ assert.ok(goodNode, "good node present");
134
+ assert.deepEqual(
135
+ goodNode!.children,
136
+ ["leaf_0", "leaf_1"],
137
+ "good node children intact",
138
+ );
139
+ });