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
|
@@ -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
|
+
});
|
|
@@ -59,12 +59,18 @@ function msg(text: string, toolName?: string): EngineMessage {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function seedTwoTopics(store: VectorStore, sid: string, per: number) {
|
|
62
|
-
const topics = [
|
|
62
|
+
const topics = [
|
|
63
|
+
"database connection pool postgres",
|
|
64
|
+
"user interface button react",
|
|
65
|
+
];
|
|
63
66
|
for (let i = 1; i <= per * 2; i++) {
|
|
64
67
|
compactSession(
|
|
65
68
|
{
|
|
66
69
|
sessionId: sid,
|
|
67
|
-
messages: [
|
|
70
|
+
messages: [
|
|
71
|
+
msg(`${topics[i % 2]} checkpoint ${i} alpha beta`),
|
|
72
|
+
msg(`ack ${i}`, "Edit"),
|
|
73
|
+
],
|
|
68
74
|
keepFrom: 2,
|
|
69
75
|
timestamp: i,
|
|
70
76
|
},
|
|
@@ -109,7 +115,8 @@ test("S25-P2: RAPTOR_INJECT_SUMMARIES=true prepends a hierarchical overview head
|
|
|
109
115
|
"detailed recall block still present after overview",
|
|
110
116
|
);
|
|
111
117
|
assert.ok(
|
|
112
|
-
r.block.indexOf("hierarchical overview") <
|
|
118
|
+
r.block.indexOf("hierarchical overview") <
|
|
119
|
+
r.block.indexOf("Recalled context"),
|
|
113
120
|
"overview precedes detail",
|
|
114
121
|
);
|
|
115
122
|
});
|
|
@@ -168,9 +175,18 @@ test("S25-P2: formatRaptorBlock labels root as 'Session overview' + clusters as
|
|
|
168
175
|
{ summary: "root overview text", level: 0, score: 0.9 },
|
|
169
176
|
{ summary: "cluster A text", level: 1, score: 0.7 },
|
|
170
177
|
]);
|
|
171
|
-
assert.ok(
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
assert.ok(
|
|
179
|
+
/The following hierarchical overview/.test(block),
|
|
180
|
+
"preamble present",
|
|
181
|
+
);
|
|
182
|
+
assert.ok(
|
|
183
|
+
/Session overview \[1\] \(relevance 90%\)/.test(block),
|
|
184
|
+
"root labeled",
|
|
185
|
+
);
|
|
186
|
+
assert.ok(
|
|
187
|
+
/Cluster summary \[2\] \(level 1\) \(relevance 70%\)/.test(block),
|
|
188
|
+
"cluster labeled",
|
|
189
|
+
);
|
|
174
190
|
assert.ok(block.includes("root overview text"), "root body present");
|
|
175
191
|
assert.ok(block.includes("cluster A text"), "cluster body present");
|
|
176
192
|
assert.equal(formatRaptorBlock([]), "", "empty input → empty string");
|