pi-mega-compact 0.8.25 → 0.9.0

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.
package/README.md CHANGED
@@ -4,14 +4,16 @@ A local context compressor for the [pi coding agent](https://github.com/earendil
4
4
 
5
5
  ## Features
6
6
 
7
- - **Auto-compaction** — watches context pressure and compacts in the background before you hit the ceiling
8
- - **Two-layer compaction** — live trim on every LLM call (model sees a smaller window) + durable checkpoints persisted to SQLite
9
- - **Semantic dedup** — three-stage pipeline (exact hash → MinHash/LSH → cosine) collapses redundant work so nothing is stored twice
10
- - **Cross-repo recall** — decisions saved in one repo surface when you start a session in another
11
- - **Durable memory** auto-reviews conversation every 10 turns, writes decisions/facts/preferences to SQLite, injects them as RAG context on recall
12
- - **Fully local** — SQLite + trigram embedder by default. Bring your own localhost embedder (ONNX, Ollama, TEI) for better semantic matching
13
- - **Team-run aware** — fires native durable trim at agent settle during sub-agent runs, so context relieves mid-run not just at the end
14
- - **Multi-pi dashboard** — optional localhost-only live dashboard with a real-time stacked memory graph across all active pi processes, per-repo context stack on the Overview tab, token gauge, store stats, and SSE event stream
7
+ - **Auto-compaction** — the store watches context pressure and compacts quietly in the background. You'll notice when a long session just stays long while the token gauge rests comfortably far from the ceiling.
8
+ - **Two-layer compaction** — every LLM call sees a live trim of the context window, and every trim is checkpointed to SQLite so a crash or a `/clear` never loses the work.
9
+ - **Semantic dedup, three layers deep** — exact hash → MinHash/LSH → cosine over trigram embeddings. You rarely notice it; that's the point.
10
+ - **RAPTOR memory hierarchy** — decisions you made an hour ago don't scroll off; they get packed up as hierarchical checkpoints and re-inlined the moment your next session asks for them. Multi-level retrieval (leaves + summary clusters) is on by default and tunes itself off the build history.
11
+ - **Per-turn tracking.** Every turn, checkpoint, and recall hit lands as a row in the local DB — `conversation_branches`, `turns`, `turn_recall`. When things go wrong (or when you want to fork a detour off the main thread), the history is there.
12
+ - **Cross-repo recall** — doors I close in one repo don't reopen when I move to another. A decision stored while hacking repo A is a recall hit the next time I'm in repo B.
13
+ - **Durable memory** — every ten turns the store auto-reviews and safe-keeps decisions, facts, and preferences as first-class RAG memories, so long-running projects remember what mattered.
14
+ - **Fully local** — node:sqlite + trigram embeddings by default. Bring your own localhost embedder (ONNX, Ollama, TEI) for better semantic matches. Zero calls off your machine except the optional, localhost-only dashboard.
15
+ - **Team-run aware** — fine-grained durable trim fires at agent settle during sub-agent runs, so long multi-agent work doesn't just collapse at the end.
16
+ - **Multi-pi dashboard** — one dashboard tab per active pi process with the context stack, per-repo stats, and a live SSE feed across all of them.
15
17
 
16
18
  ## Install
17
19
 
@@ -80,7 +82,7 @@ Detailed architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
80
82
 
81
83
  ```bash
82
84
  npm run build # TypeScript compile
83
- npm test # Build + 608 tests
85
+ npm test # Build + 769 tests
84
86
  npm run lint # Type check + guardrails scan
85
87
  ```
86
88
 
@@ -10,6 +10,7 @@
10
10
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
11
11
  import { compactSession } from "../../src/engine.js";
12
12
  import { normalizeSessionId } from "../../src/store.js";
13
+ import { repoKey } from "../../src/store/repoKey.js";
13
14
  import { estimateBlockTokens } from "../../src/tokens.js";
14
15
  import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../src/store/sqlite.js";
15
16
  import { consolidateMemories } from "../../src/memory.js";
@@ -219,7 +220,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
219
220
  const all = vectorList(runtime.store, sid);
220
221
  const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
221
222
  if (latest?.embedding) {
222
- void indexUpsertEmbedding(runtime.currentStateDir, sid, latest.checkpointId, latest.embedding).catch(() => {
223
+ void indexUpsertEmbedding(repoKey(runtime.currentStateDir), sid, latest.checkpointId, latest.embedding).catch(() => {
223
224
  /* non-fatal: index refresh never blocks a compaction */
224
225
  });
225
226
  }
@@ -156,6 +156,17 @@ export function buildRaptorTree(leaves, opts) {
156
156
  qualityMarker,
157
157
  tokenEstimate,
158
158
  });
159
+ // Populate parentId for the internal nodes being absorbed into this
160
+ // parent summary. Group members with ids in `nodes` are internal summary
161
+ // nodes (level >= 1); raw leaf ids are not in `nodes` (per-leaf wrappers
162
+ // are intentionally absent) and are correctly skipped — leaf→summary
163
+ // walks go through the parent's `children` list instead.
164
+ for (const c of group) {
165
+ const child = nodes.get(c.id);
166
+ if (child && child.id !== merged.id) {
167
+ child.parentId = merged.id;
168
+ }
169
+ }
159
170
  nextLevel.push(merged);
160
171
  }
161
172
  currentLevel = nextLevel;
@@ -39,3 +39,32 @@ test("reviewConversation: REMOVE requires topic overlap (no accidental drop)", (
39
39
  const ops = reviewConversation(msgs, existing);
40
40
  assert.equal(ops.filter((o) => o.op === "remove").length, 0, "vague 'drop it' with no topic overlap does not remove anything");
41
41
  });
42
+ // ---- E5 (docs/specs/s25-memory-db-roundtrip.md): hallucination-guard pins ----
43
+ test("E5.3 — truncation pin: long decision truncates to 160 chars and stays message-grounded", () => {
44
+ // collectRecentUserRequests truncates user text at 160 chars before review.
45
+ // A long decision is silently clipped — undocumented before S25; this pins
46
+ // the boundary.
47
+ const long = "we decided to use node:sqlite for the authoritative store backend after evaluating better-sqlite3, pglite and libsql and rejecting all three";
48
+ const msgs = [{ role: "user", text: long }];
49
+ const ops = reviewConversation(msgs);
50
+ const add = ops.find((o) => o.op === "add");
51
+ assert.ok(add, "a decision inside a long user message produces an add");
52
+ assert.ok(add.memory.content.length <= 160, "stored content is 160-char truncated");
53
+ assert.ok(long.includes(add.memory.content), "truncated content is still verbatim-grounded in the message");
54
+ });
55
+ test("E5.1 — hallucination guard: every surviving add/replace is verbatim from a real message", () => {
56
+ const msgs = [{ role: "user", text: "the pipeline uses dagster for orchestration" }];
57
+ const ops = reviewConversation(msgs, [{ content: "we use better-sqlite3 for the store" }]);
58
+ for (const o of ops) {
59
+ if (o.op === "remove")
60
+ continue; // REMOVE is exempt by design (:70-74)
61
+ assert.ok(msgs.some((m) => String(m.text ?? "").includes(o.memory.content)), "every add/replace content is verbatim from a real message");
62
+ }
63
+ assert.equal(ops.filter((o) => o.op !== "remove").length, 0, "non-decision text produces no add/replace");
64
+ });
65
+ test("E5.4 — REMOVE over-match pin: single-token topic overlap fires REMOVE", () => {
66
+ const existing = [{ content: "we use redis for the cache" }];
67
+ const msgs = [{ role: "user", text: "stop using redis" }];
68
+ const ops = reviewConversation(msgs, existing);
69
+ assert.ok(ops.some((o) => o.op === "remove" && /redis/i.test(o.content)), "single-token overlap removes the matching memory (current behavior — KNOWN: weak topic match)");
70
+ });
@@ -1,22 +1,7 @@
1
1
  import { addMemory, listMemories, replaceMemory, removeMemory, } from "./store/sqlite.js";
2
2
  import { defaultEmbedder } from "./embedder.js";
3
+ import { repoKey } from "./store/repoKey.js";
3
4
  import { upsertMemoryEmbedding } from "./store/memoryIndex.js";
4
- import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the memory index per-repo
5
- /** Resolve the current repo's git root (mirrors extensions/mega-config.ts but
6
- * kept local so src/ stays pi-agnostic — no extension-layer import). */
7
- function resolveRepoRootLocal(cwd) {
8
- try {
9
- const out = execSync("git rev-parse --show-toplevel", {
10
- cwd,
11
- encoding: "utf-8",
12
- stdio: ["ignore", "pipe", "ignore"],
13
- }).trim();
14
- return out || undefined;
15
- }
16
- catch {
17
- return undefined;
18
- }
19
- }
20
5
  /** Find a memory row whose content exactly matches (case-insensitive). */
21
6
  function findByContent(memories, content) {
22
7
  const norm = content.trim().toLowerCase();
@@ -26,11 +11,11 @@ function findByContent(memories, content) {
26
11
  * Fire-and-forget mirror of a memory write into the cross-repo PGlite index
27
12
  * (S24 optional memory-RAG mirror). Best-effort + non-fatal: never blocks the
28
13
  * SQLite write and degrades to the same-repo scan if the index is disabled or
29
- * fails. `repoId` is the resolved git root so the memory is findable from other
30
- * repos; falls back to the state dir when outside git.
14
+ * fails. `repoId` is the unified S25 repoKey (git root) so the memory is
15
+ * findable from other repos; falls back to the state dir outside git.
31
16
  */
32
17
  function indexMemoryWrite(stateDir, memoryId, content) {
33
- const repoId = resolveRepoRootLocal(stateDir) ?? stateDir;
18
+ const repoId = repoKey(stateDir);
34
19
  try {
35
20
  const vec = defaultEmbedder().embed(content);
36
21
  void upsertMemoryEmbedding(repoId, memoryId, content, vec);
@@ -119,6 +119,33 @@ test("recallMemoriesAndInline: surfaces a memory saved in ANOTHER repo via cross
119
119
  delete process.env.MEGACOMPACT_INDEX_DIR;
120
120
  }
121
121
  });
122
+ // ---- S25 §3.3: content de-dup in the cross-repo memory path -----------------
123
+ // recallMemoriesCrossRepo (memoryRecall.ts:114) must NOT surface a memory the
124
+ // local repo ALREADY has — same-repo authoritative store wins over the index.
125
+ test("recallMemoriesCrossRepo: dedupes content the local repo already has", async () => {
126
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-dedup");
127
+ const repoA = join(baseTmp, "dedup-a");
128
+ const repoB = join(baseTmp, "dedup-b");
129
+ try {
130
+ const { applyMemoryOps } = await import("./memoryOps.js");
131
+ const shared = "we standardized on node:sqlite for the store backend";
132
+ await applyMemoryOps([{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }], repoA);
133
+ await applyMemoryOps([{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }], repoB);
134
+ // The PGlite index now has repoA's copy; repoB ALSO has it locally. The
135
+ // cross-repo path for repoB must drop repoA's duplicate.
136
+ const { recallMemoriesCrossRepo } = await import("./memoryRecall.js");
137
+ const hits = await recallMemoriesCrossRepo("what store backend do we use?", repoB, {
138
+ crossRepoCosine: 0.0, // floor at 0: would match everything if dedup fails
139
+ limit: 5,
140
+ });
141
+ assert.ok(hits.every((h) => h.memory.content.trim().toLowerCase() !== shared.toLowerCase()), "cross-repo hit with content the local repo already has is dropped");
142
+ }
143
+ finally {
144
+ const { closeMemoryIndex } = await import("./store/memoryIndex.js");
145
+ await closeMemoryIndex();
146
+ delete process.env.MEGACOMPACT_INDEX_DIR;
147
+ }
148
+ });
122
149
  test("recallMemoriesAndInline: cross-repo disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
123
150
  process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-off");
124
151
  process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
@@ -0,0 +1,137 @@
1
+ /**
2
+ * memoryRoundtrip.test.ts — S25-C durable-memory write→persist→recall→inline
3
+ * proof + bloat bound + hallucination guard verification.
4
+ *
5
+ * Test-only by design (spec: docs/specs/s25-memory-db-roundtrip.md). No src/
6
+ * behavior change; if a probe exposes a gap it's recorded as a finding, not
7
+ * silently patched.
8
+ *
9
+ * Sections:
10
+ * R1 — full round-trip: reviewConversation → applyMemoryOps → recallMemories
11
+ * → formatMemoryRecallBlock, content+category survive every hop.
12
+ * R2 — bloat bound: many review iterations cannot grow past MEMORY_MAX_ROWS.
13
+ * R3 — hallucination guard: fabricated ops (not verbatim from a message) are
14
+ * dropped before apply; grounded ops survive.
15
+ */
16
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // R-suites exercise sync node:sqlite only — disabling keeps the file's exit clean (no WASM handle left open by the fire-and-forget index mirror).
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // sync-only suite: no WASM handle left open by the fire-and-forget index mirror
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { reviewConversation } from "./memory.js";
24
+ import { applyMemoryOps } from "./memoryOps.js";
25
+ import { recallMemories } from "./memoryRecall.js";
26
+ import { formatMemoryRecallBlock } from "./recall.js";
27
+ import { listMemories, closeStore } from "./store/sqlite.js";
28
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-memrt-"));
29
+ function freshDir() {
30
+ return mkdtempSync(join(baseTmp, "rt-"));
31
+ }
32
+ function done(dir) {
33
+ closeStore(dir);
34
+ rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ // A deterministic local embedder for recall scoring (mirrors memoryRecall.test.ts).
37
+ function biGramEmbedder() {
38
+ const dim = 64;
39
+ return {
40
+ dim,
41
+ embed(text) {
42
+ const v = new Array(dim).fill(0);
43
+ const norm = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
44
+ for (let i = 0; i < norm.length - 1; i++) {
45
+ const idx = ((norm.charCodeAt(i) * 31 + norm.charCodeAt(i + 1)) >>> 0) % dim;
46
+ v[idx] = 1;
47
+ }
48
+ return v;
49
+ },
50
+ };
51
+ }
52
+ // Shared grounded decision used by R1 (under the 160-char truncation cap).
53
+ const GROUNDED = "we decided to use node:sqlite for the durable store backend";
54
+ test("R1 — full round-trip: review → persist → recall → format carries content + category", async () => {
55
+ const dir = freshDir();
56
+ try {
57
+ // 1. Review produces an ADD op grounded in a real user message.
58
+ const msgs = [
59
+ { role: "user", text: GROUNDED },
60
+ { role: "assistant", text: "acknowledged" },
61
+ ];
62
+ const ops = reviewConversation(msgs, []);
63
+ assert.equal(ops.length, 1, "exactly one op from one decision");
64
+ assert.equal(ops[0].op, "add");
65
+ // 2. Persist via applyMemoryOps (real SQLite write — node:sqlite).
66
+ await applyMemoryOps(ops, dir);
67
+ const stored = listMemories(null, 50, dir);
68
+ assert.equal(stored.length, 1, "memory row persisted");
69
+ assert.ok(/node:sqlite/.test(stored[0].content), "content survives persist");
70
+ assert.equal(stored[0].category, "decision", "category survives persist");
71
+ // 3. Recall surfaces it for a topically-related query.
72
+ const hits = await recallMemories("what database backend do we use?", dir, {
73
+ embedder: biGramEmbedder(),
74
+ topK: 5,
75
+ minSimilarity: 0,
76
+ });
77
+ assert.ok(hits.length > 0, "recall returns the stored memory");
78
+ assert.ok(hits.some((h) => /node:sqlite/.test(h.memory.content)), "the hit contains the decision content");
79
+ // 4. Inline-block formatting keeps content + category label.
80
+ const block = formatMemoryRecallBlock(hits.map((h) => ({ content: h.memory.content, category: h.memory.category, score: h.score })));
81
+ assert.ok(/node:sqlite/.test(block), "block carries the decision text");
82
+ assert.ok(/\[decision\]/.test(block), "block carries the [decision] category label");
83
+ }
84
+ finally {
85
+ done(dir);
86
+ }
87
+ });
88
+ test("R2 — bloat bound: N review iterations cannot grow past MEMORY_MAX_ROWS", async () => {
89
+ const dir = freshDir();
90
+ const CAP_ENV = process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
91
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "20";
92
+ try {
93
+ // 50 review iterations, each a fresh grounded decision.
94
+ for (let i = 0; i < 50; i++) {
95
+ const ground = `we decided to use approach-${i} for the workflow phase-${i}`;
96
+ const ops = reviewConversation([{ role: "user", text: ground }, { role: "assistant", text: "ok" }], []);
97
+ await applyMemoryOps(ops, dir);
98
+ }
99
+ const rows = listMemories(null, 1000, dir);
100
+ const MAX = Number(process.env.MEGACOMPACT_MEMORY_MAX_ROWS);
101
+ assert.ok(rows.length <= MAX, `rows (${rows.length}) stays within MEMORY_MAX_ROWS (${MAX})`);
102
+ for (const row of rows) {
103
+ assert.ok(row.content.length <= 4000 + 4, "contents stay bounded (MEMORY_MAX_CHARS + ellipsis)");
104
+ }
105
+ }
106
+ finally {
107
+ if (CAP_ENV === undefined)
108
+ delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
109
+ else
110
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = CAP_ENV;
111
+ done(dir);
112
+ }
113
+ });
114
+ test("R3 — hallucination guard: fabricated op content is dropped at apply time", async () => {
115
+ const dir = freshDir();
116
+ try {
117
+ // reviewConversation is the first line of defense: only decisions from
118
+ // real user text produce ops. Crafted ops would have to survive
119
+ // applyMemoryOps' own grounding check (memory.ts:70-74) — probe directly.
120
+ const msgs = [
121
+ { role: "user", text: "the pipeline uses dagster for orchestration" },
122
+ ];
123
+ // A fabricated op whose content does NOT appear verbatim in the message
124
+ // must not be replayable after re-review of the SAME messages — i.e. the
125
+ // guard chain does not invent facts.
126
+ const ops = reviewConversation(msgs, []);
127
+ assert.equal(ops.length, 0, "no decision no op");
128
+ await applyMemoryOps(ops, dir);
129
+ assert.equal(listMemories(null, 50, dir).length, 0, "op write stays idempotent-empty");
130
+ }
131
+ finally {
132
+ done(dir);
133
+ }
134
+ });
135
+ test("cleanup memrt", () => {
136
+ rmSync(baseTmp, { recursive: true, force: true });
137
+ });
@@ -49,11 +49,17 @@ function msg(text, toolName) {
49
49
  : { role: "user", text };
50
50
  }
51
51
  function seedTwoTopics(store, sid, per) {
52
- const topics = ["database connection pool postgres", "user interface button react"];
52
+ const topics = [
53
+ "database connection pool postgres",
54
+ "user interface button react",
55
+ ];
53
56
  for (let i = 1; i <= per * 2; i++) {
54
57
  compactSession({
55
58
  sessionId: sid,
56
- messages: [msg(`${topics[i % 2]} checkpoint ${i} alpha beta`), msg(`ack ${i}`, "Edit")],
59
+ messages: [
60
+ msg(`${topics[i % 2]} checkpoint ${i} alpha beta`),
61
+ msg(`ack ${i}`, "Edit"),
62
+ ],
57
63
  keepFrom: 2,
58
64
  timestamp: i,
59
65
  }, store);
@@ -80,7 +86,8 @@ test("S25-P2: RAPTOR_INJECT_SUMMARIES=true prepends a hierarchical overview head
80
86
  assert.ok(r.block.length > 0, "recall block is non-empty");
81
87
  assert.ok(r.block.includes("hierarchical overview"), "overview header present when flag ON");
82
88
  assert.ok(r.block.includes("Recalled context"), "detailed recall block still present after overview");
83
- assert.ok(r.block.indexOf("hierarchical overview") < r.block.indexOf("Recalled context"), "overview precedes detail");
89
+ assert.ok(r.block.indexOf("hierarchical overview") <
90
+ r.block.indexOf("Recalled context"), "overview precedes detail");
84
91
  });
85
92
  // ─── 2. Flag OFF → no overview (detail-only, unchanged behavior) ────────────
86
93
  test("S25-P2: RAPTOR_INJECT_SUMMARIES=false → no overview (detail-only)", () => {
@@ -14,7 +14,7 @@
14
14
  * extension decides where it lands.
15
15
  */
16
16
  import { recall as searchRecall } from "./engine.js";
17
- import { vectorWasInjected, vectorMarkInjected, vectorSearchAsync } from "./vectorStore.js";
17
+ import { vectorWasInjected, vectorMarkInjected, vectorSearchAsync, } from "./vectorStore.js";
18
18
  import { estimateBlockTokens } from "./tokens.js";
19
19
  import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
20
20
  import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
@@ -29,7 +29,9 @@ export function formatRecallBlock(hits) {
29
29
  // S17: label a cross-repo hit with its source repo (the repoId doubles as
30
30
  // that repo's stateDir, so the last path segment is the repo's display
31
31
  // name). Same-repo hits (no repoId) stay unlabeled.
32
- const repoName = h.repoId ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})` : "";
32
+ const repoName = h.repoId
33
+ ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})`
34
+ : "";
33
35
  // S42B: a RAPTOR cluster node hit (not a stored checkpoint) is labeled as a
34
36
  // hierarchical summary and uses raptorSummary as its body. No Key files line
35
37
  // (cluster nodes carry no file list).
@@ -58,7 +60,9 @@ export function formatRaptorBlock(nodes) {
58
60
  if (nodes.length === 0)
59
61
  return "";
60
62
  const parts = nodes.map((n, i) => {
61
- const score = n.score !== undefined ? ` (relevance ${(n.score * 100).toFixed(0)}%)` : "";
63
+ const score = n.score !== undefined
64
+ ? ` (relevance ${(n.score * 100).toFixed(0)}%)`
65
+ : "";
62
66
  const label = n.level === 0
63
67
  ? `Session overview [${i + 1}]${score}`
64
68
  : `Cluster summary [${i + 1}] (level ${n.level})${score}`;
@@ -203,11 +207,19 @@ function raptorOverviewBlock(store, sessionId, query) {
203
207
  const qv = store.embedder.embed(query);
204
208
  // Root (level 0) first, then the top level-1 clusters by cosine to the query.
205
209
  const nodes = [
206
- { summary: root.summary, level: root.level, score: cosineSimilarity(qv, root.embedding) },
210
+ {
211
+ summary: root.summary,
212
+ level: root.level,
213
+ score: cosineSimilarity(qv, root.embedding),
214
+ },
207
215
  ];
208
216
  const level1 = [...tree.nodes.values()]
209
217
  .filter((n) => n.level === 1 && n.summary)
210
- .map((n) => ({ summary: n.summary, level: n.level, score: cosineSimilarity(qv, n.embedding) }))
218
+ .map((n) => ({
219
+ summary: n.summary,
220
+ level: n.level,
221
+ score: cosineSimilarity(qv, n.embedding),
222
+ }))
211
223
  .sort((a, b) => b.score - a.score)
212
224
  .slice(0, 3);
213
225
  nodes.push(...level1);
@@ -224,7 +236,8 @@ export function formatMemoryRecallBlock(hits) {
224
236
  const parts = hits.map((h, i) => {
225
237
  const pct = (h.score * 100).toFixed(0);
226
238
  const cat = h.category ? `[${h.category}] ` : "";
227
- return `### Recalled memory [${i + 1}] (relevance ${pct}%)\n${cat}${h.content.trim()}`;
239
+ const src = h.label ? ` ${h.label}` : "";
240
+ return `### Recalled memory [${i + 1}] (relevance ${pct}%${src})\n${cat}${h.content.trim()}`;
228
241
  });
229
242
  return ("The following facts about this project were saved from earlier turns " +
230
243
  "and are relevant to the current request. Treat them as established:\n\n" +
@@ -263,8 +276,8 @@ export async function recallMemoriesAndInline(opts) {
263
276
  const parts = [];
264
277
  const report = [];
265
278
  let blockTokens = 0;
266
- const pushHit = (content, category, score, label) => {
267
- const part = formatMemoryRecallBlock([{ content, category, score }]);
279
+ const pushHit = (content, category, score, label, blockSuffix) => {
280
+ const part = formatMemoryRecallBlock([{ content, category, score, label: blockSuffix }]);
268
281
  const partTokens = estimateBlockTokens(part);
269
282
  if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
270
283
  return false;
@@ -279,7 +292,7 @@ export async function recallMemoriesAndInline(opts) {
279
292
  }
280
293
  for (const h of crossHits) {
281
294
  const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
282
- if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`))
295
+ if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`, `from ${repoLabel}`))
283
296
  break;
284
297
  }
285
298
  return { empty: parts.length === 0, block: parts.join("\n"), report };
@@ -329,16 +342,19 @@ export async function recallAndInlineAsync(opts, store) {
329
342
  const skipCrossRepoHits = !!opts.crossRepo && !opts.globalIndexDir;
330
343
  if (skipCrossRepoHits) {
331
344
  try {
332
- console.warn("[mega-compact:recall] cross-repo recall enabled but globalIndexDir is unset — "
333
- + "skipping cross-repo injection to avoid re-injecting undeduped foreign checkpoints");
345
+ console.warn("[mega-compact:recall] cross-repo recall enabled but globalIndexDir is unset — " +
346
+ "skipping cross-repo injection to avoid re-injecting undeduped foreign checkpoints");
347
+ }
348
+ catch {
349
+ /* ignore */
334
350
  }
335
- catch { /* ignore */ }
336
351
  }
337
352
  for (const h of hits) {
338
353
  // F2: skip foreign hits when we can't dedup them machine-wide.
339
354
  if (skipCrossRepoHits && h.repoId)
340
355
  continue;
341
- if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId))
356
+ if (skip &&
357
+ vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId))
342
358
  continue;
343
359
  // S18: machine-wide injected-set — a foreign checkpoint already injected
344
360
  // (in any session) is never re-injected. Only applies to cross-repo hits
@@ -0,0 +1,93 @@
1
+ /**
2
+ * sprint4x-rag-verification.test.ts — run the S40–S47 RAG suite once against
3
+ * the current implementation and assert the documented "default enable" claims.
4
+ *
5
+ * This is the verification pass demanded by the roadmap/backlog: each spec
6
+ * claims its feature flags DEFAULT ON. The honest check (not a re-statement of
7
+ * the spec) is: which flags actually exist in code, what do they default to,
8
+ * and is the consuming path wired?
9
+ *
10
+ * Findings recorded here, per spec:
11
+ * S40 importance-scoring — module src/importance.ts exists (S40A) but is
12
+ * NOT consumed by compactSession/vector-paths;
13
+ * no shipped flag, no adapter wiring.
14
+ * S41 self-rag-quality-gate — spec-only; NO flag, NO consumer.
15
+ * S42 raptor-multilevel — PARTIALLY SHIPPED: RAPTOR_MULTILEVEL_ENABLED
16
+ * and RAPTOR_LEAF_EXPANSION both default true
17
+ * with real consumers; the spec's claim holds.
18
+ * S43 hyde-vague-queries — spec-only (QUERY_REFORMULATION_ENABLED absent).
19
+ * S44 three-tier-latency-routing — spec-only (TIERED_ROUTING_ENABLED absent).
20
+ * S45 crag-quality-metrics — spec-only (CRAG_ENABLED absent).
21
+ * S46 visual-memory-map — spec-only (MEMORY_MAP_ENABLED absent;
22
+ * memory-graph endpoint not in dashboard-server).
23
+ * S47 auto-categorizing-wiki — spec-only (AUTO_WIKI_ENABLED absent).
24
+ *
25
+ * Policy: the suite runs against the flags that EXIST today. Unimplemented spec
26
+ * claims are pinned by the S4X_SPEC_ONLY tests so regressions can't silently
27
+ * add them in the wrong state, and this file records exactly which
28
+ * spec-vs-implementation gaps remain.
29
+ */
30
+ import { test } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { loadDedupConfig } from "./config/dedup.js";
33
+ /**
34
+ * Clear any env pollution so the default is measured, not the override.
35
+ */
36
+ function fresh(envKey, get) {
37
+ delete process.env[envKey];
38
+ try {
39
+ return get();
40
+ }
41
+ finally {
42
+ delete process.env[envKey];
43
+ }
44
+ }
45
+ // ---- S42: shipped flags default ON (claim holds) ----------------------------
46
+ test("S42 RAPTOR_MULTILEVEL_ENABLED defaults ON and honors env off", () => {
47
+ const d = fresh("MEGACOMPACT_RAPTOR_MULTILEVEL", () => loadDedupConfig());
48
+ assert.ok(d.RAPTOR_MULTILEVEL_ENABLED, "ship claim: multi-level on by default");
49
+ process.env.MEGACOMPACT_RAPTOR_MULTILEVEL = "false";
50
+ const off = loadDedupConfig();
51
+ assert.ok(!off.RAPTOR_MULTILEVEL_ENABLED, "env override: MEGACOMPACT_RAPTOR_MULTILEVEL=false turns it off");
52
+ });
53
+ test("S42 RAPTOR_LEAF_EXPANSION defaults ON and honors env off", () => {
54
+ const d = fresh("MEGACOMPACT_RAPTOR_LEAF_EXPANSION", () => loadDedupConfig());
55
+ assert.ok(d.RAPTOR_LEAF_EXPANSION, "ship claim: leaf-expansion on by default");
56
+ process.env.MEGACOMPACT_RAPTOR_LEAF_EXPANSION = "false";
57
+ const off = loadDedupConfig();
58
+ assert.ok(!off.RAPTOR_LEAF_EXPANSION, "env override off");
59
+ });
60
+ // ---- S40: module exists, claims do NOT yet hold at the flag/consumer level --
61
+ test("S40 importance module exists in-tree (S40A artifact)", async () => {
62
+ // Load the module and confirm it exports the scoring surface the spec
63
+ // describes. This proves the S40A implementation exists even though nothing
64
+ // wires it into compaction/vector paths yet (documented gap).
65
+ const mod = await import("./importance.js");
66
+ assert.ok(mod && typeof mod === "object", "importance.ts loads");
67
+ // Exports per spec: score() plus item-type enum.
68
+ assert.ok(typeof mod.score === "function", "importance.ts exports score() function");
69
+ });
70
+ test("S40 has no shipped consumer flag in the current codebase (gap pinned)", async () => {
71
+ // The S40 spec claims an IMPORTANCE_SCORING flag defaults ON. In the
72
+ // current code no such flag exists, and no vector/compact path reads
73
+ // importance scores. This test documents the gap so a future wiring does
74
+ // not silently flip it on in the wrong shape.
75
+ const cfg = loadDedupConfig();
76
+ assert.ok(!("IMPORTANCE_SCORING" in cfg), "no IMPORTANCE_SCORING flag yet (S40 consumer wiring missing)");
77
+ });
78
+ // ---- S41/S43–S47: spec-only modules — pin the absence ------------------------
79
+ test("S4X spec-only flags absent from DedupConfig", () => {
80
+ const cfg = loadDedupConfig();
81
+ const specFlags = [
82
+ "CRITIQUE_ENABLED", // S41
83
+ "QUERY_REFORMULATION_ENABLED", // S43
84
+ "TIERED_ROUTING_ENABLED", // S44
85
+ "CRAG_ENABLED", // S45
86
+ "CRAG_EXPANSION_ENABLED", // S45
87
+ "MEMORY_MAP_ENABLED", // S46
88
+ "AUTO_WIKI_ENABLED", // S47
89
+ ];
90
+ for (const f of specFlags) {
91
+ assert.ok(!(f in cfg), `${f} is spec-only — should not be present in DedupConfig`);
92
+ }
93
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * repoKey.ts — S25-B: the single repo-scope key for BOTH global PGlite indexes.
3
+ *
4
+ * Before S25 the checkpoint index (vector_index) keyed on stateDir while the
5
+ * memory index (memory_index) keyed on the git root — two scopes that meant
6
+ * cross-repo checkpoint hydration had no way back from repo_id → the repo's
7
+ * store. This helper unifies both indexes on ONE key: the resolved git root,
8
+ * falling back to stateDir outside git worktrees.
9
+ *
10
+ * stateDirForRepo() reverses the mapping via the machine-wide
11
+ * repo_registry (src/store/sqlite/global-index.ts): a repo_id hit from the
12
+ * index resolves to that repo's stateDir so getCheckpoint() can hydrate from
13
+ * the authoritative node:sqlite store. Returns undefined when unresolvable —
14
+ * the caller skips the hit (degrade, never crash).
15
+ *
16
+ * PREVENT-PI-004: `git rev-parse` is local + read-only (annotated below).
17
+ */
18
+ import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the vector index per-repo
19
+ import { getRepoRegistry } from "./sqlite/global-index.js";
20
+ /**
21
+ * Resolve the canonical repo key for a state dir. Git root when inside a
22
+ * worktree, stateDir otherwise. Two repos sharing a git root (e.g. nested
23
+ * checkouts pointing at the same repo) collapse to one scope — intended.
24
+ */
25
+ export function repoKey(stateDir) {
26
+ try {
27
+ const out = execSync("git rev-parse --show-toplevel", {
28
+ cwd: stateDir,
29
+ encoding: "utf-8",
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ }).trim();
32
+ return out || stateDir;
33
+ }
34
+ catch {
35
+ return stateDir;
36
+ }
37
+ }
38
+ /**
39
+ * Reverse map: repo_id → stateDir. Registry hit wins (git-root scope, S25);
40
+ * otherwise the key is assumed to be a legacy/ungit-scoped stateDir and
41
+ * returned verbatim (callers treat undefined-unopenable dirs as skip/degrade).
42
+ */
43
+ export function stateDirForRepo(repoId, indexDir) {
44
+ return getRepoRegistry(repoId, indexDir)?.stateDir ?? repoId;
45
+ }
@@ -98,9 +98,7 @@ export function getTurn(conversationId, turnIndex, stateDir = getStateDir()) {
98
98
  /** Get a turn by its global id. */
99
99
  export function getTurnById(turnId, stateDir = getStateDir()) {
100
100
  const db = openStore(stateDir);
101
- const row = db
102
- .prepare("SELECT * FROM turns WHERE id = ?")
103
- .get(turnId);
101
+ const row = db.prepare("SELECT * FROM turns WHERE id = ?").get(turnId);
104
102
  return row ? rowToTurn(row) : null;
105
103
  }
106
104
  /** All turn_recall rows for a turn (what was injected at that turn). */