pi-mega-compact 0.6.0 → 0.6.2
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 +1 -1
- package/dist/extensions/mega-compact.test.js +48 -0
- package/dist/extensions/mega-conflict-cmds.js +17 -0
- package/dist/extensions/mega-events.js +110 -24
- package/dist/extensions/mega-pipeline.js +33 -18
- package/dist/extensions/mega-runtime.js +20 -0
- package/dist/extensions/mega-teamrun.test.js +143 -0
- package/dist/extensions/mega-trim.js +33 -2
- package/dist/src/memoryOps.js +42 -2
- package/dist/src/memoryOps.test.js +50 -27
- package/dist/src/memoryRecall.js +48 -0
- package/dist/src/memoryRecall.test.js +52 -0
- package/dist/src/recall.js +34 -8
- package/dist/src/store/memoryIndex.js +205 -0
- package/dist/src/store/memoryIndex.test.js +51 -0
- package/dist/src/store/sqlite.js +24 -5
- package/extensions/mega-compact.test.ts +50 -0
- package/extensions/mega-conflict-cmds.ts +15 -0
- package/extensions/mega-events.ts +104 -23
- package/extensions/mega-pipeline.ts +37 -17
- package/extensions/mega-runtime.ts +21 -0
- package/extensions/mega-teamrun.test.ts +164 -0
- package/extensions/mega-trim.ts +28 -1
- package/package.json +1 -1
- package/src/memoryOps.test.ts +47 -26
- package/src/memoryOps.ts +42 -2
- package/src/memoryRecall.test.ts +58 -0
- package/src/memoryRecall.ts +52 -0
- package/src/recall.ts +35 -10
- package/src/store/memoryIndex.test.ts +61 -0
- package/src/store/memoryIndex.ts +235 -0
- package/src/store/sqlite.ts +26 -5
package/src/memoryOps.test.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
replaceMemory,
|
|
11
11
|
referenceMemory,
|
|
12
12
|
MEMORY_MAX_CHARS,
|
|
13
|
-
MEMORY_MAX_ROWS,
|
|
14
13
|
} from "./store/sqlite.js";
|
|
15
14
|
|
|
16
15
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
|
|
@@ -79,31 +78,53 @@ test("S24: replaceMemory also truncates oversized content", () => {
|
|
|
79
78
|
});
|
|
80
79
|
|
|
81
80
|
test("S24: addMemory evicts LRU rows past MEMORY_MAX_ROWS per repo", () => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
81
|
+
// Use a small env cap for a fast, deterministic LRU check (the production
|
|
82
|
+
// default is 500; this exercises the same code path).
|
|
83
|
+
process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "10";
|
|
84
|
+
try {
|
|
85
|
+
const dir = join(baseTmp, "lru");
|
|
86
|
+
const n = 10;
|
|
87
|
+
const seeds = n - 2;
|
|
88
|
+
for (let i = 0; i < seeds; i++) addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
|
|
89
|
+
const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
|
|
90
|
+
const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
|
|
91
|
+
// Mark the two as referenced so the LRU eviction spares them (they get a
|
|
92
|
+
// higher last_referenced than the un-referenced seeds).
|
|
93
|
+
assert.ok(referenceMemory(keep1, dir), "reference keep1");
|
|
94
|
+
assert.ok(referenceMemory(keep2, dir), "reference keep2");
|
|
95
|
+
// Insert 3 more — 3 over the cap across the inserts. The two referenced rows
|
|
96
|
+
// must survive; only un-referenced (oldest) seeds should be evicted.
|
|
97
|
+
addMemory({ content: "new-1", category: "note" }, null, dir);
|
|
98
|
+
addMemory({ content: "new-2", category: "note" }, null, dir);
|
|
99
|
+
addMemory({ content: "new-3", category: "note" }, null, dir);
|
|
100
|
+
const rows = listMemories(null, 1000, dir);
|
|
101
|
+
assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
|
|
102
|
+
assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
|
|
103
|
+
assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
|
|
104
|
+
assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
|
|
105
|
+
const seedRows = rows.filter((m) => /seed-/.test(m.content));
|
|
106
|
+
// 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
|
|
107
|
+
// must be un-referenced seeds — the referenced rows survived above.
|
|
108
|
+
assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
|
|
109
|
+
assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
|
|
110
|
+
} finally {
|
|
111
|
+
delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("S24: MEGACOMPACT_MEMORY_MAX_CHARS env override truncates content", () => {
|
|
116
|
+
process.env.MEGACOMPACT_MEMORY_MAX_CHARS = "50";
|
|
117
|
+
try {
|
|
118
|
+
const dir = join(baseTmp, "cap-env");
|
|
119
|
+
const id = addMemory({ content: "x".repeat(500), category: "note" }, null, dir);
|
|
120
|
+
const rows = listMemories(null, 50, dir);
|
|
121
|
+
const row = rows.find((m) => m.id === id);
|
|
122
|
+
assert.ok(row, "row present");
|
|
123
|
+
assert.equal(row!.content.length, 50 + "…[truncated]".length, "truncated to env cap + marker");
|
|
124
|
+
assert.ok(row!.content.endsWith("…[truncated]"), "marker appended");
|
|
125
|
+
} finally {
|
|
126
|
+
delete process.env.MEGACOMPACT_MEMORY_MAX_CHARS;
|
|
127
|
+
}
|
|
107
128
|
});
|
|
108
129
|
|
|
109
130
|
test("cleanup memops", () => {
|
package/src/memoryOps.ts
CHANGED
|
@@ -12,6 +12,24 @@ import {
|
|
|
12
12
|
removeMemory,
|
|
13
13
|
type MemoryRecord,
|
|
14
14
|
} from "./store/sqlite.js";
|
|
15
|
+
import { defaultEmbedder } from "./embedder.js";
|
|
16
|
+
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
|
+
}
|
|
15
33
|
|
|
16
34
|
/** Find a memory row whose content exactly matches (case-insensitive). */
|
|
17
35
|
function findByContent(memories: MemoryRecord[], content: string): MemoryRecord | undefined {
|
|
@@ -19,6 +37,23 @@ function findByContent(memories: MemoryRecord[], content: string): MemoryRecord
|
|
|
19
37
|
return memories.find((m) => m.content.trim().toLowerCase() === norm);
|
|
20
38
|
}
|
|
21
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Fire-and-forget mirror of a memory write into the cross-repo PGlite index
|
|
42
|
+
* (S24 optional memory-RAG mirror). Best-effort + non-fatal: never blocks the
|
|
43
|
+
* SQLite write and degrades to the same-repo scan if the index is disabled or
|
|
44
|
+
* fails. `repoId` is the resolved git root so the memory is findable from other
|
|
45
|
+
* repos; falls back to the state dir when outside git.
|
|
46
|
+
*/
|
|
47
|
+
function indexMemoryWrite(stateDir: string, memoryId: number, content: string): void {
|
|
48
|
+
const repoId = resolveRepoRootLocal(stateDir) ?? stateDir;
|
|
49
|
+
try {
|
|
50
|
+
const vec = defaultEmbedder().embed(content);
|
|
51
|
+
void upsertMemoryEmbedding(repoId, memoryId, content, vec);
|
|
52
|
+
} catch {
|
|
53
|
+
/* non-fatal — embedding/index failure must never break the SQLite write */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
22
57
|
/**
|
|
23
58
|
* Apply add/replace/remove ops to the memories table. Replaces are matched by
|
|
24
59
|
* existing content; removes by content. Idempotent: an add that already exists
|
|
@@ -32,7 +67,7 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
|
|
|
32
67
|
if (op.op === "add") {
|
|
33
68
|
// Skip if an identical memory already exists.
|
|
34
69
|
if (findByContent(existing, op.memory.content)) continue;
|
|
35
|
-
addMemory(
|
|
70
|
+
const id = addMemory(
|
|
36
71
|
{
|
|
37
72
|
kind: op.memory.category,
|
|
38
73
|
content: op.memory.content,
|
|
@@ -44,6 +79,8 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
|
|
|
44
79
|
repo,
|
|
45
80
|
stateDir,
|
|
46
81
|
);
|
|
82
|
+
// S24: mirror into the cross-repo index (fire-and-forget; non-fatal).
|
|
83
|
+
indexMemoryWrite(stateDir, id, op.memory.content);
|
|
47
84
|
} else if (op.op === "replace") {
|
|
48
85
|
const match = findByContent(existing, op.targetContent);
|
|
49
86
|
if (match) {
|
|
@@ -53,9 +90,11 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
|
|
|
53
90
|
category: op.memory.category,
|
|
54
91
|
sourceTurn: op.memory.sourceTurn,
|
|
55
92
|
}, stateDir);
|
|
93
|
+
// S24: re-mirror under the same memory id (fire-and-forget; non-fatal).
|
|
94
|
+
indexMemoryWrite(stateDir, match.id, op.memory.content);
|
|
56
95
|
} else {
|
|
57
96
|
// Target missing (e.g. earlier in-conversation contradiction) → add.
|
|
58
|
-
addMemory(
|
|
97
|
+
const id = addMemory(
|
|
59
98
|
{
|
|
60
99
|
kind: op.memory.category,
|
|
61
100
|
content: op.memory.content,
|
|
@@ -66,6 +105,7 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
|
|
|
66
105
|
repo,
|
|
67
106
|
stateDir,
|
|
68
107
|
);
|
|
108
|
+
indexMemoryWrite(stateDir, id, op.memory.content);
|
|
69
109
|
}
|
|
70
110
|
} else {
|
|
71
111
|
const match = findByContent(existing, op.content);
|
package/src/memoryRecall.test.ts
CHANGED
|
@@ -98,3 +98,61 @@ test("recallMemories: fresher reference beats older at equal similarity", async
|
|
|
98
98
|
test("cleanup memrec", () => {
|
|
99
99
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
// ---- S24: cross-repo memory recall (PGlite mirror) ---------------------------
|
|
103
|
+
test("recallMemoriesAndInline: surfaces a memory saved in ANOTHER repo via cross-repo index", async () => {
|
|
104
|
+
// Isolate the global PGlite index to a temp dir shared by both "repos".
|
|
105
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index");
|
|
106
|
+
const repoA = join(baseTmp, "repo-a");
|
|
107
|
+
const repoB = join(baseTmp, "repo-b");
|
|
108
|
+
try {
|
|
109
|
+
// repoA owns a decision about the store backend.
|
|
110
|
+
const { applyMemoryOps } = await import("./memoryOps.js");
|
|
111
|
+
await applyMemoryOps(
|
|
112
|
+
[{ op: "add", memory: { content: "we standardized on node:sqlite for the store backend", category: "decision", sourceTurn: 0 } }],
|
|
113
|
+
repoA,
|
|
114
|
+
);
|
|
115
|
+
// repoB is a fresh session with NO local memory about the store backend.
|
|
116
|
+
const { recallMemoriesAndInline } = await import("./recall.js");
|
|
117
|
+
const res = await recallMemoriesAndInline({
|
|
118
|
+
query: "what store backend do we use?",
|
|
119
|
+
stateDir: repoB,
|
|
120
|
+
limit: 5,
|
|
121
|
+
crossRepo: true,
|
|
122
|
+
crossRepoCosine: 0.3,
|
|
123
|
+
});
|
|
124
|
+
assert.ok(!res.empty, "cross-repo recall found the other repo's memory");
|
|
125
|
+
assert.ok(/node:sqlite/.test(res.block), "the node:sqlite decision was recalled from repo A");
|
|
126
|
+
assert.ok(res.report.some((r) => /from /.test(r)), "report labels the memory as cross-repo");
|
|
127
|
+
} finally {
|
|
128
|
+
const { closeMemoryIndex } = await import("./store/memoryIndex.js");
|
|
129
|
+
await closeMemoryIndex();
|
|
130
|
+
delete process.env.MEGACOMPACT_INDEX_DIR;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("recallMemoriesAndInline: cross-repo disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
|
|
135
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-off");
|
|
136
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|
|
137
|
+
const repoA = join(baseTmp, "repo-a2");
|
|
138
|
+
const repoB = join(baseTmp, "repo-b2");
|
|
139
|
+
try {
|
|
140
|
+
const { applyMemoryOps } = await import("./memoryOps.js");
|
|
141
|
+
await applyMemoryOps(
|
|
142
|
+
[{ op: "add", memory: { content: "we standardized on node:sqlite for the store backend", category: "decision", sourceTurn: 0 } }],
|
|
143
|
+
repoA,
|
|
144
|
+
);
|
|
145
|
+
const { recallMemoriesAndInline } = await import("./recall.js");
|
|
146
|
+
const res = await recallMemoriesAndInline({
|
|
147
|
+
query: "what store backend do we use?",
|
|
148
|
+
stateDir: repoB,
|
|
149
|
+
limit: 5,
|
|
150
|
+
crossRepo: true,
|
|
151
|
+
});
|
|
152
|
+
// Index disabled → no cross-repo hit; repoB has no local memory → empty.
|
|
153
|
+
assert.equal(res.empty, true, "cross-repo recall degrades to empty when disabled");
|
|
154
|
+
} finally {
|
|
155
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
156
|
+
delete process.env.MEGACOMPACT_INDEX_DIR;
|
|
157
|
+
}
|
|
158
|
+
});
|
package/src/memoryRecall.ts
CHANGED
|
@@ -81,3 +81,55 @@ export async function recallMemories(
|
|
|
81
81
|
}
|
|
82
82
|
return top;
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cross-repo memory recall (S24): augments the same-repo `recallMemories` with
|
|
87
|
+
* HNSW NN over the global PGlite `memory_index` (other repos' memories). Content
|
|
88
|
+
* is read inline from the index hit (the recall process can't open other repos'
|
|
89
|
+
* SQLite dirs), so no other-repo db access is required. Returns hits sorted by
|
|
90
|
+
* descending cosine, above `crossRepoCosine`. De-duped by content against
|
|
91
|
+
* `sameRepoContent` so we never surface a memory the same-repo scan already has.
|
|
92
|
+
* Non-fatal: any index failure returns []. Best-effort + PREVENT-PI-004 (local
|
|
93
|
+
* WASM only).
|
|
94
|
+
*/
|
|
95
|
+
export async function recallMemoriesCrossRepo(
|
|
96
|
+
query: string,
|
|
97
|
+
stateDir: string,
|
|
98
|
+
opts: RecallMemoriesOptions & { crossRepoCosine?: number; limit?: number } = {},
|
|
99
|
+
): Promise<Array<{ memory: MemoryRecord; score: number; repoId: string }>> {
|
|
100
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
101
|
+
const queryVec = embedder.embed(query);
|
|
102
|
+
const { searchMemoriesAsync } = await import("./store/memoryIndex.js");
|
|
103
|
+
const k = opts.limit ?? 5;
|
|
104
|
+
const floor = opts.crossRepoCosine ?? 0.3;
|
|
105
|
+
const hits = await searchMemoriesAsync(queryVec, { k });
|
|
106
|
+
if (!hits.length) return [];
|
|
107
|
+
// Mark same-repo content as already-covered so we don't duplicate it.
|
|
108
|
+
const sameRepo = new Set(
|
|
109
|
+
listMemories(opts.repo ?? null, 1000, stateDir).map((m) => m.content.trim().toLowerCase()),
|
|
110
|
+
);
|
|
111
|
+
const out: Array<{ memory: MemoryRecord; score: number; repoId: string }> = [];
|
|
112
|
+
for (const h of hits) {
|
|
113
|
+
if (h.score < floor) continue;
|
|
114
|
+
if (sameRepo.has(h.content.trim().toLowerCase())) continue;
|
|
115
|
+
out.push({
|
|
116
|
+
memory: {
|
|
117
|
+
id: h.memoryId,
|
|
118
|
+
repo: h.repoId,
|
|
119
|
+
kind: "note",
|
|
120
|
+
content: h.content,
|
|
121
|
+
tags: [],
|
|
122
|
+
createdAt: 0,
|
|
123
|
+
lastRecalledAt: null,
|
|
124
|
+
category: null,
|
|
125
|
+
target: null,
|
|
126
|
+
lastReferenced: null,
|
|
127
|
+
sourceTurn: null,
|
|
128
|
+
} as MemoryRecord,
|
|
129
|
+
score: h.score,
|
|
130
|
+
repoId: h.repoId,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
out.sort((a, b) => b.score - a.score);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/src/recall.ts
CHANGED
|
@@ -159,6 +159,10 @@ export interface MemoryRecallInjectOptions {
|
|
|
159
159
|
recallMaxTokens?: number;
|
|
160
160
|
/** Cosine threshold; default 0.2. */
|
|
161
161
|
minSimilarity?: number;
|
|
162
|
+
/** When true, augment same-repo recall with cross-repo PGlite NN (S24). */
|
|
163
|
+
crossRepo?: boolean;
|
|
164
|
+
/** Stricter cosine floor for cross-repo memory hits (S24). Default 0.3. */
|
|
165
|
+
crossRepoCosine?: number;
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
/** Format one memory hit for the recall block. Category + score for traceability. */
|
|
@@ -184,28 +188,49 @@ export async function recallMemoriesAndInline(
|
|
|
184
188
|
): Promise<{ empty: boolean; block: string; report: string[] }> {
|
|
185
189
|
const limit = opts.limit ?? 5;
|
|
186
190
|
const maxTokens = opts.recallMaxTokens ?? 0;
|
|
187
|
-
const { recallMemories } = await import("./memoryRecall.js");
|
|
191
|
+
const { recallMemories, recallMemoriesCrossRepo } = await import("./memoryRecall.js");
|
|
188
192
|
const hits = await recallMemories(opts.query, opts.stateDir, {
|
|
189
193
|
topK: limit,
|
|
190
194
|
minSimilarity: opts.minSimilarity ?? 0.2,
|
|
191
195
|
});
|
|
192
|
-
|
|
196
|
+
|
|
197
|
+
// S24 cross-repo augmentation: if same-repo recall is thin, pull additional
|
|
198
|
+
// memories from OTHER repos via the PGlite HNSW index. Non-fatal: a failure
|
|
199
|
+
// degrades to the same-repo hits only.
|
|
200
|
+
const crossHits: Array<{ memory: any; score: number; repoId: string }> = [];
|
|
201
|
+
if (opts.crossRepo && hits.length < limit) {
|
|
202
|
+
try {
|
|
203
|
+
const x = await recallMemoriesCrossRepo(opts.query, opts.stateDir, {
|
|
204
|
+
repo: null,
|
|
205
|
+
limit: limit - hits.length,
|
|
206
|
+
crossRepoCosine: opts.crossRepoCosine ?? 0.3,
|
|
207
|
+
});
|
|
208
|
+
for (const h of x) crossHits.push(h);
|
|
209
|
+
} catch {
|
|
210
|
+
/* non-fatal — cross-repo failure → same-repo only */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (hits.length === 0 && crossHits.length === 0) return { empty: true, block: "", report: [] };
|
|
193
214
|
|
|
194
215
|
// Same incremental token cap pattern as checkpoint recall.
|
|
195
216
|
const parts: string[] = [];
|
|
196
217
|
const report: string[] = [];
|
|
197
218
|
let blockTokens = 0;
|
|
198
|
-
|
|
199
|
-
const part = formatMemoryRecallBlock([
|
|
200
|
-
{ content: h.memory.content, category: h.memory.category, score: h.score },
|
|
201
|
-
]);
|
|
219
|
+
const pushHit = (content: string, category: string | null, score: number, label: string) => {
|
|
220
|
+
const part = formatMemoryRecallBlock([{ content, category, score }]);
|
|
202
221
|
const partTokens = estimateBlockTokens(part);
|
|
203
|
-
if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
|
|
222
|
+
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) return false;
|
|
204
223
|
parts.push(part);
|
|
205
|
-
report.push(
|
|
206
|
-
` • memory#${h.memory.id} (${(h.score * 100).toFixed(0)}%): ${h.memory.content.slice(0, 60).replace(/\n/g, " ")}…`,
|
|
207
|
-
);
|
|
224
|
+
report.push(` • ${label} (${(score * 100).toFixed(0)}%): ${content.slice(0, 60).replace(/\n/g, " ")}…`);
|
|
208
225
|
blockTokens += partTokens;
|
|
226
|
+
return true;
|
|
227
|
+
};
|
|
228
|
+
for (const h of hits) {
|
|
229
|
+
if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id}`)) break;
|
|
230
|
+
}
|
|
231
|
+
for (const h of crossHits) {
|
|
232
|
+
const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
|
|
233
|
+
if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`)) break;
|
|
209
234
|
}
|
|
210
235
|
return { empty: parts.length === 0, block: parts.join("\n"), report };
|
|
211
236
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { defaultEmbedder } from "../embedder.js";
|
|
7
|
+
import {
|
|
8
|
+
upsertMemoryEmbedding,
|
|
9
|
+
searchMemoriesAsync,
|
|
10
|
+
initMemoryIndex,
|
|
11
|
+
closeMemoryIndex,
|
|
12
|
+
isMemoryIndexDisabled,
|
|
13
|
+
} from "./memoryIndex.js";
|
|
14
|
+
|
|
15
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memidx-"));
|
|
16
|
+
|
|
17
|
+
test("memoryIndex: disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
|
|
18
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|
|
19
|
+
try {
|
|
20
|
+
assert.equal(isMemoryIndexDisabled(), true, "kill-switch honored");
|
|
21
|
+
const hits = await searchMemoriesAsync(defaultEmbedder().embed("anything"), { k: 3 });
|
|
22
|
+
assert.deepEqual(hits, [], "search returns [] when disabled");
|
|
23
|
+
} finally {
|
|
24
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("memoryIndex: cross-repo upsert + NN search returns the right repo's memory", async () => {
|
|
29
|
+
// Isolate the global PGlite dir so concurrent test runs don't collide.
|
|
30
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
|
|
31
|
+
const repoA = "/tmp/repo-a";
|
|
32
|
+
const repoB = "/tmp/repo-b";
|
|
33
|
+
try {
|
|
34
|
+
await initMemoryIndex();
|
|
35
|
+
// Two memories in different repos, with clearly distinct content so their
|
|
36
|
+
// trigram embeddings separate.
|
|
37
|
+
const vecA = defaultEmbedder().embed("We standardized on node:sqlite for the store backend");
|
|
38
|
+
const vecB = defaultEmbedder().embed("The deployment target is a raspberry pi in the closet");
|
|
39
|
+
await upsertMemoryEmbedding(repoA, 1, "We standardized on node:sqlite for the store backend", vecA);
|
|
40
|
+
await upsertMemoryEmbedding(repoB, 7, "The deployment target is a raspberry pi in the closet", vecB);
|
|
41
|
+
|
|
42
|
+
// Query close to A's content → top hit should be A's memory, not B's.
|
|
43
|
+
const q = defaultEmbedder().embed("standardized node:sqlite store backend choice");
|
|
44
|
+
const hits = await searchMemoriesAsync(q, { k: 3 });
|
|
45
|
+
assert.ok(hits.length >= 1, "at least one hit");
|
|
46
|
+
assert.equal(hits[0].repoId, repoA, "nearest neighbor is repo A");
|
|
47
|
+
assert.equal(hits[0].memoryId, 1, "correct memory id");
|
|
48
|
+
assert.ok(hits[0].score > 0.5, "high cosine for the matching memory");
|
|
49
|
+
|
|
50
|
+
// Scope to repoB only → A must not appear.
|
|
51
|
+
const scoped = await searchMemoriesAsync(q, { k: 3, repoId: repoB });
|
|
52
|
+
assert.ok(scoped.every((h) => h.repoId === repoB), "scoped search stays within repoB");
|
|
53
|
+
} finally {
|
|
54
|
+
await closeMemoryIndex();
|
|
55
|
+
delete process.env.MEGACOMPACT_INDEX_DIR;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("memoryIndex: cleanup", () => {
|
|
60
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
61
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memoryIndex.ts — cross-repo async vector index for durable memories (S24).
|
|
3
|
+
*
|
|
4
|
+
* A REDUNDANT, additive, ASYNC index layered over the authoritative node:sqlite
|
|
5
|
+
* `memories` table. The same-repo linear cosine scan over the in-repo memories
|
|
6
|
+
* (src/memoryRecall.ts) stays the DEFAULT recall path; this global PGlite index
|
|
7
|
+
* exists only to provide real cross-repo nearest-neighbor memory recall — so a
|
|
8
|
+
* decision you saved in repo A can be inlined as RAG context when you start a
|
|
9
|
+
* session in repo B. It is best-effort and non-fatal: any init/write failure
|
|
10
|
+
* degrades to the same-repo scan and must NEVER break memory write, recall, or
|
|
11
|
+
* extension load.
|
|
12
|
+
*
|
|
13
|
+
* PREVENT-PI-004: PGlite is WASM Postgres — fully local, zero network. Memory
|
|
14
|
+
* remains AUTHORITATIVE in SQLite; this index only holds (repo_id, memory_id,
|
|
15
|
+
* content, embedding) for NN lookup and is rebuilt from SQLite at any time.
|
|
16
|
+
*
|
|
17
|
+
* Topology mirrors vectorIndex.ts (Slice 2): ONE global PGlite DB, `repo_id` is
|
|
18
|
+
* a first-class column. `searchMemoriesAsync(q, k, {repoId?})` → omit repoId for
|
|
19
|
+
* cross-repo NN, pass repoId to scope to a single repo. Hit content is stored
|
|
20
|
+
* inline because the recall process cannot open every other repo's SQLite dir.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
26
|
+
|
|
27
|
+
// PGlite + pgvector are script-free WASM (no native build) → survive pi's
|
|
28
|
+
// install-script block. Imported lazily so a missing/broken package degrades
|
|
29
|
+
// gracefully instead of crashing module load.
|
|
30
|
+
import { PGlite, type PGlite as PGliteInstance } from "@electric-sql/pglite";
|
|
31
|
+
import { vector } from "@electric-sql/pglite-pgvector";
|
|
32
|
+
|
|
33
|
+
/** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
|
|
34
|
+
export const MEMORY_INDEX_DIM = 512;
|
|
35
|
+
|
|
36
|
+
/** A single cross-repo memory hit returned by the async index. */
|
|
37
|
+
export interface MemoryIndexHit {
|
|
38
|
+
repoId: string;
|
|
39
|
+
memoryId: number;
|
|
40
|
+
/** Inline content so recall can read it without opening the other repo's db. */
|
|
41
|
+
content: string;
|
|
42
|
+
/** Cosine similarity in [0,1] (1 = identical). */
|
|
43
|
+
score: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let db: PGliteInstance | undefined;
|
|
47
|
+
let initPromise: Promise<PGliteInstance | undefined> | undefined;
|
|
48
|
+
let disabled = false;
|
|
49
|
+
let warned = false;
|
|
50
|
+
|
|
51
|
+
function indexDir(): string {
|
|
52
|
+
const override = process.env.MEGACOMPACT_INDEX_DIR;
|
|
53
|
+
if (override && override.trim() !== "") return join(override, "memory");
|
|
54
|
+
try {
|
|
55
|
+
return join(homedir(), ".pi", "mega-compact-vector", "memory");
|
|
56
|
+
} catch {
|
|
57
|
+
return join("/tmp", ".mega-compact-vector", "memory");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function logWarn(msg: string): void {
|
|
62
|
+
// Never throw — degradation is the whole point. One warning per process.
|
|
63
|
+
if (warned) return;
|
|
64
|
+
warned = true;
|
|
65
|
+
try {
|
|
66
|
+
console.warn(`[mega-compact:memoryIndex] ${msg} (falling back to same-repo scan)`);
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Honor the emergency kill-switch (shared with the checkpoint index). */
|
|
73
|
+
export function isMemoryIndexDisabled(): boolean {
|
|
74
|
+
return (
|
|
75
|
+
disabled ||
|
|
76
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED === "true" ||
|
|
77
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED === "1"
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Lazily open + schema-init the global PGlite DB. Idempotent and safe to call
|
|
83
|
+
* from many places. Returns undefined when disabled/unavailable so callers can
|
|
84
|
+
* fall back to the synchronous scan. Never throws.
|
|
85
|
+
*/
|
|
86
|
+
export function initMemoryIndex(): Promise<PGliteInstance | undefined> {
|
|
87
|
+
if (isMemoryIndexDisabled()) return Promise.resolve(undefined);
|
|
88
|
+
if (db) return Promise.resolve(db);
|
|
89
|
+
if (initPromise) return initPromise;
|
|
90
|
+
initPromise = openPgLite(/* retryOnCorrupt */ true);
|
|
91
|
+
return initPromise;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level abort
|
|
96
|
+
* (typically from a corrupted/torn data dir) triggers a delete + one retry.
|
|
97
|
+
*/
|
|
98
|
+
async function openPgLite(
|
|
99
|
+
retryOnCorrupt: boolean,
|
|
100
|
+
): Promise<PGliteInstance | undefined> {
|
|
101
|
+
try {
|
|
102
|
+
const dir = indexDir();
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
const pg = await new PGlite({
|
|
105
|
+
dataDir: dir,
|
|
106
|
+
extensions: { vector },
|
|
107
|
+
});
|
|
108
|
+
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
109
|
+
await pg.exec(`
|
|
110
|
+
CREATE TABLE IF NOT EXISTS memory_index (
|
|
111
|
+
repo_id TEXT NOT NULL,
|
|
112
|
+
memory_id INTEGER NOT NULL,
|
|
113
|
+
content TEXT NOT NULL,
|
|
114
|
+
embedding vector(${MEMORY_INDEX_DIM}) NOT NULL,
|
|
115
|
+
PRIMARY KEY (repo_id, memory_id)
|
|
116
|
+
);
|
|
117
|
+
`);
|
|
118
|
+
await pg.exec(
|
|
119
|
+
"CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);",
|
|
120
|
+
);
|
|
121
|
+
db = pg;
|
|
122
|
+
return pg;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
125
|
+
if (retryOnCorrupt && (msg.includes("Aborted") || msg.includes("RuntimeError"))) {
|
|
126
|
+
try {
|
|
127
|
+
const dir = indexDir();
|
|
128
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
initPromise = undefined;
|
|
130
|
+
return openPgLite(/* retryOnCorrupt */ false);
|
|
131
|
+
} catch {
|
|
132
|
+
/* self-heal failed — fall through to disable */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
disabled = true;
|
|
136
|
+
logWarn(`init failed: ${msg}`);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toVectorLiteral(v: number[]): string {
|
|
142
|
+
const parts = v.map((x) => (Number.isFinite(x) ? x : 0));
|
|
143
|
+
return `[${parts.join(",")}]`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Best-effort upsert of one memory embedding into the global index.
|
|
148
|
+
* Dimension-mismatched vectors (e.g. a BYO embedder with dim ≠ 512) are skipped.
|
|
149
|
+
* Fire-and-forget: callers must NOT await this on the sync write path. Never
|
|
150
|
+
* throws. `content` is stored inline so cross-repo recall can read it directly.
|
|
151
|
+
*/
|
|
152
|
+
export async function upsertMemoryEmbedding(
|
|
153
|
+
repoId: string,
|
|
154
|
+
memoryId: number,
|
|
155
|
+
content: string,
|
|
156
|
+
embedding: number[],
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
if (isMemoryIndexDisabled()) return;
|
|
159
|
+
if (!embedding || embedding.length !== MEMORY_INDEX_DIM) return;
|
|
160
|
+
try {
|
|
161
|
+
const pg = await initMemoryIndex();
|
|
162
|
+
if (!pg) return;
|
|
163
|
+
const lit = toVectorLiteral(embedding);
|
|
164
|
+
await pg.query(
|
|
165
|
+
`INSERT INTO memory_index (repo_id, memory_id, content, embedding)
|
|
166
|
+
VALUES ($1, $2, $3, $4::vector)
|
|
167
|
+
ON CONFLICT (repo_id, memory_id)
|
|
168
|
+
DO UPDATE SET content = EXCLUDED.content, embedding = EXCLUDED.embedding;`,
|
|
169
|
+
[repoId, memoryId, content, lit],
|
|
170
|
+
);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
disabled = true;
|
|
173
|
+
logWarn(`upsert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface SearchMemoriesAsyncOpts {
|
|
178
|
+
/** When provided, scope the NN search to a single repo; omit for cross-repo. */
|
|
179
|
+
repoId?: string;
|
|
180
|
+
/** Max hits (default 5). */
|
|
181
|
+
k?: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Cross-repo (or single-repo) HNSW nearest-neighbor memory search. Returns hits
|
|
186
|
+
* sorted by descending similarity. Never throws — on any failure returns [].
|
|
187
|
+
*/
|
|
188
|
+
export async function searchMemoriesAsync(
|
|
189
|
+
query: number[],
|
|
190
|
+
opts: SearchMemoriesAsyncOpts = {},
|
|
191
|
+
): Promise<MemoryIndexHit[]> {
|
|
192
|
+
if (isMemoryIndexDisabled() || !query || query.length !== MEMORY_INDEX_DIM) return [];
|
|
193
|
+
const k = opts.k ?? 5;
|
|
194
|
+
const repoId = opts.repoId;
|
|
195
|
+
try {
|
|
196
|
+
const pg = await initMemoryIndex();
|
|
197
|
+
if (!pg) return [];
|
|
198
|
+
const lit = toVectorLiteral(query);
|
|
199
|
+
const params: unknown[] = [lit, k];
|
|
200
|
+
let sql =
|
|
201
|
+
"SELECT repo_id, memory_id, content, 1 - (embedding <=> $1::vector) AS score " +
|
|
202
|
+
"FROM memory_index";
|
|
203
|
+
if (repoId) {
|
|
204
|
+
sql += " WHERE repo_id = $3";
|
|
205
|
+
params.push(repoId);
|
|
206
|
+
}
|
|
207
|
+
sql += " ORDER BY embedding <=> $1::vector LIMIT $2";
|
|
208
|
+
const res = await pg.query(sql, params);
|
|
209
|
+
return res.rows.map((r: any) => ({
|
|
210
|
+
repoId: r.repo_id as string,
|
|
211
|
+
memoryId: Number(r.memory_id),
|
|
212
|
+
content: r.content as string,
|
|
213
|
+
score: r.score as number,
|
|
214
|
+
}));
|
|
215
|
+
} catch (err) {
|
|
216
|
+
disabled = true;
|
|
217
|
+
logWarn(`search failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Close the index (test teardown / shutdown). Safe to call when unopened. */
|
|
223
|
+
export async function closeMemoryIndex(): Promise<void> {
|
|
224
|
+
if (db) {
|
|
225
|
+
try {
|
|
226
|
+
await db.close();
|
|
227
|
+
} catch {
|
|
228
|
+
/* ignore */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
db = undefined;
|
|
232
|
+
initPromise = undefined;
|
|
233
|
+
disabled = false;
|
|
234
|
+
warned = false;
|
|
235
|
+
}
|