pi-mega-compact 0.8.26 → 0.9.1

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 (46) hide show
  1. package/README.md +12 -9
  2. package/dist/extensions/mega-compact.js +16 -1
  3. package/dist/extensions/mega-config.js +1 -0
  4. package/dist/extensions/mega-pipeline/compact.js +2 -1
  5. package/dist/extensions/mega-runtime/reset-runtime.js +8 -0
  6. package/dist/extensions/mega-runtime/runtime.js +12 -50
  7. package/dist/extensions/mega-shutdown-widget.test.js +121 -0
  8. package/dist/src/compact.js +4 -2
  9. package/dist/src/dedup/raptor/tree.js +11 -0
  10. package/dist/src/memory.test.js +29 -0
  11. package/dist/src/memoryOps.js +4 -19
  12. package/dist/src/memoryRecall.test.js +27 -0
  13. package/dist/src/memoryRoundtrip.test.js +137 -0
  14. package/dist/src/recall.js +5 -4
  15. package/dist/src/sprint4x-rag-verification.test.js +93 -0
  16. package/dist/src/store/memoryIndex.js +29 -7
  17. package/dist/src/store/pgOpenGuard.js +83 -0
  18. package/dist/src/store/pgOpenGuard.test.js +74 -0
  19. package/dist/src/store/repoKey.js +45 -0
  20. package/dist/src/store/vectorIndex.js +30 -8
  21. package/dist/src/store/vectorIndex.test.js +25 -1
  22. package/dist/src/vector-search.js +11 -5
  23. package/dist/src/vectorStore.js +4 -1
  24. package/extensions/mega-compact.ts +16 -1
  25. package/extensions/mega-config.ts +8 -0
  26. package/extensions/mega-pipeline/compact.ts +2 -1
  27. package/extensions/mega-runtime/reset-runtime.ts +88 -0
  28. package/extensions/mega-runtime/runtime.ts +27 -59
  29. package/extensions/mega-shutdown-widget.test.ts +141 -0
  30. package/package.json +1 -1
  31. package/src/compact.ts +209 -174
  32. package/src/dedup/raptor/tree.ts +11 -0
  33. package/src/memory.test.ts +47 -1
  34. package/src/memoryOps.ts +4 -19
  35. package/src/memoryRecall.test.ts +36 -0
  36. package/src/memoryRoundtrip.test.ts +155 -0
  37. package/src/recall.ts +6 -3
  38. package/src/sprint4x-rag-verification.test.ts +119 -0
  39. package/src/store/memoryIndex.ts +34 -8
  40. package/src/store/pgOpenGuard.test.ts +89 -0
  41. package/src/store/pgOpenGuard.ts +93 -0
  42. package/src/store/repoKey.ts +50 -0
  43. package/src/store/vectorIndex.test.ts +25 -1
  44. package/src/store/vectorIndex.ts +35 -9
  45. package/src/vector-search.ts +10 -5
  46. package/src/vectorStore.ts +4 -1
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
 
@@ -59,6 +61,7 @@ Set env vars before starting pi. Defaults are in `src/config/dedup.ts`.
59
61
  | `MEGACOMPACT_DEDUP_SIM` | `0.90` | Cosine threshold for near-dup collapse |
60
62
  | `MEGACOMPACT_CROSSREPO_ENABLED` | `true` | Cross-repo recall on resume |
61
63
  | `MEGACOMPACT_EMBEDDING_URL` | _(unset)_ | BYO localhost embedder endpoint |
64
+ | `MEGACOMPACT_TUI_WIDGET` | `true` | Render the above-editor panel. Set to `0` to suppress it — useful when pi runs inside an editor terminal (e.g. Neovim `:terminal`) where you drive scrollback yourself and the panel's repaints fight it. Compaction is unaffected. |
62
65
 
63
66
  Full config reference: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md)
64
67
 
@@ -80,7 +83,7 @@ Detailed architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
80
83
 
