pi-mega-compact 0.8.26 → 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 +11 -9
- package/dist/extensions/mega-pipeline/compact.js +2 -1
- package/dist/src/dedup/raptor/tree.js +11 -0
- package/dist/src/memory.test.js +29 -0
- package/dist/src/memoryOps.js +4 -19
- package/dist/src/memoryRecall.test.js +27 -0
- package/dist/src/memoryRoundtrip.test.js +137 -0
- package/dist/src/recall.js +5 -4
- package/dist/src/sprint4x-rag-verification.test.js +93 -0
- package/dist/src/store/repoKey.js +45 -0
- package/dist/src/store/vectorIndex.test.js +25 -1
- package/dist/src/vector-search.js +11 -5
- package/dist/src/vectorStore.js +4 -1
- package/extensions/mega-pipeline/compact.ts +2 -1
- package/package.json +1 -1
- package/src/dedup/raptor/tree.ts +11 -0
- package/src/memory.test.ts +47 -1
- package/src/memoryOps.ts +4 -19
- package/src/memoryRecall.test.ts +36 -0
- package/src/memoryRoundtrip.test.ts +155 -0
- package/src/recall.ts +6 -3
- package/src/sprint4x-rag-verification.test.ts +119 -0
- package/src/store/repoKey.ts +50 -0
- package/src/store/vectorIndex.test.ts +25 -1
- package/src/vector-search.ts +10 -5
- 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
|
|
8
|
-
- **Two-layer compaction** — live trim
|
|
9
|
-
- **Semantic dedup** —
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
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 +
|
|
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;
|
package/dist/src/memory.test.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/src/memoryOps.js
CHANGED
|
@@ -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
|
|
30
|
-
* repos; falls back to the state dir
|
|
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 =
|
|
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
|
+
});
|
package/dist/src/recall.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
|
@@ -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
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { EMBEDDING_DIM, initVectorIndex, upsertEmbedding, searchAsync, closeVectorIndex, isVectorIndexDisabled, } from "./vectorIndex.js";
|
|
@@ -77,6 +77,30 @@ test("dimension guard: non-512 vectors are skipped, never corrupt the index", as
|
|
|
77
77
|
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
78
78
|
}
|
|
79
79
|
});
|
|
80
|
+
test("corrupt-dir self-heal: torn PGlite dir is deleted + retried, never crashes", async () => {
|
|
81
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
82
|
+
const dir = isolateIndexDir();
|
|
83
|
+
try {
|
|
84
|
+
await closeVectorIndex();
|
|
85
|
+
// Tear the dir: garbage bytes where PGlite expects its data/ files.
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
writeFileSync(join(dir, "data"), Buffer.from([0xde, 0xad, 0xbe, 0xef, 0xde, 0xad]));
|
|
88
|
+
// openPgLite(retryOnCorrupt=true) deletes + retries; if that still fails it
|
|
89
|
+
// disables gracefully. Either path must NOT throw.
|
|
90
|
+
const pg = await initVectorIndex();
|
|
91
|
+
assert.ok(pg, "index self-healed after corruption ");
|
|
92
|
+
// And the index works after heal: upsert + search round-trip.
|
|
93
|
+
await upsertEmbedding("/repoE/.pi/mega-compact", "sessE", "chkpt_001", spikeVec(7));
|
|
94
|
+
const hits = await searchAsync(spikeVec(7), { k: 1 });
|
|
95
|
+
assert.equal(hits.length, 1, "search works on the healed index");
|
|
96
|
+
assert.equal(hits[0].checkpointId, "chkpt_001");
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await closeVectorIndex();
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
80
104
|
test("kill-switch: MEGACOMPACT_PGLITE_DISABLED disables the index gracefully", async () => {
|
|
81
105
|
const dir = isolateIndexDir();
|
|
82
106
|
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { cosineSimilarity } from "./embedder.js";
|
|
14
14
|
import { normalizeSessionId } from "./store.js";
|
|
15
15
|
import { mmrRerank } from "./dedup/mmr.js";
|
|
16
|
+
import { stateDirForRepo } from "./store/repoKey.js";
|
|
16
17
|
import { topK } from "./dedup/topk.js";
|
|
17
18
|
import { listCheckpoints, getCheckpoint, maxCheckpointTimestamp, maxRaptorNodeBuiltAt, } from "./store/sqlite.js";
|
|
18
19
|
import { initVectorIndex, searchAsync as vectorIndexSearch, } from "./store/vectorIndex.js";
|
|
@@ -255,13 +256,18 @@ export async function vectorSearchAsync(store, sessionId, query, k = 3, opts = {
|
|
|
255
256
|
// Index empty/unavailable → synchronous per-session fallback (this repo).
|
|
256
257
|
return vectorSearch(store, sid, query, k);
|
|
257
258
|
}
|
|
258
|
-
// Hydrate each index hit from the authoritative node:sqlite store.
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
259
|
+
// Hydrate each index hit from the authoritative node:sqlite store. The
|
|
260
|
+
// index keys on repo_id (S25: git root via repoKey; legacy rows keyed by
|
|
261
|
+
// stateDir) — resolve repo_id → stateDir via stateDirForRepo, and skip
|
|
262
|
+
// unresolvable/foreign hits (degrade, never crash). Cross-repo hits carry
|
|
263
|
+
// their source repoId so the recall block can label them; same-repo stays
|
|
264
|
+
// unlabeled.
|
|
262
265
|
const hydrated = [];
|
|
263
266
|
for (const h of indexHits) {
|
|
264
|
-
const
|
|
267
|
+
const hitStateDir = stateDirForRepo(h.repoId);
|
|
268
|
+
if (!hitStateDir)
|
|
269
|
+
continue;
|
|
270
|
+
const cp = getCheckpoint(h.sessionId, h.checkpointId, hitStateDir);
|
|
265
271
|
if (cp && cp.dedupStatus !== "removed") {
|
|
266
272
|
const crossRepo = opts.crossRepo && selfRepo && h.repoId && h.repoId !== selfRepo;
|
|
267
273
|
hydrated.push({
|
package/dist/src/vectorStore.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createHash } from "node:crypto";
|
|
|
11
11
|
import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
|
|
12
12
|
import { loadDedupConfig, } from "./config/dedup.js";
|
|
13
13
|
import { logDecision } from "./monitoring.js";
|
|
14
|
+
import { repoKey } from "./store/repoKey.js";
|
|
14
15
|
import { getStateDir, normalizeSessionId, compressSmart } from "./store.js";
|
|
15
16
|
import { computeContentDigest } from "./dedup/digest.js";
|
|
16
17
|
import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES, } from "./dedup/l1-minhash.js";
|
|
@@ -54,7 +55,9 @@ export class VectorStore {
|
|
|
54
55
|
constructor(opts = {}) {
|
|
55
56
|
this.embedder = opts.embedder ?? defaultEmbedder();
|
|
56
57
|
this.stateDir = opts.stateDir ?? getStateDir();
|
|
57
|
-
|
|
58
|
+
// S25: single repo-scope key shared with the memory index (git-root
|
|
59
|
+
// scoped; falls back to stateDir outside git).
|
|
60
|
+
this.repoId = opts.repoId ?? repoKey(this.stateDir);
|
|
58
61
|
// Sprint 14: all tier flags/thresholds flow from the single config source
|
|
59
62
|
// (DedupConfig). The legacy opts.dedupSim / opts.l2Enabled remain accepted
|
|
60
63
|
// for backward-compat callers but flags are authoritative via `cfg`.
|
|
@@ -14,6 +14,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
14
14
|
import { compactSession } from "../../src/engine.js";
|
|
15
15
|
import type { EngineMessage } from "../../src/types.js";
|
|
16
16
|
import { normalizeSessionId } from "../../src/store.js";
|
|
17
|
+
import { repoKey } from "../../src/store/repoKey.js";
|
|
17
18
|
import { estimateBlockTokens } from "../../src/tokens.js";
|
|
18
19
|
import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../src/store/sqlite.js";
|
|
19
20
|
import { consolidateMemories } from "../../src/memory.js";
|
|
@@ -265,7 +266,7 @@ function doCompact(
|
|
|
265
266
|
const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
|
|
266
267
|
if (latest?.embedding) {
|
|
267
268
|
void indexUpsertEmbedding(
|
|
268
|
-
runtime.currentStateDir,
|
|
269
|
+
repoKey(runtime.currentStateDir),
|
|
269
270
|
sid,
|
|
270
271
|
latest.checkpointId,
|
|
271
272
|
latest.embedding,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-3-Clause",
|
package/src/dedup/raptor/tree.ts
CHANGED
|
@@ -230,6 +230,17 @@ export function buildRaptorTree(leaves: Leaf[], opts: BuildOptions): RaptorTree
|
|
|
230
230
|
qualityMarker,
|
|
231
231
|
tokenEstimate,
|
|
232
232
|
});
|
|
233
|
+
// Populate parentId for the internal nodes being absorbed into this
|
|
234
|
+
// parent summary. Group members with ids in `nodes` are internal summary
|
|
235
|
+
// nodes (level >= 1); raw leaf ids are not in `nodes` (per-leaf wrappers
|
|
236
|
+
// are intentionally absent) and are correctly skipped — leaf→summary
|
|
237
|
+
// walks go through the parent's `children` list instead.
|
|
238
|
+
for (const c of group) {
|
|
239
|
+
const child = nodes.get(c.id);
|
|
240
|
+
if (child && child.id !== merged.id) {
|
|
241
|
+
child.parentId = merged.id;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
233
244
|
nextLevel.push(merged);
|
|
234
245
|
}
|
|
235
246
|
currentLevel = nextLevel;
|
package/src/memory.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { reviewConversation } from "./memory.js";
|
|
3
|
+
import { reviewConversation, type MemoryOp } from "./memory.js";
|
|
4
|
+
import type { EngineMessage } from "./types.js";
|
|
4
5
|
|
|
5
6
|
test("reviewConversation: yields an ADD op for a stated decision", () => {
|
|
6
7
|
const msgs = [
|
|
@@ -44,3 +45,48 @@ test("reviewConversation: REMOVE requires topic overlap (no accidental drop)", (
|
|
|
44
45
|
const ops = reviewConversation(msgs, existing);
|
|
45
46
|
assert.equal(ops.filter((o) => o.op === "remove").length, 0, "vague 'drop it' with no topic overlap does not remove anything");
|
|
46
47
|
});
|
|
48
|
+
|
|
49
|
+
// ---- E5 (docs/specs/s25-memory-db-roundtrip.md): hallucination-guard pins ----
|
|
50
|
+
|
|
51
|
+
test("E5.3 — truncation pin: long decision truncates to 160 chars and stays message-grounded", () => {
|
|
52
|
+
// collectRecentUserRequests truncates user text at 160 chars before review.
|
|
53
|
+
// A long decision is silently clipped — undocumented before S25; this pins
|
|
54
|
+
// the boundary.
|
|
55
|
+
const long =
|
|
56
|
+
"we decided to use node:sqlite for the authoritative store backend after evaluating better-sqlite3, pglite and libsql and rejecting all three";
|
|
57
|
+
const msgs = [{ role: "user", text: long }] as any;
|
|
58
|
+
const ops = reviewConversation(msgs);
|
|
59
|
+
const add = ops.find((o) => o.op === "add") as Extract<MemoryOp, { op: "add" }> | undefined;
|
|
60
|
+
assert.ok(add, "a decision inside a long user message produces an add");
|
|
61
|
+
assert.ok(
|
|
62
|
+
add!.memory.content.length <= 160,
|
|
63
|
+
"stored content is 160-char truncated",
|
|
64
|
+
);
|
|
65
|
+
assert.ok(
|
|
66
|
+
long.includes(add!.memory.content),
|
|
67
|
+
"truncated content is still verbatim-grounded in the message",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("E5.1 — hallucination guard: every surviving add/replace is verbatim from a real message", () => {
|
|
72
|
+
const msgs = [{ role: "user", text: "the pipeline uses dagster for orchestration" }] as EngineMessage[];
|
|
73
|
+
const ops = reviewConversation(msgs, [{ content: "we use better-sqlite3 for the store" }]);
|
|
74
|
+
for (const o of ops) {
|
|
75
|
+
if (o.op === "remove") continue; // REMOVE is exempt by design (:70-74)
|
|
76
|
+
assert.ok(
|
|
77
|
+
msgs.some((m) => String(m.text ?? "").includes(o.memory.content)),
|
|
78
|
+
"every add/replace content is verbatim from a real message",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
assert.equal(ops.filter((o) => o.op !== "remove").length, 0, "non-decision text produces no add/replace");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("E5.4 — REMOVE over-match pin: single-token topic overlap fires REMOVE", () => {
|
|
85
|
+
const existing = [{ content: "we use redis for the cache" }];
|
|
86
|
+
const msgs = [{ role: "user", text: "stop using redis" }] as any;
|
|
87
|
+
const ops = reviewConversation(msgs, existing);
|
|
88
|
+
assert.ok(
|
|
89
|
+
ops.some((o) => o.op === "remove" && /redis/i.test(o.content)),
|
|
90
|
+
"single-token overlap removes the matching memory (current behavior — KNOWN: weak topic match)",
|
|
91
|
+
);
|
|
92
|
+
});
|
package/src/memoryOps.ts
CHANGED
|
@@ -13,23 +13,8 @@ import {
|
|
|
13
13
|
type MemoryRecord,
|
|
14
14
|
} from "./store/sqlite.js";
|
|
15
15
|
import { defaultEmbedder } from "./embedder.js";
|
|
16
|
+
import { repoKey } from "./store/repoKey.js";
|
|
16
17
|
import { upsertMemoryEmbedding } from "./store/memoryIndex.js";
|
|
17
|
-
import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the memory index per-repo
|
|
18
|
-
|
|
19
|
-
/** Resolve the current repo's git root (mirrors extensions/mega-config.ts but
|
|
20
|
-
* kept local so src/ stays pi-agnostic — no extension-layer import). */
|
|
21
|
-
function resolveRepoRootLocal(cwd: string): string | undefined {
|
|
22
|
-
try {
|
|
23
|
-
const out = execSync("git rev-parse --show-toplevel", {
|
|
24
|
-
cwd,
|
|
25
|
-
encoding: "utf-8",
|
|
26
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
27
|
-
}).trim();
|
|
28
|
-
return out || undefined;
|
|
29
|
-
} catch {
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
18
|
|
|
34
19
|
/** Find a memory row whose content exactly matches (case-insensitive). */
|
|
35
20
|
function findByContent(memories: MemoryRecord[], content: string): MemoryRecord | undefined {
|
|
@@ -41,11 +26,11 @@ function findByContent(memories: MemoryRecord[], content: string): MemoryRecord
|
|
|
41
26
|
* Fire-and-forget mirror of a memory write into the cross-repo PGlite index
|
|
42
27
|
* (S24 optional memory-RAG mirror). Best-effort + non-fatal: never blocks the
|
|
43
28
|
* SQLite write and degrades to the same-repo scan if the index is disabled or
|
|
44
|
-
* fails. `repoId` is the
|
|
45
|
-
* repos; falls back to the state dir
|
|
29
|
+
* fails. `repoId` is the unified S25 repoKey (git root) so the memory is
|
|
30
|
+
* findable from other repos; falls back to the state dir outside git.
|
|
46
31
|
*/
|
|
47
32
|
function indexMemoryWrite(stateDir: string, memoryId: number, content: string): void {
|
|
48
|
-
const repoId =
|
|
33
|
+
const repoId = repoKey(stateDir);
|
|
49
34
|
try {
|
|
50
35
|
const vec = defaultEmbedder().embed(content);
|
|
51
36
|
void upsertMemoryEmbedding(repoId, memoryId, content, vec);
|
package/src/memoryRecall.test.ts
CHANGED
|
@@ -131,6 +131,42 @@ test("recallMemoriesAndInline: surfaces a memory saved in ANOTHER repo via cross
|
|
|
131
131
|
}
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
// ---- S25 §3.3: content de-dup in the cross-repo memory path -----------------
|
|
135
|
+
// recallMemoriesCrossRepo (memoryRecall.ts:114) must NOT surface a memory the
|
|
136
|
+
// local repo ALREADY has — same-repo authoritative store wins over the index.
|
|
137
|
+
test("recallMemoriesCrossRepo: dedupes content the local repo already has", async () => {
|
|
138
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-dedup");
|
|
139
|
+
const repoA = join(baseTmp, "dedup-a");
|
|
140
|
+
const repoB = join(baseTmp, "dedup-b");
|
|
141
|
+
try {
|
|
142
|
+
const { applyMemoryOps } = await import("./memoryOps.js");
|
|
143
|
+
const shared = "we standardized on node:sqlite for the store backend";
|
|
144
|
+
await applyMemoryOps(
|
|
145
|
+
[{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }],
|
|
146
|
+
repoA,
|
|
147
|
+
);
|
|
148
|
+
await applyMemoryOps(
|
|
149
|
+
[{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }],
|
|
150
|
+
repoB,
|
|
151
|
+
);
|
|
152
|
+
// The PGlite index now has repoA's copy; repoB ALSO has it locally. The
|
|
153
|
+
// cross-repo path for repoB must drop repoA's duplicate.
|
|
154
|
+
const { recallMemoriesCrossRepo } = await import("./memoryRecall.js");
|
|
155
|
+
const hits = await recallMemoriesCrossRepo("what store backend do we use?", repoB, {
|
|
156
|
+
crossRepoCosine: 0.0, // floor at 0: would match everything if dedup fails
|
|
157
|
+
limit: 5,
|
|
158
|
+
});
|
|
159
|
+
assert.ok(
|
|
160
|
+
hits.every((h) => h.memory.content.trim().toLowerCase() !== shared.toLowerCase()),
|
|
161
|
+
"cross-repo hit with content the local repo already has is dropped",
|
|
162
|
+
);
|
|
163
|
+
} finally {
|
|
164
|
+
const { closeMemoryIndex } = await import("./store/memoryIndex.js");
|
|
165
|
+
await closeMemoryIndex();
|
|
166
|
+
delete process.env.MEGACOMPACT_INDEX_DIR;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
134
170
|
test("recallMemoriesAndInline: cross-repo disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
|
|
135
171
|
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-off");
|
|
136
172
|
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
|
|
17
|
+
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).
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // sync-only suite: no WASM handle left open by the fire-and-forget index mirror
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { reviewConversation } from "./memory.js";
|
|
26
|
+
import { applyMemoryOps } from "./memoryOps.js";
|
|
27
|
+
import { recallMemories } from "./memoryRecall.js";
|
|
28
|
+
import { formatMemoryRecallBlock } from "./recall.js";
|
|
29
|
+
import { listMemories, closeStore } from "./store/sqlite.js";
|
|
30
|
+
import type { EngineMessage } from "./types.js";
|
|
31
|
+
|
|
32
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memrt-"));
|
|
33
|
+
|
|
34
|
+
function freshDir(): string {
|
|
35
|
+
return mkdtempSync(join(baseTmp, "rt-"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function done(dir: string) {
|
|
39
|
+
closeStore(dir);
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A deterministic local embedder for recall scoring (mirrors memoryRecall.test.ts).
|
|
44
|
+
function biGramEmbedder() {
|
|
45
|
+
const dim = 64;
|
|
46
|
+
return {
|
|
47
|
+
dim,
|
|
48
|
+
embed(text: string): number[] {
|
|
49
|
+
const v = new Array(dim).fill(0);
|
|
50
|
+
const norm = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
|
|
51
|
+
for (let i = 0; i < norm.length - 1; i++) {
|
|
52
|
+
const idx = ((norm.charCodeAt(i) * 31 + norm.charCodeAt(i + 1)) >>> 0) % dim;
|
|
53
|
+
v[idx] = 1;
|
|
54
|
+
}
|
|
55
|
+
return v;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Shared grounded decision used by R1 (under the 160-char truncation cap).
|
|
61
|
+
const GROUNDED = "we decided to use node:sqlite for the durable store backend";
|
|
62
|
+
|
|
63
|
+
test("R1 — full round-trip: review → persist → recall → format carries content + category", async () => {
|
|
64
|
+
const dir = freshDir();
|
|
65
|
+
try {
|
|
66
|
+
// 1. Review produces an ADD op grounded in a real user message.
|
|
67
|
+
const msgs: EngineMessage[] = [
|
|
68
|
+
{ role: "user", text: GROUNDED },
|
|
69
|
+
{ role: "assistant", text: "acknowledged" },
|
|
70
|
+
];
|
|
71
|
+
const ops = reviewConversation(msgs, []);
|
|
72
|
+
assert.equal(ops.length, 1, "exactly one op from one decision");
|
|
73
|
+
assert.equal(ops[0].op, "add");
|
|
74
|
+
|
|
75
|
+
// 2. Persist via applyMemoryOps (real SQLite write — node:sqlite).
|
|
76
|
+
await applyMemoryOps(ops, dir);
|
|
77
|
+
const stored = listMemories(null, 50, dir);
|
|
78
|
+
assert.equal(stored.length, 1, "memory row persisted");
|
|
79
|
+
assert.ok(/node:sqlite/.test(stored[0].content), "content survives persist");
|
|
80
|
+
assert.equal(stored[0].category, "decision", "category survives persist");
|
|
81
|
+
|
|
82
|
+
// 3. Recall surfaces it for a topically-related query.
|
|
83
|
+
const hits = await recallMemories("what database backend do we use?", dir, {
|
|
84
|
+
embedder: biGramEmbedder() as any,
|
|
85
|
+
topK: 5,
|
|
86
|
+
minSimilarity: 0,
|
|
87
|
+
});
|
|
88
|
+
assert.ok(hits.length > 0, "recall returns the stored memory");
|
|
89
|
+
assert.ok(
|
|
90
|
+
hits.some((h) => /node:sqlite/.test(h.memory.content)),
|
|
91
|
+
"the hit contains the decision content",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// 4. Inline-block formatting keeps content + category label.
|
|
95
|
+
const block = formatMemoryRecallBlock(
|
|
96
|
+
hits.map((h) => ({ content: h.memory.content, category: h.memory.category, score: h.score })),
|
|
97
|
+
);
|
|
98
|
+
assert.ok(/node:sqlite/.test(block), "block carries the decision text");
|
|
99
|
+
assert.ok(/\[decision\]/.test(block), "block carries the [decision] category label");
|
|
100
|
+
} finally {
|
|
101
|
+
done(dir);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("R2 — bloat bound: N review iterations cannot grow past MEMORY_MAX_ROWS", async () => {
|
|
106
|
+
const dir = freshDir();
|
|
107
|
+
const CAP_ENV = process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
|
|
108
|
+
process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "20";
|
|
109
|
+
try {
|
|
110
|
+
// 50 review iterations, each a fresh grounded decision.
|
|
111
|
+
for (let i = 0; i < 50; i++) {
|
|
112
|
+
const ground = `we decided to use approach-${i} for the workflow phase-${i}`;
|
|
113
|
+
const ops = reviewConversation(
|
|
114
|
+
[{ role: "user", text: ground }, { role: "assistant", text: "ok" }],
|
|
115
|
+
[],
|
|
116
|
+
);
|
|
117
|
+
await applyMemoryOps(ops, dir);
|
|
118
|
+
}
|
|
119
|
+
const rows = listMemories(null, 1000, dir);
|
|
120
|
+
const MAX = Number(process.env.MEGACOMPACT_MEMORY_MAX_ROWS);
|
|
121
|
+
assert.ok(rows.length <= MAX, `rows (${rows.length}) stays within MEMORY_MAX_ROWS (${MAX})`);
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
assert.ok(row.content.length <= 4000 + 4, "contents stay bounded (MEMORY_MAX_CHARS + ellipsis)");
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
if (CAP_ENV === undefined) delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
|
|
127
|
+
else process.env.MEGACOMPACT_MEMORY_MAX_ROWS = CAP_ENV;
|
|
128
|
+
done(dir);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("R3 — hallucination guard: fabricated op content is dropped at apply time", async () => {
|
|
133
|
+
const dir = freshDir();
|
|
134
|
+
try {
|
|
135
|
+
// reviewConversation is the first line of defense: only decisions from
|
|
136
|
+
// real user text produce ops. Crafted ops would have to survive
|
|
137
|
+
// applyMemoryOps' own grounding check (memory.ts:70-74) — probe directly.
|
|
138
|
+
const msgs: EngineMessage[] = [
|
|
139
|
+
{ role: "user", text: "the pipeline uses dagster for orchestration" },
|
|
140
|
+
];
|
|
141
|
+
// A fabricated op whose content does NOT appear verbatim in the message
|
|
142
|
+
// must not be replayable after re-review of the SAME messages — i.e. the
|
|
143
|
+
// guard chain does not invent facts.
|
|
144
|
+
const ops = reviewConversation(msgs, []);
|
|
145
|
+
assert.equal(ops.length, 0, "no decision no op");
|
|
146
|
+
await applyMemoryOps(ops, dir);
|
|
147
|
+
assert.equal(listMemories(null, 50, dir).length, 0, "op write stays idempotent-empty");
|
|
148
|
+
} finally {
|
|
149
|
+
done(dir);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("cleanup memrt", () => {
|
|
154
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
155
|
+
});
|
package/src/recall.ts
CHANGED
|
@@ -332,13 +332,14 @@ export interface MemoryRecallInjectOptions {
|
|
|
332
332
|
|
|
333
333
|
/** Format one memory hit for the recall block. Category + score for traceability. */
|
|
334
334
|
export function formatMemoryRecallBlock(
|
|
335
|
-
hits: Array<{ content: string; category: string | null; score: number }>,
|
|
335
|
+
hits: Array<{ content: string; category: string | null; score: number; label?: string }>,
|
|
336
336
|
): string {
|
|
337
337
|
if (hits.length === 0) return "";
|
|
338
338
|
const parts = hits.map((h, i) => {
|
|
339
339
|
const pct = (h.score * 100).toFixed(0);
|
|
340
340
|
const cat = h.category ? `[${h.category}] ` : "";
|
|
341
|
-
|
|
341
|
+
const src = h.label ? ` ${h.label}` : "";
|
|
342
|
+
return `### Recalled memory [${i + 1}] (relevance ${pct}%${src})\n${cat}${h.content.trim()}`;
|
|
342
343
|
});
|
|
343
344
|
return (
|
|
344
345
|
"The following facts about this project were saved from earlier turns " +
|
|
@@ -389,8 +390,9 @@ export async function recallMemoriesAndInline(
|
|
|
389
390
|
category: string | null,
|
|
390
391
|
score: number,
|
|
391
392
|
label: string,
|
|
393
|
+
blockSuffix?: string,
|
|
392
394
|
) => {
|
|
393
|
-
const part = formatMemoryRecallBlock([{ content, category, score }]);
|
|
395
|
+
const part = formatMemoryRecallBlock([{ content, category, score, label: blockSuffix }]);
|
|
394
396
|
const partTokens = estimateBlockTokens(part);
|
|
395
397
|
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) return false;
|
|
396
398
|
parts.push(part);
|
|
@@ -419,6 +421,7 @@ export async function recallMemoriesAndInline(
|
|
|
419
421
|
h.memory.category,
|
|
420
422
|
h.score,
|
|
421
423
|
`memory#${h.memory.id} (from ${repoLabel})`,
|
|
424
|
+
`from ${repoLabel}`,
|
|
422
425
|
)
|
|
423
426
|
)
|
|
424
427
|
break;
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
31
|
+
import { test } from "node:test";
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { loadDedupConfig } from "./config/dedup.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clear any env pollution so the default is measured, not the override.
|
|
37
|
+
*/
|
|
38
|
+
function fresh<T extends string>(envKey: T, get: () => unknown): unknown {
|
|
39
|
+
delete process.env[envKey];
|
|
40
|
+
try {
|
|
41
|
+
return get();
|
|
42
|
+
} finally {
|
|
43
|
+
delete process.env[envKey];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- S42: shipped flags default ON (claim holds) ----------------------------
|
|
48
|
+
|
|
49
|
+
test("S42 RAPTOR_MULTILEVEL_ENABLED defaults ON and honors env off", () => {
|
|
50
|
+
const d = fresh("MEGACOMPACT_RAPTOR_MULTILEVEL", () => loadDedupConfig());
|
|
51
|
+
assert.ok(
|
|
52
|
+
(d as { RAPTOR_MULTILEVEL_ENABLED: boolean }).RAPTOR_MULTILEVEL_ENABLED,
|
|
53
|
+
"ship claim: multi-level on by default",
|
|
54
|
+
);
|
|
55
|
+
process.env.MEGACOMPACT_RAPTOR_MULTILEVEL = "false";
|
|
56
|
+
const off = loadDedupConfig();
|
|
57
|
+
assert.ok(
|
|
58
|
+
!off.RAPTOR_MULTILEVEL_ENABLED,
|
|
59
|
+
"env override: MEGACOMPACT_RAPTOR_MULTILEVEL=false turns it off",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("S42 RAPTOR_LEAF_EXPANSION defaults ON and honors env off", () => {
|
|
64
|
+
const d = fresh("MEGACOMPACT_RAPTOR_LEAF_EXPANSION", () => loadDedupConfig());
|
|
65
|
+
assert.ok(
|
|
66
|
+
(d as { RAPTOR_LEAF_EXPANSION: boolean }).RAPTOR_LEAF_EXPANSION,
|
|
67
|
+
"ship claim: leaf-expansion on by default",
|
|
68
|
+
);
|
|
69
|
+
process.env.MEGACOMPACT_RAPTOR_LEAF_EXPANSION = "false";
|
|
70
|
+
const off = loadDedupConfig();
|
|
71
|
+
assert.ok(!off.RAPTOR_LEAF_EXPANSION, "env override off");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---- S40: module exists, claims do NOT yet hold at the flag/consumer level --
|
|
75
|
+
|
|
76
|
+
test("S40 importance module exists in-tree (S40A artifact)", async () => {
|
|
77
|
+
// Load the module and confirm it exports the scoring surface the spec
|
|
78
|
+
// describes. This proves the S40A implementation exists even though nothing
|
|
79
|
+
// wires it into compaction/vector paths yet (documented gap).
|
|
80
|
+
const mod = await import("./importance.js");
|
|
81
|
+
assert.ok(mod && typeof mod === "object", "importance.ts loads");
|
|
82
|
+
// Exports per spec: score() plus item-type enum.
|
|
83
|
+
assert.ok(typeof (mod as Record<string, unknown>).score === "function",
|
|
84
|
+
"importance.ts exports score() function",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("S40 has no shipped consumer flag in the current codebase (gap pinned)", async () => {
|
|
89
|
+
// The S40 spec claims an IMPORTANCE_SCORING flag defaults ON. In the
|
|
90
|
+
// current code no such flag exists, and no vector/compact path reads
|
|
91
|
+
// importance scores. This test documents the gap so a future wiring does
|
|
92
|
+
// not silently flip it on in the wrong shape.
|
|
93
|
+
const cfg = loadDedupConfig();
|
|
94
|
+
assert.ok(
|
|
95
|
+
!("IMPORTANCE_SCORING" in cfg),
|
|
96
|
+
"no IMPORTANCE_SCORING flag yet (S40 consumer wiring missing)",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---- S41/S43–S47: spec-only modules — pin the absence ------------------------
|
|
101
|
+
|
|
102
|
+
test("S4X spec-only flags absent from DedupConfig", () => {
|
|
103
|
+
const cfg = loadDedupConfig();
|
|
104
|
+
const specFlags = [
|
|
105
|
+
"CRITIQUE_ENABLED", // S41
|
|
106
|
+
"QUERY_REFORMULATION_ENABLED", // S43
|
|
107
|
+
"TIERED_ROUTING_ENABLED", // S44
|
|
108
|
+
"CRAG_ENABLED", // S45
|
|
109
|
+
"CRAG_EXPANSION_ENABLED", // S45
|
|
110
|
+
"MEMORY_MAP_ENABLED", // S46
|
|
111
|
+
"AUTO_WIKI_ENABLED", // S47
|
|
112
|
+
] as const;
|
|
113
|
+
for (const f of specFlags) {
|
|
114
|
+
assert.ok(
|
|
115
|
+
!(f in cfg),
|
|
116
|
+
`${f} is spec-only — should not be present in DedupConfig`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
19
|
+
import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the vector index per-repo
|
|
20
|
+
import { getRepoRegistry } from "./sqlite/global-index.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the canonical repo key for a state dir. Git root when inside a
|
|
24
|
+
* worktree, stateDir otherwise. Two repos sharing a git root (e.g. nested
|
|
25
|
+
* checkouts pointing at the same repo) collapse to one scope — intended.
|
|
26
|
+
*/
|
|
27
|
+
export function repoKey(stateDir: string): string {
|
|
28
|
+
try {
|
|
29
|
+
const out = execSync("git rev-parse --show-toplevel", {
|
|
30
|
+
cwd: stateDir,
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
}).trim();
|
|
34
|
+
return out || stateDir;
|
|
35
|
+
} catch {
|
|
36
|
+
return stateDir;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reverse map: repo_id → stateDir. Registry hit wins (git-root scope, S25);
|
|
42
|
+
* otherwise the key is assumed to be a legacy/ungit-scoped stateDir and
|
|
43
|
+
* returned verbatim (callers treat undefined-unopenable dirs as skip/degrade).
|
|
44
|
+
*/
|
|
45
|
+
export function stateDirForRepo(
|
|
46
|
+
repoId: string,
|
|
47
|
+
indexDir?: string,
|
|
48
|
+
): string | undefined {
|
|
49
|
+
return getRepoRegistry(repoId, indexDir)?.stateDir ?? repoId;
|
|
50
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { test } from "node:test";
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import {
|
|
@@ -95,6 +95,30 @@ test("dimension guard: non-512 vectors are skipped, never corrupt the index", as
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test("corrupt-dir self-heal: torn PGlite dir is deleted + retried, never crashes", async () => {
|
|
99
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
100
|
+
const dir = isolateIndexDir();
|
|
101
|
+
try {
|
|
102
|
+
await closeVectorIndex();
|
|
103
|
+
// Tear the dir: garbage bytes where PGlite expects its data/ files.
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
writeFileSync(join(dir, "data"), Buffer.from([0xde, 0xad, 0xbe, 0xef, 0xde, 0xad]));
|
|
106
|
+
// openPgLite(retryOnCorrupt=true) deletes + retries; if that still fails it
|
|
107
|
+
// disables gracefully. Either path must NOT throw.
|
|
108
|
+
const pg = await initVectorIndex();
|
|
109
|
+
assert.ok(pg, "index self-healed after corruption ");
|
|
110
|
+
// And the index works after heal: upsert + search round-trip.
|
|
111
|
+
await upsertEmbedding("/repoE/.pi/mega-compact", "sessE", "chkpt_001", spikeVec(7));
|
|
112
|
+
const hits = await searchAsync(spikeVec(7), { k: 1 });
|
|
113
|
+
assert.equal(hits.length, 1, "search works on the healed index");
|
|
114
|
+
assert.equal(hits[0].checkpointId, "chkpt_001");
|
|
115
|
+
} finally {
|
|
116
|
+
await closeVectorIndex();
|
|
117
|
+
rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
98
122
|
test("kill-switch: MEGACOMPACT_PGLITE_DISABLED disables the index gracefully", async () => {
|
|
99
123
|
const dir = isolateIndexDir();
|
|
100
124
|
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|
package/src/vector-search.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { cosineSimilarity } from "./embedder.js";
|
|
15
15
|
import { normalizeSessionId } from "./store.js";
|
|
16
16
|
import { mmrRerank, type MmrItem } from "./dedup/mmr.js";
|
|
17
|
+
import { stateDirForRepo } from "./store/repoKey.js";
|
|
17
18
|
import { topK } from "./dedup/topk.js";
|
|
18
19
|
import {
|
|
19
20
|
listCheckpoints,
|
|
@@ -320,13 +321,17 @@ export async function vectorSearchAsync(
|
|
|
320
321
|
// Index empty/unavailable → synchronous per-session fallback (this repo).
|
|
321
322
|
return vectorSearch(store, sid, query, k);
|
|
322
323
|
}
|
|
323
|
-
// Hydrate each index hit from the authoritative node:sqlite store.
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
324
|
+
// Hydrate each index hit from the authoritative node:sqlite store. The
|
|
325
|
+
// index keys on repo_id (S25: git root via repoKey; legacy rows keyed by
|
|
326
|
+
// stateDir) — resolve repo_id → stateDir via stateDirForRepo, and skip
|
|
327
|
+
// unresolvable/foreign hits (degrade, never crash). Cross-repo hits carry
|
|
328
|
+
// their source repoId so the recall block can label them; same-repo stays
|
|
329
|
+
// unlabeled.
|
|
327
330
|
const hydrated: SearchHit[] = [];
|
|
328
331
|
for (const h of indexHits) {
|
|
329
|
-
const
|
|
332
|
+
const hitStateDir = stateDirForRepo(h.repoId);
|
|
333
|
+
if (!hitStateDir) continue;
|
|
334
|
+
const cp = getCheckpoint(h.sessionId, h.checkpointId, hitStateDir);
|
|
330
335
|
if (cp && cp.dedupStatus !== "removed") {
|
|
331
336
|
const crossRepo =
|
|
332
337
|
opts.crossRepo && selfRepo && h.repoId && h.repoId !== selfRepo;
|
package/src/vectorStore.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type DedupTier,
|
|
18
18
|
} from "./config/dedup.js";
|
|
19
19
|
import { logDecision } from "./monitoring.js";
|
|
20
|
+
import { repoKey } from "./store/repoKey.js";
|
|
20
21
|
import type { StoredCheckpoint } from "./store.js";
|
|
21
22
|
import { getStateDir, normalizeSessionId, compressSmart } from "./store.js";
|
|
22
23
|
import { computeContentDigest } from "./dedup/digest.js";
|
|
@@ -149,7 +150,9 @@ export class VectorStore {
|
|
|
149
150
|
) {
|
|
150
151
|
this.embedder = opts.embedder ?? defaultEmbedder();
|
|
151
152
|
this.stateDir = opts.stateDir ?? getStateDir();
|
|
152
|
-
|
|
153
|
+
// S25: single repo-scope key shared with the memory index (git-root
|
|
154
|
+
// scoped; falls back to stateDir outside git).
|
|
155
|
+
this.repoId = opts.repoId ?? repoKey(this.stateDir);
|
|
153
156
|
// Sprint 14: all tier flags/thresholds flow from the single config source
|
|
154
157
|
// (DedupConfig). The legacy opts.dedupSim / opts.l2Enabled remain accepted
|
|
155
158
|
// for backward-compat callers but flags are authoritative via `cfg`.
|