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
@@ -0,0 +1,146 @@
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
+ function makeCp(id, sessionId) {
18
+ return {
19
+ checkpointId: id,
20
+ sessionId,
21
+ summary: `summary-${id}`,
22
+ keyDecisions: [],
23
+ nextSteps: [],
24
+ filesModified: [],
25
+ tokenEstimate: 100,
26
+ regionHash: `hash-${id}`,
27
+ embedding: [],
28
+ timestamp: Date.now(),
29
+ };
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // safeJson (utils.ts)
33
+ // ---------------------------------------------------------------------------
34
+ describe("mechanical-fix: safeJson (utils.ts)", () => {
35
+ it("returns fallback on null, undefined, empty, and corrupt JSON", () => {
36
+ assert.deepEqual(safeJson(null, []), []);
37
+ assert.deepEqual(safeJson(undefined, []), []);
38
+ assert.deepEqual(safeJson("", []), []);
39
+ assert.deepEqual(safeJson("not json", ["fb"]), ["fb"]);
40
+ assert.deepEqual(safeJson('["a","b"]', []), ["a", "b"]);
41
+ assert.equal(safeJson(null, 42), 42);
42
+ assert.deepEqual(safeJson('{"k":1}', {}), { k: 1 });
43
+ });
44
+ });
45
+ // ---------------------------------------------------------------------------
46
+ // storeStats (stats.ts) — numeric MAX(id) + dedup_status filter
47
+ // ---------------------------------------------------------------------------
48
+ describe("mechanical-fix: storeStats (stats.ts)", () => {
49
+ let dir;
50
+ before(() => {
51
+ dir = mkdtempSync(join(tmpdir(), "mc-mech-stats-"));
52
+ process.env.MEGACOMPACT_STATE_DIR = dir;
53
+ });
54
+ after(() => {
55
+ closeStore(dir);
56
+ delete process.env.MEGACOMPACT_STATE_DIR;
57
+ rmSync(dir, { recursive: true, force: true });
58
+ });
59
+ it("returns numerically-max checkpoint id (not lexicographic) for 100+ checkpoints", () => {
60
+ const sid = "sess_stats";
61
+ // Insert chkpt_001..chkpt_100 (100 checkpoints) + chkpt_999 + chkpt_1000.
62
+ // Numeric max = 1000 → "chkpt_1000"; lexicographic max would be "chkpt_999"
63
+ // (because '9' > '1' at position 6 when comparing 8 vs 9-char strings).
64
+ for (let i = 1; i <= 100; i++) {
65
+ upsertCheckpoint(makeCp(`chkpt_${String(i).padStart(3, "0")}`, sid), dir);
66
+ }
67
+ upsertCheckpoint(makeCp("chkpt_999", sid), dir);
68
+ upsertCheckpoint(makeCp("chkpt_1000", sid), dir);
69
+ const s = storeStats(sid, dir);
70
+ assert.equal(s.checkpointCount, 102);
71
+ assert.equal(s.lastCheckpointId, "chkpt_1000");
72
+ assert.equal(s.lastSummary, "summary-chkpt_1000");
73
+ });
74
+ it("excludes dedup_status='removed' rows from counts", () => {
75
+ const sid = "sess_dedup";
76
+ upsertCheckpoint(makeCp("chkpt_001", sid), dir);
77
+ upsertCheckpoint(makeCp("chkpt_002", sid), dir);
78
+ setDedupStatus("chkpt_001", sid, "removed", dir);
79
+ const s = storeStats(sid, dir);
80
+ assert.equal(s.checkpointCount, 1);
81
+ assert.equal(s.lastCheckpointId, "chkpt_002");
82
+ });
83
+ });
84
+ // ---------------------------------------------------------------------------
85
+ // addTokensSaved (meta.ts) — CAST AS REAL preserves fractional values
86
+ // ---------------------------------------------------------------------------
87
+ describe("mechanical-fix: addTokensSaved (meta.ts)", () => {
88
+ let dir;
89
+ before(() => {
90
+ dir = mkdtempSync(join(tmpdir(), "mc-mech-meta-"));
91
+ process.env.MEGACOMPACT_STATE_DIR = dir;
92
+ });
93
+ after(() => {
94
+ closeStore(dir);
95
+ delete process.env.MEGACOMPACT_STATE_DIR;
96
+ rmSync(dir, { recursive: true, force: true });
97
+ });
98
+ it("preserves fractional values (CAST AS REAL, not INTEGER)", () => {
99
+ addTokensSaved(10.5, dir);
100
+ assert.equal(getTokensSaved(dir), 10.5);
101
+ addTokensSaved(10.5, dir);
102
+ // With INTEGER: CAST("10.5" AS INTEGER)=10, 10+10.5=20.5.
103
+ // With REAL: CAST("10.5" AS REAL)=10.5, 10.5+10.5=21.
104
+ assert.equal(getTokensSaved(dir), 21);
105
+ });
106
+ });
107
+ // ---------------------------------------------------------------------------
108
+ // evictMemoryLru (memories.ts) — named @param bindings
109
+ // ---------------------------------------------------------------------------
110
+ describe("mechanical-fix: evictMemoryLru (memories.ts)", () => {
111
+ let dir;
112
+ before(() => {
113
+ dir = mkdtempSync(join(tmpdir(), "mc-mech-mem-"));
114
+ process.env.MEGACOMPACT_STATE_DIR = dir;
115
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "3";
116
+ });
117
+ after(() => {
118
+ closeStore(dir);
119
+ delete process.env.MEGACOMPACT_STATE_DIR;
120
+ delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
121
+ rmSync(dir, { recursive: true, force: true });
122
+ });
123
+ it("evicts oldest rows past cap (repo-scoped, @repo named binding)", () => {
124
+ const repo = "test-repo";
125
+ for (let i = 0; i < 5; i++) {
126
+ addMemory({ content: `memory-${i}` }, repo, dir);
127
+ }
128
+ const memories = listMemories(repo, 100, dir);
129
+ assert.equal(memories.length, 3);
130
+ // Oldest two (memory-0, memory-1) evicted; memory-2, 3, 4 survive.
131
+ const contents = memories.map((m) => m.content).sort();
132
+ assert.deepEqual(contents, ["memory-2", "memory-3", "memory-4"]);
133
+ });
134
+ it("handles null-repo scope (repo IS NULL branch, @over only)", () => {
135
+ for (let i = 0; i < 4; i++) {
136
+ addMemory({ content: `nullmem-${i}` }, null, dir);
137
+ }
138
+ // listMemories(null, ...) returns ALL rows (no repo filter); narrow to
139
+ // null-repo rows to verify the IS NULL eviction branch in isolation.
140
+ const nullMemories = listMemories(null, 100, dir).filter((m) => m.repo === null);
141
+ assert.equal(nullMemories.length, 3);
142
+ // Oldest (nullmem-0) evicted.
143
+ const contents = nullMemories.map((m) => m.content).sort();
144
+ assert.deepEqual(contents, ["nullmem-1", "nullmem-2", "nullmem-3"]);
145
+ });
146
+ });
@@ -49,10 +49,10 @@ function evictMemoryLru(repo, stateDir) {
49
49
  const maxRows = memoryMaxRows();
50
50
  // SQLite `= NULL` is never true, so the null-repo scope (memories are
51
51
  // stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
52
- const where = repo == null ? "repo IS NULL" : "repo = ?";
52
+ const where = repo == null ? "repo IS NULL" : "repo = @repo";
53
53
  const countRow = repo == null
54
54
  ? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
55
- : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
55
+ : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get({ "@repo": repo });
56
56
  const count = countRow.n;
57
57
  const over = count - maxRows;
58
58
  if (over <= 0)
@@ -63,12 +63,12 @@ function evictMemoryLru(repo, stateDir) {
63
63
  const sql = `DELETE FROM memories WHERE ${where} AND id IN (
64
64
  SELECT id FROM memories WHERE ${where}
65
65
  ORDER BY COALESCE(last_referenced, last_recalled_at, created_at) ASC, id ASC
66
- LIMIT ?
66
+ LIMIT @over
67
67
  )`;
68
68
  if (repo == null)
69
- db.prepare(sql).run(over);
69
+ db.prepare(sql).run({ "@over": over });
70
70
  else
71
- db.prepare(sql).run(repo, repo, over);
71
+ db.prepare(sql).run({ "@over": over, "@repo": repo });
72
72
  }
73
73
  /** Save a memory to the current repo's store. Returns the new row id.
74
74
  * S24 hardening: content is truncated to MEMORY_MAX_CHARS and, once the per-repo
@@ -28,7 +28,7 @@ export function addTokensSaved(delta, stateDir = getStateDir()) {
28
28
  return;
29
29
  const db = openStore(stateDir);
30
30
  db.prepare(`INSERT INTO meta(key, value) VALUES('tokens_saved', ?)
31
- ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`).run(String(delta), delta);
31
+ ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS REAL) + ? AS TEXT)`).run(String(delta), delta);
32
32
  }
33
33
  /** Read a store-wide integer counter from the meta table (0 if absent). */
34
34
  export function getMetaNumber(key, stateDir = getStateDir()) {
@@ -2,33 +2,60 @@
2
2
  * raptor.ts — Sprint 13 RAPTOR node persistence.
3
3
  */
4
4
  import { getStateDir, normalizeSessionId } from "../../store.js";
5
- import { openStore, jsonText, encodeEmbedding, decodeEmbedding } from "./utils.js";
5
+ import { openStore, withTx, jsonText, encodeEmbedding, decodeEmbedding, } from "./utils.js";
6
6
  /** Persist a single RAPTOR node (upsert by (session_id, id)). */
7
7
  export function upsertRaptorNode(node, stateDir = getStateDir()) {
8
8
  const db = openStore(stateDir);
9
9
  db.prepare(`INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate, built_at)
10
- VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10
+ VALUES(@id, @session_id, @level, @parent_id, @children, @summary, @embedding_blob, @quality_marker, @token_estimate, @built_at)
11
11
  ON CONFLICT(session_id, id) DO UPDATE SET
12
12
  level=excluded.level, parent_id=excluded.parent_id, children=excluded.children,
13
13
  summary=excluded.summary, embedding_blob=excluded.embedding_blob,
14
14
  quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate,
15
- built_at=excluded.built_at`).run(node.id, node.sessionId, node.level, node.parentId, jsonText(node.children), node.summary, encodeEmbedding(node.embedding), node.qualityMarker, node.tokenEstimate, node.builtAt);
15
+ built_at=excluded.built_at`).run({
16
+ id: node.id,
17
+ session_id: node.sessionId,
18
+ level: node.level,
19
+ parent_id: node.parentId,
20
+ children: jsonText(node.children),
21
+ summary: node.summary,
22
+ embedding_blob: encodeEmbedding(node.embedding),
23
+ quality_marker: node.qualityMarker,
24
+ token_estimate: node.tokenEstimate,
25
+ built_at: node.builtAt,
26
+ });
16
27
  }
17
28
  /** Persist an entire built RAPTOR tree for a session (shadow or live). */
18
29
  export function saveRaptorTree(sessionId, tree, builtAt, stateDir = getStateDir()) {
19
- for (const node of tree.nodes.values()) {
20
- upsertRaptorNode({
21
- id: node.id,
22
- sessionId,
23
- level: node.level,
24
- parentId: node.parentId,
25
- children: node.children,
26
- summary: node.summary,
27
- embedding: node.embedding,
28
- qualityMarker: node.qualityMarker,
29
- tokenEstimate: node.tokenEstimate,
30
- builtAt,
31
- }, stateDir);
30
+ const nsid = normalizeSessionId(sessionId);
31
+ const db = openStore(stateDir);
32
+ withTx(db, () => {
33
+ db.prepare("DELETE FROM raptor_nodes WHERE session_id = ?").run(nsid);
34
+ for (const node of tree.nodes.values()) {
35
+ upsertRaptorNode({
36
+ id: node.id,
37
+ sessionId: nsid,
38
+ level: node.level,
39
+ parentId: node.parentId,
40
+ children: node.children,
41
+ summary: node.summary,
42
+ embedding: node.embedding,
43
+ qualityMarker: node.qualityMarker,
44
+ tokenEstimate: node.tokenEstimate,
45
+ builtAt,
46
+ }, stateDir);
47
+ }
48
+ });
49
+ }
50
+ /** Safe JSON array parse — returns [] on corrupt input. */
51
+ function safeJsonArray(raw) {
52
+ if (typeof raw !== "string" || !raw)
53
+ return [];
54
+ try {
55
+ return JSON.parse(raw);
56
+ }
57
+ catch {
58
+ return [];
32
59
  }
33
60
  }
34
61
  /** Load all RAPTOR nodes for a session. */
@@ -42,7 +69,7 @@ export function listRaptorNodes(sessionId, stateDir = getStateDir()) {
42
69
  sessionId: row.session_id,
43
70
  level: row.level,
44
71
  parentId: row.parent_id ?? null,
45
- children: row.children ? JSON.parse(row.children) : [],
72
+ children: safeJsonArray(row.children),
46
73
  summary: row.summary ?? "",
47
74
  embedding: decodeEmbedding(row.embedding_blob),
48
75
  qualityMarker: row.quality_marker ?? "low",
@@ -55,3 +82,15 @@ export function clearRaptorNodes(sessionId, stateDir = getStateDir()) {
55
82
  const db = openStore(stateDir);
56
83
  db.prepare("DELETE FROM raptor_nodes WHERE session_id = ?").run(normalizeSessionId(sessionId));
57
84
  }
85
+ /**
86
+ * S25: newest built_at for a session's RAPTOR tree (0 if no nodes exist).
87
+ * Cheap indexed MAX query used by raptorSearchHits to validate cache freshness
88
+ * without rehydrating the full node Map.
89
+ */
90
+ export function maxRaptorNodeBuiltAt(sessionId, stateDir = getStateDir()) {
91
+ const db = openStore(stateDir);
92
+ const row = db
93
+ .prepare(`SELECT MAX(built_at) AS max_built FROM raptor_nodes WHERE session_id = ?`)
94
+ .get(normalizeSessionId(sessionId));
95
+ return row?.max_built ?? 0;
96
+ }
@@ -0,0 +1,106 @@
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
+ import { test, beforeEach, afterEach } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { openStore, closeStore } from "./utils.js";
19
+ import { saveRaptorTree, listRaptorNodes } from "./raptor.js";
20
+ let tmpDir;
21
+ beforeEach(() => {
22
+ tmpDir = mkdtempSync(join(tmpdir(), "mc-raptor-persist-"));
23
+ });
24
+ afterEach(() => {
25
+ closeStore(tmpDir);
26
+ rmSync(tmpDir, { recursive: true, force: true });
27
+ });
28
+ /** Build a simple tree map with the minimal shape saveRaptorTree expects. */
29
+ function makeTree(nodeSpecs) {
30
+ const nodes = new Map();
31
+ for (const spec of nodeSpecs) {
32
+ nodes.set(spec.id, {
33
+ id: spec.id,
34
+ level: spec.level,
35
+ parentId: spec.level === 0 ? null : "root",
36
+ children: spec.children,
37
+ summary: `summary for ${spec.id}`,
38
+ embedding: [1, 0, 0],
39
+ qualityMarker: "low",
40
+ tokenEstimate: 10,
41
+ });
42
+ }
43
+ return { nodes };
44
+ }
45
+ // ── Test 1: rebuild + save a different tree → no stale nodes ──────────────────
46
+ test("saveRaptorTree: rebuilding a different tree for the same session leaves no stale nodes", () => {
47
+ const sid = "sess_replace";
48
+ const builtAt1 = 1000;
49
+ const builtAt2 = 2000;
50
+ // Tree A: nodes alpha, beta, root.
51
+ const treeA = makeTree([
52
+ { id: "alpha", level: 1, children: ["leaf_0", "leaf_1"] },
53
+ { id: "beta", level: 1, children: ["leaf_2", "leaf_3"] },
54
+ { id: "root", level: 2, children: ["leaf_0", "leaf_1", "leaf_2", "leaf_3"] },
55
+ ]);
56
+ saveRaptorTree(sid, treeA, builtAt1, tmpDir);
57
+ const afterA = listRaptorNodes(sid, tmpDir);
58
+ assert.equal(afterA.length, 3, "tree A has 3 nodes");
59
+ assert.deepEqual(afterA.map((n) => n.id).sort(), ["alpha", "beta", "root"], "tree A node ids match");
60
+ // Tree B: completely different nodes gamma, delta, root.
61
+ const treeB = makeTree([
62
+ { id: "gamma", level: 1, children: ["leaf_4", "leaf_5"] },
63
+ { id: "delta", level: 1, children: ["leaf_6", "leaf_7"] },
64
+ { id: "root", level: 2, children: ["leaf_4", "leaf_5", "leaf_6", "leaf_7"] },
65
+ ]);
66
+ saveRaptorTree(sid, treeB, builtAt2, tmpDir);
67
+ const afterB = listRaptorNodes(sid, tmpDir);
68
+ assert.equal(afterB.length, 3, "tree B has 3 nodes (stale A nodes gone)");
69
+ // No stale node ids from tree A survive.
70
+ const idsAfterB = new Set(afterB.map((n) => n.id));
71
+ assert.ok(!idsAfterB.has("alpha"), "stale node alpha is gone");
72
+ assert.ok(!idsAfterB.has("beta"), "stale node beta is gone");
73
+ assert.ok(idsAfterB.has("gamma"), "new node gamma present");
74
+ assert.ok(idsAfterB.has("delta"), "new node delta present");
75
+ assert.ok(idsAfterB.has("root"), "root present (upserted, not duplicated)");
76
+ // builtAt was updated to the new timestamp.
77
+ for (const n of afterB) {
78
+ assert.equal(n.builtAt, builtAt2, `node ${n.id} builtAt updated to tree B timestamp`);
79
+ }
80
+ });
81
+ // ── Test 2: corrupt children JSON → no throw, children = [] ──────────────────
82
+ test("listRaptorNodes: corrupt children JSON does not throw and returns []", () => {
83
+ const sid = "sess_corrupt";
84
+ const builtAt = 5000;
85
+ const tree = makeTree([
86
+ { id: "good", level: 1, children: ["leaf_0", "leaf_1"] },
87
+ { id: "bad", level: 1, children: ["leaf_2", "leaf_3"] },
88
+ { id: "root", level: 2, children: ["leaf_0", "leaf_1", "leaf_2", "leaf_3"] },
89
+ ]);
90
+ saveRaptorTree(sid, tree, builtAt, tmpDir);
91
+ // Corrupt the `children` JSON for the "bad" row directly in SQLite.
92
+ const db = openStore(tmpDir);
93
+ db.prepare("UPDATE raptor_nodes SET children = ? WHERE session_id = ? AND id = ?").run("{not valid json", sid, "bad");
94
+ // listRaptorNodes must not throw.
95
+ let nodes;
96
+ assert.doesNotThrow(() => {
97
+ nodes = listRaptorNodes(sid, tmpDir);
98
+ }, "listRaptorNodes must not throw on corrupt children JSON");
99
+ const badNode = nodes.find((n) => n.id === "bad");
100
+ assert.ok(badNode, "bad node still present");
101
+ assert.deepEqual(badNode.children, [], "corrupt children parsed to []");
102
+ // The good node is unaffected.
103
+ const goodNode = nodes.find((n) => n.id === "good");
104
+ assert.ok(goodNode, "good node present");
105
+ assert.deepEqual(goodNode.children, ["leaf_0", "leaf_1"], "good node children intact");
106
+ });
@@ -42,6 +42,11 @@ export function initSchema(db) {
42
42
  CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_pk
43
43
  ON context_chunks(session_id, id);
44
44
  CREATE INDEX IF NOT EXISTS idx_chunks_session ON context_chunks(session_id);
45
+ -- S42D/QA perf: session-scoped timestamp ordering so the S25 RAPTOR
46
+ -- freshness guard's MAX(timestamp) (run on every RAPTOR search) is
47
+ -- index-satisfied rather than a full partition scan as checkpoints grow.
48
+ CREATE INDEX IF NOT EXISTS idx_chunks_session_ts
49
+ ON context_chunks(session_id, timestamp DESC);
45
50
  CREATE INDEX IF NOT EXISTS idx_chunks_region ON context_chunks(region_hash);
46
51
  CREATE INDEX IF NOT EXISTS idx_chunks_content ON context_chunks(content_hash);
47
52
  -- Partial UNIQUE (QA #1): null content_hash rows never violate the constraint;
@@ -71,7 +76,9 @@ export function initSchema(db) {
71
76
  CREATE TABLE IF NOT EXISTS session_state (
72
77
  session_id TEXT PRIMARY KEY,
73
78
  injected_checkpoint_ids TEXT, -- JSON array
74
- stored_region_hashes TEXT -- JSON array
79
+ stored_region_hashes TEXT, -- JSON array
80
+ conversation_id TEXT, -- S43: groups turns across resumes (/clear → new root)
81
+ last_turn_id INTEGER -- S43: most recent turn id (stable fork refs)
75
82
  );
76
83
 
77
84
  CREATE TABLE IF NOT EXISTS meta (
@@ -98,6 +105,28 @@ export function initSchema(db) {
98
105
  );
99
106
  CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
100
107
  CREATE INDEX IF NOT EXISTS idx_raptor_parent ON raptor_nodes(parent_id);
108
+ -- S42D/QA perf: session-scoped built_at ordering so the per-search cache
109
+ -- invalidation MAX(built_at) is index-satisfied.
110
+ CREATE INDEX IF NOT EXISTS idx_raptor_session_built
111
+ ON raptor_nodes(session_id, built_at DESC);
112
+
113
+ -- S42D: structured RAPTOR build history. One row per tree build; the
114
+ -- freshness check (buildHistory.ts) uses completed_at + leaf_count to skip
115
+ -- unnecessary rebuilds when the tree is recent and the chunk count is stable.
116
+ CREATE TABLE IF NOT EXISTS raptor_build_history (
117
+ build_id TEXT PRIMARY KEY,
118
+ session_id TEXT NOT NULL,
119
+ state_dir TEXT NOT NULL,
120
+ started_at INTEGER NOT NULL,
121
+ completed_at INTEGER NOT NULL,
122
+ node_count INTEGER NOT NULL,
123
+ leaf_count INTEGER NOT NULL,
124
+ depth INTEGER NOT NULL,
125
+ config_json TEXT NOT NULL, -- serialized BuildOptions
126
+ coherence_score REAL, -- avg intra-cluster cosine (post-build)
127
+ timed_out INTEGER NOT NULL DEFAULT 0
128
+ );
129
+ CREATE INDEX IF NOT EXISTS idx_raptor_build_session ON raptor_build_history(session_id);
101
130
 
102
131
  -- Foundation for future features (resume sessions, daily log, lessons
103
132
  -- learned). Scaffolded now so all store data lives in SQLite from day one;
@@ -216,6 +245,59 @@ export function initSchema(db) {
216
245
  );
217
246
  CREATE INDEX IF NOT EXISTS idx_epoch_session ON checkpoint_epochs(session_id, created_at DESC);
218
247
 
248
+ -- S43 (per-turn vector tracking): the relational spine for per-turn +
249
+ -- per-conversation memories. One row per turn_end; links turns to the
250
+ -- epoch that compacted them (epoch_id) and to the checkpoints/cluster
251
+ -- summaries that were RECALLED during that turn (turn_recall).
252
+ -- conversation_id groups turns across pi session resumes (/clear starts a
253
+ -- new conversation root; a fork carries parent_conversation_id).
254
+ CREATE TABLE IF NOT EXISTS turns (
255
+ id INTEGER PRIMARY KEY AUTOINCREMENT, -- global turn id
256
+ conversation_id TEXT NOT NULL,
257
+ session_id TEXT NOT NULL,
258
+ turn_index INTEGER NOT NULL, -- per-session turn (event.turnIndex)
259
+ role TEXT, -- 'user' | 'assistant' | 'tool' (turn's last role)
260
+ started_at INTEGER NOT NULL,
261
+ ended_at INTEGER, -- set at turn_end
262
+ ctx_tokens INTEGER, -- runtime.lastCtxTokens snapshot
263
+ ctx_percent REAL, -- runtime.lastCtxPercent
264
+ pressure_band TEXT, -- 'low'|'mid'|'high'|'critical'
265
+ model_id TEXT,
266
+ epoch_id TEXT, -- FK checkpoint_epochs (set when a compact closes this turn's epoch)
267
+ UNIQUE(session_id, turn_index)
268
+ );
269
+ CREATE INDEX IF NOT EXISTS idx_turns_conv ON turns(conversation_id, turn_index);
270
+ CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index);
271
+ CREATE INDEX IF NOT EXISTS idx_turns_epoch ON turns(epoch_id) WHERE epoch_id IS NOT NULL;
272
+
273
+ -- S43: recall provenance — which checkpoints/cluster summaries were
274
+ -- injected at which turn, their score, and the path that sourced them.
275
+ -- This is the per-turn vector data that makes memory quality measurable
276
+ -- per turn and enables recall-to-point (replay these checkpoint_ids into a
277
+ -- forked session). source: 'flat' | 'raptor' | 'cross-repo' | 'memory'.
278
+ CREATE TABLE IF NOT EXISTS turn_recall (
279
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
280
+ turn_id INTEGER NOT NULL,
281
+ checkpoint_id TEXT NOT NULL,
282
+ score REAL NOT NULL,
283
+ source TEXT NOT NULL,
284
+ raptor_level INTEGER, -- set for RAPTOR cluster hits
285
+ UNIQUE(turn_id, checkpoint_id)
286
+ );
287
+ CREATE INDEX IF NOT EXISTS idx_turn_recall_turn ON turn_recall(turn_id);
288
+ CREATE INDEX IF NOT EXISTS idx_turn_recall_cp ON turn_recall(checkpoint_id);
289
+
290
+ -- S43: conversation branch/fork registry. A row per fork: the child
291
+ -- conversation inherits the parent's recall state at fork_turn_id as its
292
+ -- starting injected-set. The root conversation has no row here.
293
+ CREATE TABLE IF NOT EXISTS conversation_branches (
294
+ conversation_id TEXT PRIMARY KEY,
295
+ parent_conversation_id TEXT NOT NULL,
296
+ fork_turn_id INTEGER NOT NULL, -- FK turns.id at the branch point
297
+ created_at INTEGER NOT NULL
298
+ );
299
+ CREATE INDEX IF NOT EXISTS idx_conv_branch_parent ON conversation_branches(parent_conversation_id);
300
+
219
301
  -- S27 Task 6: dedup_mirror for space-efficient deduplicated storage.
220
302
  -- Each unique content_hash stores its bytes ONCE; raw_transcript rows
221
303
  -- reference this table via content_ref instead of storing duplicate content_bytes inline.
@@ -293,6 +375,13 @@ export function initSchema(db) {
293
375
  // S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL → 0 →
294
376
  // treated as stale → flat fallback (safe).
295
377
  ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
378
+ // S43: turn_index on raw_transcript so a message points directly at its
379
+ // conversation turn (otherwise it must be inferred from seq ordering). NULL
380
+ // for legacy rows — turns written before S43 have no turn link.
381
+ ensureColumn(db, "raw_transcript", "turn_index", "INTEGER");
382
+ // S43: conversation_id + last_turn_id on session_state (legacy DBs have NULL).
383
+ ensureColumn(db, "session_state", "conversation_id", "TEXT");
384
+ ensureColumn(db, "session_state", "last_turn_id", "INTEGER");
296
385
  // S35: idempotent seed of the 9 achievement rows. ON CONFLICT(id) DO
297
386
  // NOTHING so a re-open never clobbers an already-unlocked row's
298
387
  // unlocked_at. No user input reaches this SQL (PREVENT-002 safe).
@@ -8,6 +8,8 @@ function loadSessionStateRow(sid, db) {
8
8
  return {
9
9
  injectedCheckpointIds: row.injected_checkpoint_ids ? JSON.parse(row.injected_checkpoint_ids) : [],
10
10
  storedRegionHashes: row.stored_region_hashes ? JSON.parse(row.stored_region_hashes) : [],
11
+ conversationId: row.conversation_id ?? undefined,
12
+ lastTurnId: row.last_turn_id ?? undefined,
11
13
  };
12
14
  }
13
15
  export function loadSessionState(sessionId, stateDir = getStateDir()) {
@@ -16,13 +18,17 @@ export function loadSessionState(sessionId, stateDir = getStateDir()) {
16
18
  export function saveSessionState(sessionId, state, stateDir = getStateDir()) {
17
19
  const db = openStore(stateDir);
18
20
  const sid = normalizeSessionId(sessionId);
19
- db.prepare(`INSERT INTO session_state(session_id, injected_checkpoint_ids, stored_region_hashes)
20
- VALUES(@sid, @inj, @reg)
21
+ db.prepare(`INSERT INTO session_state(session_id, injected_checkpoint_ids, stored_region_hashes, conversation_id, last_turn_id)
22
+ VALUES(@sid, @inj, @reg, @conv, @tid)
21
23
  ON CONFLICT(session_id) DO UPDATE SET
22
24
  injected_checkpoint_ids=excluded.injected_checkpoint_ids,
23
- stored_region_hashes=excluded.stored_region_hashes`).run({
25
+ stored_region_hashes=excluded.stored_region_hashes,
26
+ conversation_id=excluded.conversation_id,
27
+ last_turn_id=excluded.last_turn_id`).run({
24
28
  sid,
25
29
  inj: jsonText(state.injectedCheckpointIds),
26
30
  reg: jsonText(state.storedRegionHashes),
31
+ conv: state.conversationId ?? null,
32
+ tid: state.lastTurnId ?? null,
27
33
  });
28
34
  }
@@ -9,18 +9,22 @@ export function storeStats(sessionId, stateDir = getStateDir()) {
9
9
  const sid = normalizeSessionId(sessionId);
10
10
  const row = db
11
11
  .prepare(`SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
12
- MAX(id) AS lastId
13
- FROM context_chunks WHERE session_id = ?`)
12
+ MAX(CAST(SUBSTR(id, 7) AS INTEGER)) AS lastNum
13
+ FROM context_chunks WHERE session_id = ? AND dedup_status != 'removed'`)
14
14
  .get(sid);
15
+ let lastCheckpointId;
15
16
  let lastSummary;
16
- if (row.lastId) {
17
- const s = db.prepare("SELECT summary FROM context_chunks WHERE id = ?").get(row.lastId);
17
+ if (row.lastNum != null) {
18
+ lastCheckpointId = `chkpt_${String(row.lastNum).padStart(3, "0")}`;
19
+ const s = db
20
+ .prepare("SELECT summary FROM context_chunks WHERE session_id = ? AND id = ?")
21
+ .get(sid, lastCheckpointId);
18
22
  lastSummary = s?.summary;
19
23
  }
20
24
  return {
21
25
  checkpointCount: row.c,
22
26
  totalTokenEstimate: row.tok,
23
- lastCheckpointId: row.lastId ?? undefined,
27
+ lastCheckpointId,
24
28
  lastSummary,
25
29
  };
26
30
  }