pi-mega-compact 0.6.1 → 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/dist/extensions/mega-conflict-cmds.js +17 -0
- package/dist/extensions/mega-events.js +102 -6
- 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/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/extensions/mega-conflict-cmds.ts +15 -0
- package/extensions/mega-events.ts +96 -6
- 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.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
|
@@ -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
|
+
}
|