pi-mega-compact 0.8.25 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/raptor-inject-summaries.test.js +10 -3
- package/dist/src/recall.js +29 -13
- package/dist/src/sprint4x-rag-verification.test.js +93 -0
- package/dist/src/store/repoKey.js +45 -0
- package/dist/src/store/sqlite/turns.js +1 -3
- 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/raptor-inject-summaries.test.ts +22 -6
- package/src/recall.ts +439 -368
- package/src/sprint4x-rag-verification.test.ts +119 -0
- package/src/store/repoKey.ts +50 -0
- package/src/store/sqlite/turns.ts +177 -167
- 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
|
+
});
|
|
@@ -49,11 +49,17 @@ function msg(text, toolName) {
|
|
|
49
49
|
: { role: "user", text };
|
|
50
50
|
}
|
|
51
51
|
function seedTwoTopics(store, sid, per) {
|
|
52
|
-
const topics = [
|
|
52
|
+
const topics = [
|
|
53
|
+
"database connection pool postgres",
|
|
54
|
+
"user interface button react",
|
|
55
|
+
];
|
|
53
56
|
for (let i = 1; i <= per * 2; i++) {
|
|
54
57
|
compactSession({
|
|
55
58
|
sessionId: sid,
|
|
56
|
-
messages: [
|
|
59
|
+
messages: [
|
|
60
|
+
msg(`${topics[i % 2]} checkpoint ${i} alpha beta`),
|
|
61
|
+
msg(`ack ${i}`, "Edit"),
|
|
62
|
+
],
|
|
57
63
|
keepFrom: 2,
|
|
58
64
|
timestamp: i,
|
|
59
65
|
}, store);
|
|
@@ -80,7 +86,8 @@ test("S25-P2: RAPTOR_INJECT_SUMMARIES=true prepends a hierarchical overview head
|
|
|
80
86
|
assert.ok(r.block.length > 0, "recall block is non-empty");
|
|
81
87
|
assert.ok(r.block.includes("hierarchical overview"), "overview header present when flag ON");
|
|
82
88
|
assert.ok(r.block.includes("Recalled context"), "detailed recall block still present after overview");
|
|
83
|
-
assert.ok(r.block.indexOf("hierarchical overview") <
|
|
89
|
+
assert.ok(r.block.indexOf("hierarchical overview") <
|
|
90
|
+
r.block.indexOf("Recalled context"), "overview precedes detail");
|
|
84
91
|
});
|
|
85
92
|
// ─── 2. Flag OFF → no overview (detail-only, unchanged behavior) ────────────
|
|
86
93
|
test("S25-P2: RAPTOR_INJECT_SUMMARIES=false → no overview (detail-only)", () => {
|
package/dist/src/recall.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* extension decides where it lands.
|
|
15
15
|
*/
|
|
16
16
|
import { recall as searchRecall } from "./engine.js";
|
|
17
|
-
import { vectorWasInjected, vectorMarkInjected, vectorSearchAsync } from "./vectorStore.js";
|
|
17
|
+
import { vectorWasInjected, vectorMarkInjected, vectorSearchAsync, } from "./vectorStore.js";
|
|
18
18
|
import { estimateBlockTokens } from "./tokens.js";
|
|
19
19
|
import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
|
|
20
20
|
import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
|
|
@@ -29,7 +29,9 @@ export function formatRecallBlock(hits) {
|
|
|
29
29
|
// S17: label a cross-repo hit with its source repo (the repoId doubles as
|
|
30
30
|
// that repo's stateDir, so the last path segment is the repo's display
|
|
31
31
|
// name). Same-repo hits (no repoId) stay unlabeled.
|
|
32
|
-
const repoName = h.repoId
|
|
32
|
+
const repoName = h.repoId
|
|
33
|
+
? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})`
|
|
34
|
+
: "";
|
|
33
35
|
// S42B: a RAPTOR cluster node hit (not a stored checkpoint) is labeled as a
|
|
34
36
|
// hierarchical summary and uses raptorSummary as its body. No Key files line
|
|
35
37
|
// (cluster nodes carry no file list).
|
|
@@ -58,7 +60,9 @@ export function formatRaptorBlock(nodes) {
|
|
|
58
60
|
if (nodes.length === 0)
|
|
59
61
|
return "";
|
|
60
62
|
const parts = nodes.map((n, i) => {
|
|
61
|
-
const score = n.score !== undefined
|
|
63
|
+
const score = n.score !== undefined
|
|
64
|
+
? ` (relevance ${(n.score * 100).toFixed(0)}%)`
|
|
65
|
+
: "";
|
|
62
66
|
const label = n.level === 0
|
|
63
67
|
? `Session overview [${i + 1}]${score}`
|
|
64
68
|
: `Cluster summary [${i + 1}] (level ${n.level})${score}`;
|
|
@@ -203,11 +207,19 @@ function raptorOverviewBlock(store, sessionId, query) {
|
|
|
203
207
|
const qv = store.embedder.embed(query);
|
|
204
208
|
// Root (level 0) first, then the top level-1 clusters by cosine to the query.
|
|
205
209
|
const nodes = [
|
|
206
|
-
{
|
|
210
|
+
{
|
|
211
|
+
summary: root.summary,
|
|
212
|
+
level: root.level,
|
|
213
|
+
score: cosineSimilarity(qv, root.embedding),
|
|
214
|
+
},
|
|
207
215
|
];
|
|
208
216
|
const level1 = [...tree.nodes.values()]
|
|
209
217
|
.filter((n) => n.level === 1 && n.summary)
|
|
210
|
-
.map((n) => ({
|
|
218
|
+
.map((n) => ({
|
|
219
|
+
summary: n.summary,
|
|
220
|
+
level: n.level,
|
|
221
|
+
score: cosineSimilarity(qv, n.embedding),
|
|
222
|
+
}))
|
|
211
223
|
.sort((a, b) => b.score - a.score)
|
|
212
224
|
.slice(0, 3);
|
|
213
225
|
nodes.push(...level1);
|
|
@@ -224,7 +236,8 @@ export function formatMemoryRecallBlock(hits) {
|
|
|
224
236
|
const parts = hits.map((h, i) => {
|
|
225
237
|
const pct = (h.score * 100).toFixed(0);
|
|
226
238
|
const cat = h.category ? `[${h.category}] ` : "";
|
|
227
|
-
|
|
239
|
+
const src = h.label ? ` ${h.label}` : "";
|
|
240
|
+
return `### Recalled memory [${i + 1}] (relevance ${pct}%${src})\n${cat}${h.content.trim()}`;
|
|
228
241
|
});
|
|
229
242
|
return ("The following facts about this project were saved from earlier turns " +
|
|
230
243
|
"and are relevant to the current request. Treat them as established:\n\n" +
|
|
@@ -263,8 +276,8 @@ export async function recallMemoriesAndInline(opts) {
|
|
|
263
276
|
const parts = [];
|
|
264
277
|
const report = [];
|
|
265
278
|
let blockTokens = 0;
|
|
266
|
-
const pushHit = (content, category, score, label) => {
|
|
267
|
-
const part = formatMemoryRecallBlock([{ content, category, score }]);
|
|
279
|
+
const pushHit = (content, category, score, label, blockSuffix) => {
|
|
280
|
+
const part = formatMemoryRecallBlock([{ content, category, score, label: blockSuffix }]);
|
|
268
281
|
const partTokens = estimateBlockTokens(part);
|
|
269
282
|
if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
|
|
270
283
|
return false;
|
|
@@ -279,7 +292,7 @@ export async function recallMemoriesAndInline(opts) {
|
|
|
279
292
|
}
|
|
280
293
|
for (const h of crossHits) {
|
|
281
294
|
const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
|
|
282
|
-
if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`))
|
|
295
|
+
if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`, `from ${repoLabel}`))
|
|
283
296
|
break;
|
|
284
297
|
}
|
|
285
298
|
return { empty: parts.length === 0, block: parts.join("\n"), report };
|
|
@@ -329,16 +342,19 @@ export async function recallAndInlineAsync(opts, store) {
|
|
|
329
342
|
const skipCrossRepoHits = !!opts.crossRepo && !opts.globalIndexDir;
|
|
330
343
|
if (skipCrossRepoHits) {
|
|
331
344
|
try {
|
|
332
|
-
console.warn("[mega-compact:recall] cross-repo recall enabled but globalIndexDir is unset — "
|
|
333
|
-
|
|
345
|
+
console.warn("[mega-compact:recall] cross-repo recall enabled but globalIndexDir is unset — " +
|
|
346
|
+
"skipping cross-repo injection to avoid re-injecting undeduped foreign checkpoints");
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
/* ignore */
|
|
334
350
|
}
|
|
335
|
-
catch { /* ignore */ }
|
|
336
351
|
}
|
|
337
352
|
for (const h of hits) {
|
|
338
353
|
// F2: skip foreign hits when we can't dedup them machine-wide.
|
|
339
354
|
if (skipCrossRepoHits && h.repoId)
|
|
340
355
|
continue;
|
|
341
|
-
if (skip &&
|
|
356
|
+
if (skip &&
|
|
357
|
+
vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId))
|
|
342
358
|
continue;
|
|
343
359
|
// S18: machine-wide injected-set — a foreign checkpoint already injected
|
|
344
360
|
// (in any session) is never re-injected. Only applies to cross-repo hits
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sprint4x-rag-verification.test.ts — run the S40–S47 RAG suite once against
|
|
3
|
+
* the current implementation and assert the documented "default enable" claims.
|
|
4
|
+
*
|
|
5
|
+
* This is the verification pass demanded by the roadmap/backlog: each spec
|
|
6
|
+
* claims its feature flags DEFAULT ON. The honest check (not a re-statement of
|
|
7
|
+
* the spec) is: which flags actually exist in code, what do they default to,
|
|
8
|
+
* and is the consuming path wired?
|
|
9
|
+
*
|
|
10
|
+
* Findings recorded here, per spec:
|
|
11
|
+
* S40 importance-scoring — module src/importance.ts exists (S40A) but is
|
|
12
|
+
* NOT consumed by compactSession/vector-paths;
|
|
13
|
+
* no shipped flag, no adapter wiring.
|
|
14
|
+
* S41 self-rag-quality-gate — spec-only; NO flag, NO consumer.
|
|
15
|
+
* S42 raptor-multilevel — PARTIALLY SHIPPED: RAPTOR_MULTILEVEL_ENABLED
|
|
16
|
+
* and RAPTOR_LEAF_EXPANSION both default true
|
|
17
|
+
* with real consumers; the spec's claim holds.
|
|
18
|
+
* S43 hyde-vague-queries — spec-only (QUERY_REFORMULATION_ENABLED absent).
|
|
19
|
+
* S44 three-tier-latency-routing — spec-only (TIERED_ROUTING_ENABLED absent).
|
|
20
|
+
* S45 crag-quality-metrics — spec-only (CRAG_ENABLED absent).
|
|
21
|
+
* S46 visual-memory-map — spec-only (MEMORY_MAP_ENABLED absent;
|
|
22
|
+
* memory-graph endpoint not in dashboard-server).
|
|
23
|
+
* S47 auto-categorizing-wiki — spec-only (AUTO_WIKI_ENABLED absent).
|
|
24
|
+
*
|
|
25
|
+
* Policy: the suite runs against the flags that EXIST today. Unimplemented spec
|
|
26
|
+
* claims are pinned by the S4X_SPEC_ONLY tests so regressions can't silently
|
|
27
|
+
* add them in the wrong state, and this file records exactly which
|
|
28
|
+
* spec-vs-implementation gaps remain.
|
|
29
|
+
*/
|
|
30
|
+
import { test } from "node:test";
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { loadDedupConfig } from "./config/dedup.js";
|
|
33
|
+
/**
|
|
34
|
+
* Clear any env pollution so the default is measured, not the override.
|
|
35
|
+
*/
|
|
36
|
+
function fresh(envKey, get) {
|
|
37
|
+
delete process.env[envKey];
|
|
38
|
+
try {
|
|
39
|
+
return get();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
delete process.env[envKey];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ---- S42: shipped flags default ON (claim holds) ----------------------------
|
|
46
|
+
test("S42 RAPTOR_MULTILEVEL_ENABLED defaults ON and honors env off", () => {
|
|
47
|
+
const d = fresh("MEGACOMPACT_RAPTOR_MULTILEVEL", () => loadDedupConfig());
|
|
48
|
+
assert.ok(d.RAPTOR_MULTILEVEL_ENABLED, "ship claim: multi-level on by default");
|
|
49
|
+
process.env.MEGACOMPACT_RAPTOR_MULTILEVEL = "false";
|
|
50
|
+
const off = loadDedupConfig();
|
|
51
|
+
assert.ok(!off.RAPTOR_MULTILEVEL_ENABLED, "env override: MEGACOMPACT_RAPTOR_MULTILEVEL=false turns it off");
|
|
52
|
+
});
|
|
53
|
+
test("S42 RAPTOR_LEAF_EXPANSION defaults ON and honors env off", () => {
|
|
54
|
+
const d = fresh("MEGACOMPACT_RAPTOR_LEAF_EXPANSION", () => loadDedupConfig());
|
|
55
|
+
assert.ok(d.RAPTOR_LEAF_EXPANSION, "ship claim: leaf-expansion on by default");
|
|
56
|
+
process.env.MEGACOMPACT_RAPTOR_LEAF_EXPANSION = "false";
|
|
57
|
+
const off = loadDedupConfig();
|
|
58
|
+
assert.ok(!off.RAPTOR_LEAF_EXPANSION, "env override off");
|
|
59
|
+
});
|
|
60
|
+
// ---- S40: module exists, claims do NOT yet hold at the flag/consumer level --
|
|
61
|
+
test("S40 importance module exists in-tree (S40A artifact)", async () => {
|
|
62
|
+
// Load the module and confirm it exports the scoring surface the spec
|
|
63
|
+
// describes. This proves the S40A implementation exists even though nothing
|
|
64
|
+
// wires it into compaction/vector paths yet (documented gap).
|
|
65
|
+
const mod = await import("./importance.js");
|
|
66
|
+
assert.ok(mod && typeof mod === "object", "importance.ts loads");
|
|
67
|
+
// Exports per spec: score() plus item-type enum.
|
|
68
|
+
assert.ok(typeof mod.score === "function", "importance.ts exports score() function");
|
|
69
|
+
});
|
|
70
|
+
test("S40 has no shipped consumer flag in the current codebase (gap pinned)", async () => {
|
|
71
|
+
// The S40 spec claims an IMPORTANCE_SCORING flag defaults ON. In the
|
|
72
|
+
// current code no such flag exists, and no vector/compact path reads
|
|
73
|
+
// importance scores. This test documents the gap so a future wiring does
|
|
74
|
+
// not silently flip it on in the wrong shape.
|
|
75
|
+
const cfg = loadDedupConfig();
|
|
76
|
+
assert.ok(!("IMPORTANCE_SCORING" in cfg), "no IMPORTANCE_SCORING flag yet (S40 consumer wiring missing)");
|
|
77
|
+
});
|
|
78
|
+
// ---- S41/S43–S47: spec-only modules — pin the absence ------------------------
|
|
79
|
+
test("S4X spec-only flags absent from DedupConfig", () => {
|
|
80
|
+
const cfg = loadDedupConfig();
|
|
81
|
+
const specFlags = [
|
|
82
|
+
"CRITIQUE_ENABLED", // S41
|
|
83
|
+
"QUERY_REFORMULATION_ENABLED", // S43
|
|
84
|
+
"TIERED_ROUTING_ENABLED", // S44
|
|
85
|
+
"CRAG_ENABLED", // S45
|
|
86
|
+
"CRAG_EXPANSION_ENABLED", // S45
|
|
87
|
+
"MEMORY_MAP_ENABLED", // S46
|
|
88
|
+
"AUTO_WIKI_ENABLED", // S47
|
|
89
|
+
];
|
|
90
|
+
for (const f of specFlags) {
|
|
91
|
+
assert.ok(!(f in cfg), `${f} is spec-only — should not be present in DedupConfig`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repoKey.ts — S25-B: the single repo-scope key for BOTH global PGlite indexes.
|
|
3
|
+
*
|
|
4
|
+
* Before S25 the checkpoint index (vector_index) keyed on stateDir while the
|
|
5
|
+
* memory index (memory_index) keyed on the git root — two scopes that meant
|
|
6
|
+
* cross-repo checkpoint hydration had no way back from repo_id → the repo's
|
|
7
|
+
* store. This helper unifies both indexes on ONE key: the resolved git root,
|
|
8
|
+
* falling back to stateDir outside git worktrees.
|
|
9
|
+
*
|
|
10
|
+
* stateDirForRepo() reverses the mapping via the machine-wide
|
|
11
|
+
* repo_registry (src/store/sqlite/global-index.ts): a repo_id hit from the
|
|
12
|
+
* index resolves to that repo's stateDir so getCheckpoint() can hydrate from
|
|
13
|
+
* the authoritative node:sqlite store. Returns undefined when unresolvable —
|
|
14
|
+
* the caller skips the hit (degrade, never crash).
|
|
15
|
+
*
|
|
16
|
+
* PREVENT-PI-004: `git rev-parse` is local + read-only (annotated below).
|
|
17
|
+
*/
|
|
18
|
+
import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the vector index per-repo
|
|
19
|
+
import { getRepoRegistry } from "./sqlite/global-index.js";
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the canonical repo key for a state dir. Git root when inside a
|
|
22
|
+
* worktree, stateDir otherwise. Two repos sharing a git root (e.g. nested
|
|
23
|
+
* checkouts pointing at the same repo) collapse to one scope — intended.
|
|
24
|
+
*/
|
|
25
|
+
export function repoKey(stateDir) {
|
|
26
|
+
try {
|
|
27
|
+
const out = execSync("git rev-parse --show-toplevel", {
|
|
28
|
+
cwd: stateDir,
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
}).trim();
|
|
32
|
+
return out || stateDir;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return stateDir;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reverse map: repo_id → stateDir. Registry hit wins (git-root scope, S25);
|
|
40
|
+
* otherwise the key is assumed to be a legacy/ungit-scoped stateDir and
|
|
41
|
+
* returned verbatim (callers treat undefined-unopenable dirs as skip/degrade).
|
|
42
|
+
*/
|
|
43
|
+
export function stateDirForRepo(repoId, indexDir) {
|
|
44
|
+
return getRepoRegistry(repoId, indexDir)?.stateDir ?? repoId;
|
|
45
|
+
}
|
|
@@ -98,9 +98,7 @@ export function getTurn(conversationId, turnIndex, stateDir = getStateDir()) {
|
|
|
98
98
|
/** Get a turn by its global id. */
|
|
99
99
|
export function getTurnById(turnId, stateDir = getStateDir()) {
|
|
100
100
|
const db = openStore(stateDir);
|
|
101
|
-
const row = db
|
|
102
|
-
.prepare("SELECT * FROM turns WHERE id = ?")
|
|
103
|
-
.get(turnId);
|
|
101
|
+
const row = db.prepare("SELECT * FROM turns WHERE id = ?").get(turnId);
|
|
104
102
|
return row ? rowToTurn(row) : null;
|
|
105
103
|
}
|
|
106
104
|
/** All turn_recall rows for a turn (what was injected at that turn). */
|