81
84
  ```bash
82
85
  npm run build # TypeScript compile
83
- npm test # Build + 608 tests
86
+ npm test # Build + 769 tests
84
87
  npm run lint # Type check + guardrails scan
85
88
  ```
86
89
 
@@ -25,6 +25,8 @@
25
25
  * This file is the thin wiring layer: it owns the default export, constructs
26
26
  * the runtime, and registers handlers/commands. Behavior is unchanged.
27
27
  */
28
+ import { closeVectorIndex } from "../src/store/vectorIndex.js";
29
+ import { closeMemoryIndex } from "../src/store/memoryIndex.js";
28
30
  import { loadConfig } from "./mega-config.js";
29
31
  import { MegaRuntime } from "./mega-runtime.js";
30
32
  import { registerEventHandlers } from "./mega-events.js";
@@ -79,5 +81,18 @@ export default function (pi) {
79
81
  // dispose() is idempotent, and the next snapshot() re-opens the watcher
80
82
  // lazily via bindRepo() → ensureGameStateWatcher(), so there is no permanent
81
83
  // leak and no per-session fd accumulation.
82
- pi.on("session_shutdown", () => runtime.dispose());
84
+ // The PGlite indexes (vectorIndex / memoryIndex) are lazily opened module
85
+ // singletons. closeVectorIndex()/closeMemoryIndex() existed but had no
86
+ // non-test callers, so a session left both open: PGlite is WASM Postgres and
87
+ // its handles keep node's event loop alive, so `pi -p` produced its answer
88
+ // and then hung until killed rather than exiting. dispose() only released the
89
+ // fs.watch handle and the perf interval, neither of which was the culprit
90
+ // (the interval is unref'd).
91
+ //
92
+ // Both closes are idempotent and safe when the index was never opened, and
93
+ // the next initVectorIndex()/initMemoryIndex() re-opens lazily.
94
+ pi.on("session_shutdown", async () => {
95
+ runtime.dispose();
96
+ await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
97
+ });
83
98
  }
@@ -160,6 +160,7 @@ export function loadConfig() {
160
160
  memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
161
161
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
162
162
  windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
163
+ tuiWidget: envBool("MEGACOMPACT_TUI_WIDGET", true),
163
164
  debug: envBool("MEGACOMPACT_DEBUG", false),
164
165
  };
165
166
  }
@@ -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
  }
@@ -32,6 +32,14 @@ export function resetRuntimeImpl(self, sessionId) {
32
32
  errorRetryCount: 0,
33
33
  errorRetryUntil: 0,
34
34
  consecutiveErrors: 0,
35
+ lastErrorRetryAt: 0,
36
+ retryNudgePending: false,
37
+ errorRetrySessionCount: 0,
38
+ lastErrorText: undefined,
39
+ errorTextRepeatCount: 0,
40
+ poisonedAdviseSent: false,
41
+ poisonedCompactSignatures: new Set(),
42
+ poisonedCount: 0,
35
43
  };
36
44
  self.trimCache = null; // v0.8.6: never replay a stale trim into a new session
37
45
  self.statusKey = undefined;
@@ -23,6 +23,7 @@ import { captureModelImpl } from "./capture-model.js";
23
23
  import { bindRepoImpl } from "./bind-repo.js";
24
24
  import { snapshotImpl } from "./runtime-snapshot.js";
25
25
  import { pressureImpl, effectiveThresholdImpl, pressureBandImpl, } from "./pressure-getters.js";
26
+ import { resetRuntimeImpl } from "./reset-runtime.js";
26
27
  import { appendEventImpl } from "./append-event.js";
27
28
  import { getStateDirImpl } from "./get-state-dir.js";
28
29
  import { renderWidgetImpl } from "./render-widget.js";
@@ -56,7 +57,6 @@ export class MegaRuntime {
56
57
  errorRetryCount: 0,
57
58
  errorRetryUntil: 0,
58
59
  consecutiveErrors: 0,
59
- // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
60
60
  lastErrorRetryAt: 0,
61
61
  retryNudgePending: false,
62
62
  errorRetrySessionCount: 0,
@@ -255,8 +255,15 @@ export class MegaRuntime {
255
255
  snapshotImpl(this, ctx);
256
256
  }
257
257
  /** Width-aware above-editor widget factory registration — thin delegate to
258
- * `renderWidgetImpl` (render-widget.ts). */
258
+ * `renderWidgetImpl` (render-widget.ts).
259
+ *
260
+ * Gated on `config.tuiWidget` (MEGACOMPACT_TUI_WIDGET=0 to disable). This
261
+ * is the single chokepoint every caller funnels through, so returning here
262
+ * means setWidget is never called and the panel is never registered — as
263
+ * opposed to registering an empty one, which would still occupy a row. */
259
264
  renderWidget(ctx) {
265
+ if (!this.config.tuiWidget)
266
+ return;
260
267
  renderWidgetImpl(this, ctx);
261
268
  }
262
269
  /** Mirror the dashboard status text onto pi's status line — thin delegate to
@@ -264,55 +271,10 @@ export class MegaRuntime {
264
271
  setStatus(ctx, text) {
265
272
  setStatusImpl(this, ctx, text);
266
273
  }
267
- /** Per-session state reset (session_start / session_tree) — inlined by the
268
- * raptor-promotion merge (R1–R3 retry-redesign fields); reset-runtime.ts was
269
- * retired by that branch. */
274
+ /** Per-session state reset (session_start / session_tree) — thin delegate to
275
+ * `resetRuntimeImpl` (reset-runtime.ts). */
270
276
  resetRuntime(sessionId) {
271
- const sid = normalizeSessionId(sessionId);
272
- if (this.rt.sessionId === sid && this.rt.persistedThisSession)
273
- return; // same session, keep checkpoint memory
274
- this.rt = {
275
- sessionId: sid,
276
- persistedThisSession: false,
277
- lastCheckpointId: undefined,
278
- lastCompactedFrom: 0,
279
- lastCompactedTokens: 0,
280
- dedupSkips: 0,
281
- dedupAttempts: 0,
282
- tokensSaved: 0,
283
- lastCompactAt: null,
284
- lastNativeCompactAt: null,
285
- compactCount: 0,
286
- recallInjections: 0,
287
- cacheHitTokens: 0,
288
- lengthStopPending: false,
289
- errorRetryCount: 0,
290
- errorRetryUntil: 0,
291
- consecutiveErrors: 0,
292
- // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
293
- lastErrorRetryAt: 0,
294
- retryNudgePending: false,
295
- errorRetrySessionCount: 0,
296
- lastErrorText: undefined,
297
- errorTextRepeatCount: 0,
298
- poisonedAdviseSent: false,
299
- poisonedCompactSignatures: new Set(),
300
- poisonedCount: 0,
301
- };
302
- this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
303
- this.statusKey = undefined;
304
- this.activeAgents = 0;
305
- this.currentTurn = 0;
306
- this.lastActivityAt = 0;
307
- this.tierTrace = undefined;
308
- this.ticker.length = 0;
309
- this.pulsing = false;
310
- this.savedGoal = 50_000;
311
- this.lastWhy = undefined;
312
- // S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
313
- // that re-binds the repo, so drop the memo too. Cheap; the next
314
- // getCachedGameState() re-queries lazily.
315
- this.cachedGameState = undefined;
277
+ resetRuntimeImpl(this, sessionId);
316
278
  }
317
279
  captureModel(ctx) {
318
280
  captureModelImpl(this, ctx);
@@ -0,0 +1,121 @@
1
+ /**
2
+ * mega-shutdown-widget.test.ts — regression cover for two host-integration
3
+ * fixes that the rest of the suite cannot see, because both are about what
4
+ * happens at the boundary with pi rather than inside the compaction engine:
5
+ *
6
+ * 1. session_shutdown must close the PGlite indexes. They are lazily-opened
7
+ * module singletons whose handles keep node's event loop alive, so leaving
8
+ * them open made `pi -p` produce its answer and then hang instead of
9
+ * exiting. closeVectorIndex()/closeMemoryIndex() existed but had no
10
+ * non-test callers.
11
+ *
12
+ * 2. The above-editor widget must be suppressible. It is a persistent,
13
+ * animated, full-width panel that repaints on its own cadence, which fights
14
+ * terminals where the user drives scrollback (pi inside a Neovim
15
+ * `:terminal`). MEGACOMPACT_TUI_WIDGET=0 turns it off without touching
16
+ * compaction.
17
+ */
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { closeVectorIndex, initVectorIndex, isVectorIndexDisabled, } from "../src/store/vectorIndex.js";
24
+ import { closeMemoryIndex } from "../src/store/memoryIndex.js";
25
+ import { loadConfig } from "./mega-config.js";
26
+ import { MegaRuntime } from "./mega-runtime.js";
27
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-shutdown-"));
28
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
29
+ let counter = 0;
30
+ /** Fresh per-test state dir so concurrent runs never collide on disk. */
31
+ function isolate() {
32
+ process.env.MEGACOMPACT_STATE_DIR = join(baseTmp, `run-${counter++}`);
33
+ }
34
+ /** Minimal ExtensionContext slice: renderWidget only reaches ctx.ui.setWidget. */
35
+ function widgetCtx(calls) {
36
+ return {
37
+ ui: {
38
+ setWidget: (key) => calls.push(key),
39
+ },
40
+ };
41
+ }
42
+ test.after(async () => {
43
+ // This file is itself a demonstration of the bug under test: without these
44
+ // closes the PGlite handles opened above keep the event loop alive and the
45
+ // test process never exits.
46
+ await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
47
+ rmSync(baseTmp, { recursive: true, force: true });
48
+ });
49
+ test("MEGACOMPACT_TUI_WIDGET defaults on and is disabled by 0", () => {
50
+ isolate();
51
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
52
+ assert.equal(loadConfig().tuiWidget, true, "widget should default to on");
53
+ process.env.MEGACOMPACT_TUI_WIDGET = "0";
54
+ assert.equal(loadConfig().tuiWidget, false);
55
+ process.env.MEGACOMPACT_TUI_WIDGET = "1";
56
+ assert.equal(loadConfig().tuiWidget, true);
57
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
58
+ });
59
+ test("renderWidget registers the panel when tuiWidget is on", () => {
60
+ isolate();
61
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
62
+ const runtime = new MegaRuntime(loadConfig());
63
+ try {
64
+ const calls = [];
65
+ runtime.renderWidget(widgetCtx(calls));
66
+ assert.equal(calls.length, 1, "expected one setWidget registration");
67
+ }
68
+ finally {
69
+ runtime.dispose();
70
+ }
71
+ });
72
+ test("renderWidget registers nothing when tuiWidget is off", () => {
73
+ isolate();
74
+ process.env.MEGACOMPACT_TUI_WIDGET = "0";
75
+ const runtime = new MegaRuntime(loadConfig());
76
+ try {
77
+ const calls = [];
78
+ // Repeated calls, because the panel is re-registered on every snapshot
79
+ // and every game-state change — one guarded path is not enough.
80
+ runtime.renderWidget(widgetCtx(calls));
81
+ runtime.renderWidget(widgetCtx(calls));
82
+ assert.deepEqual(calls, [], "widget must never be registered when disabled");
83
+ }
84
+ finally {
85
+ runtime.dispose();
86
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
87
+ }
88
+ });
89
+ test("the extension's session_shutdown handler awaits index teardown", async () => {
90
+ isolate();
91
+ const handlers = {};
92
+ const pi = {
93
+ on(event, handler) {
94
+ (handlers[event] ??= []).push(handler);
95
+ },
96
+ registerCommand() { },
97
+ registerProvider() { },
98
+ };
99
+ const { default: extension } = await import("./mega-compact.js");
100
+ extension(pi);
101
+ const shutdown = handlers["session_shutdown"] ?? [];
102
+ assert.ok(shutdown.length, "extension must register a session_shutdown handler");
103
+ const event = { type: "session_shutdown" };
104
+ const ctx = {
105
+ ui: { setStatus: () => { }, notify: () => { }, setWidget: () => { } },
106
+ cwd: process.env.MEGACOMPACT_STATE_DIR,
107
+ };
108
+ // Assert the close by identity rather than by "some handler returned a
109
+ // promise": several modules register on session_shutdown and at least one
110
+ // other is already async, so a promise proves nothing about teardown. The
111
+ // index is a module singleton, so if shutdown really closed it, re-init
112
+ // hands back a *different* instance.
113
+ const before = await initVectorIndex();
114
+ if (isVectorIndexDisabled() || !before)
115
+ return; // PGlite unavailable — nothing to close
116
+ await Promise.all(shutdown.map((handler) => handler(event, ctx)));
117
+ const after = await initVectorIndex();
118
+ assert.notEqual(after, before, "session_shutdown must close the PGlite vector index");
119
+ // Idempotent: a second shutdown (reload, double-fire) must not throw.
120
+ await Promise.all(shutdown.map((handler) => handler(event, ctx)));
121
+ });
@@ -26,7 +26,7 @@ function firstText(m) {
26
26
  /** Heuristic: does this text look like chatty filler we can collapse? */
27
27
  export function isChatty(text) {
28
28
  const low = text.toLowerCase();
29
- if (low.includes("hello") || low.includes("thanks") || low.includes("great") || low.includes("ok")) {
29
+ if (/\b(hello|thanks|great|ok)\b/i.test(low)) {
30
30
  return true;
31
31
  }
32
32
  return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
@@ -141,7 +141,9 @@ export function summarizeMessages(messages) {
141
141
  const users = messages.filter((m) => m.role === "user").length;
142
142
  const assistants = messages.filter((m) => m.role === "assistant").length;
143
143
  const tools = messages.filter((m) => m.role === "tool").length;
144
- const toolNames = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
144
+ const toolNames = [
145
+ ...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : []))),
146
+ ].sort();
145
147
  const lines = [
146
148
  "<summary>",
147
149
  "Conversation summary:",
@@ -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
+ });
@@ -236,7 +236,8 @@ export function formatMemoryRecallBlock(hits) {
236
236
  const parts = hits.map((h, i) => {
237
237
  const pct = (h.score * 100).toFixed(0);
238
238
  const cat = h.category ? `[${h.category}] ` : "";
239
- 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()}`;
240
241
  });
241
242
  return ("The following facts about this project were saved from earlier turns " +
242
243
  "and are relevant to the current request. Treat them as established:\n\n" +
@@ -275,8 +276,8 @@ export async function recallMemoriesAndInline(opts) {
275
276
  const parts = [];
276
277
  const report = [];
277
278
  let blockTokens = 0;
278
- const pushHit = (content, category, score, label) => {
279
- const part = formatMemoryRecallBlock([{ content, category, score }]);
279
+ const pushHit = (content, category, score, label, blockSuffix) => {
280
+ const part = formatMemoryRecallBlock([{ content, category, score, label: blockSuffix }]);
280
281
  const partTokens = estimateBlockTokens(part);
281
282
  if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
282
283
  return false;
@@ -291,7 +292,7 @@ export async function recallMemoriesAndInline(opts) {
291
292
  }
292
293
  for (const h of crossHits) {
293
294
  const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
294
- 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}`))
295
296
  break;
296
297
  }
297
298
  return { empty: parts.length === 0, block: parts.join("\n"), report };