pi-mega-compact 0.6.1 → 0.6.3

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/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
- if (hits.length === 0) return { empty: true, block: "", report: [] };
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
- for (const h of hits) {
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) break;
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,269 @@
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. The VALUE import is LAZY (dynamic import inside
29
+ // openPgLite) so a missing/broken package degrades to the same-repo scan instead
30
+ // of crashing module load. A static top-level `import { PGlite }` would throw
31
+ // "Cannot find module" at pi startup and take down the whole extension. The
32
+ // `import type` below is erased at compile time and emits NO runtime load.
33
+ import type { PGlite as PGliteInstance, Extension } from "@electric-sql/pglite";
34
+
35
+ /** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
36
+ export const MEMORY_INDEX_DIM = 512;
37
+
38
+ /** A single cross-repo memory hit returned by the async index. */
39
+ export interface MemoryIndexHit {
40
+ repoId: string;
41
+ memoryId: number;
42
+ /** Inline content so recall can read it without opening the other repo's db. */
43
+ content: string;
44
+ /** Cosine similarity in [0,1] (1 = identical). */
45
+ score: number;
46
+ }
47
+
48
+ let db: PGliteInstance | undefined;
49
+ let initPromise: Promise<PGliteInstance | undefined> | undefined;
50
+ let disabled = false;
51
+ let warned = false;
52
+ /** Lazily-loaded PGlite module + pgvector extension (see loadPgLite). */
53
+ let pgliteMod: {
54
+ PGlite: typeof import("@electric-sql/pglite")["PGlite"];
55
+ vector: Extension;
56
+ } | undefined;
57
+ let pgliteLoadFailed = false;
58
+
59
+ function indexDir(): string {
60
+ const override = process.env.MEGACOMPACT_INDEX_DIR;
61
+ if (override && override.trim() !== "") return join(override, "memory");
62
+ try {
63
+ return join(homedir(), ".pi", "mega-compact-vector", "memory");
64
+ } catch {
65
+ return join("/tmp", ".mega-compact-vector", "memory");
66
+ }
67
+ }
68
+
69
+ function logWarn(msg: string): void {
70
+ // Never throw — degradation is the whole point. One warning per process.
71
+ if (warned) return;
72
+ warned = true;
73
+ try {
74
+ console.warn(`[mega-compact:memoryIndex] ${msg} (falling back to same-repo scan)`);
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ }
79
+
80
+ /** Honor the emergency kill-switch (shared with the checkpoint index). */
81
+ export function isMemoryIndexDisabled(): boolean {
82
+ return (
83
+ disabled ||
84
+ process.env.MEGACOMPACT_PGLITE_DISABLED === "true" ||
85
+ process.env.MEGACOMPACT_PGLITE_DISABLED === "1"
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Lazily open + schema-init the global PGlite DB. Idempotent and safe to call
91
+ * from many places. Returns undefined when disabled/unavailable so callers can
92
+ * fall back to the synchronous scan. Never throws.
93
+ */
94
+ export function initMemoryIndex(): Promise<PGliteInstance | undefined> {
95
+ if (isMemoryIndexDisabled()) return Promise.resolve(undefined);
96
+ if (db) return Promise.resolve(db);
97
+ if (initPromise) return initPromise;
98
+ initPromise = openPgLite(/* retryOnCorrupt */ true);
99
+ return initPromise;
100
+ }
101
+
102
+ /**
103
+ * Lazily load the PGlite module + pgvector extension via dynamic import. Caches
104
+ * success and permanent failure. Returns undefined (once, then forever) when the
105
+ * package is missing/broken so callers fall back to the same-repo scan. Never throws.
106
+ */
107
+ async function loadPgLite(): Promise<
108
+ { PGlite: typeof import("@electric-sql/pglite")["PGlite"]; vector: Extension } | undefined
109
+ > {
110
+ if (pgliteMod) return pgliteMod;
111
+ if (pgliteLoadFailed) return undefined;
112
+ try {
113
+ const [pglitePkg, pgvectorPkg] = await Promise.all([
114
+ import("@electric-sql/pglite"),
115
+ import("@electric-sql/pglite-pgvector"),
116
+ ]);
117
+ pgliteMod = { PGlite: pglitePkg.PGlite, vector: pgvectorPkg.vector };
118
+ return pgliteMod;
119
+ } catch (err) {
120
+ pgliteLoadFailed = true;
121
+ logWarn(`package unavailable: ${err instanceof Error ? err.message : String(err)}`);
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level abort
128
+ * (typically from a corrupted/torn data dir) triggers a delete + one retry.
129
+ */
130
+ async function openPgLite(
131
+ retryOnCorrupt: boolean,
132
+ ): Promise<PGliteInstance | undefined> {
133
+ try {
134
+ const mod = await loadPgLite();
135
+ if (!mod) return undefined;
136
+ const dir = indexDir();
137
+ mkdirSync(dir, { recursive: true });
138
+ const pg = await new mod.PGlite({
139
+ dataDir: dir,
140
+ extensions: { vector: mod.vector },
141
+ });
142
+ await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
143
+ await pg.exec(`
144
+ CREATE TABLE IF NOT EXISTS memory_index (
145
+ repo_id TEXT NOT NULL,
146
+ memory_id INTEGER NOT NULL,
147
+ content TEXT NOT NULL,
148
+ embedding vector(${MEMORY_INDEX_DIM}) NOT NULL,
149
+ PRIMARY KEY (repo_id, memory_id)
150
+ );
151
+ `);
152
+ await pg.exec(
153
+ "CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);",
154
+ );
155
+ db = pg;
156
+ return pg;
157
+ } catch (err) {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ if (retryOnCorrupt && (msg.includes("Aborted") || msg.includes("RuntimeError"))) {
160
+ try {
161
+ const dir = indexDir();
162
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
163
+ initPromise = undefined;
164
+ return openPgLite(/* retryOnCorrupt */ false);
165
+ } catch {
166
+ /* self-heal failed — fall through to disable */
167
+ }
168
+ }
169
+ disabled = true;
170
+ logWarn(`init failed: ${msg}`);
171
+ return undefined;
172
+ }
173
+ }
174
+
175
+ function toVectorLiteral(v: number[]): string {
176
+ const parts = v.map((x) => (Number.isFinite(x) ? x : 0));
177
+ return `[${parts.join(",")}]`;
178
+ }
179
+
180
+ /**
181
+ * Best-effort upsert of one memory embedding into the global index.
182
+ * Dimension-mismatched vectors (e.g. a BYO embedder with dim ≠ 512) are skipped.
183
+ * Fire-and-forget: callers must NOT await this on the sync write path. Never
184
+ * throws. `content` is stored inline so cross-repo recall can read it directly.
185
+ */
186
+ export async function upsertMemoryEmbedding(
187
+ repoId: string,
188
+ memoryId: number,
189
+ content: string,
190
+ embedding: number[],
191
+ ): Promise<void> {
192
+ if (isMemoryIndexDisabled()) return;
193
+ if (!embedding || embedding.length !== MEMORY_INDEX_DIM) return;
194
+ try {
195
+ const pg = await initMemoryIndex();
196
+ if (!pg) return;
197
+ const lit = toVectorLiteral(embedding);
198
+ await pg.query(
199
+ `INSERT INTO memory_index (repo_id, memory_id, content, embedding)
200
+ VALUES ($1, $2, $3, $4::vector)
201
+ ON CONFLICT (repo_id, memory_id)
202
+ DO UPDATE SET content = EXCLUDED.content, embedding = EXCLUDED.embedding;`,
203
+ [repoId, memoryId, content, lit],
204
+ );
205
+ } catch (err) {
206
+ disabled = true;
207
+ logWarn(`upsert failed: ${err instanceof Error ? err.message : String(err)}`);
208
+ }
209
+ }
210
+
211
+ export interface SearchMemoriesAsyncOpts {
212
+ /** When provided, scope the NN search to a single repo; omit for cross-repo. */
213
+ repoId?: string;
214
+ /** Max hits (default 5). */
215
+ k?: number;
216
+ }
217
+
218
+ /**
219
+ * Cross-repo (or single-repo) HNSW nearest-neighbor memory search. Returns hits
220
+ * sorted by descending similarity. Never throws — on any failure returns [].
221
+ */
222
+ export async function searchMemoriesAsync(
223
+ query: number[],
224
+ opts: SearchMemoriesAsyncOpts = {},
225
+ ): Promise<MemoryIndexHit[]> {
226
+ if (isMemoryIndexDisabled() || !query || query.length !== MEMORY_INDEX_DIM) return [];
227
+ const k = opts.k ?? 5;
228
+ const repoId = opts.repoId;
229
+ try {
230
+ const pg = await initMemoryIndex();
231
+ if (!pg) return [];
232
+ const lit = toVectorLiteral(query);
233
+ const params: unknown[] = [lit, k];
234
+ let sql =
235
+ "SELECT repo_id, memory_id, content, 1 - (embedding <=> $1::vector) AS score " +
236
+ "FROM memory_index";
237
+ if (repoId) {
238
+ sql += " WHERE repo_id = $3";
239
+ params.push(repoId);
240
+ }
241
+ sql += " ORDER BY embedding <=> $1::vector LIMIT $2";
242
+ const res = await pg.query(sql, params);
243
+ return res.rows.map((r: any) => ({
244
+ repoId: r.repo_id as string,
245
+ memoryId: Number(r.memory_id),
246
+ content: r.content as string,
247
+ score: r.score as number,
248
+ }));
249
+ } catch (err) {
250
+ disabled = true;
251
+ logWarn(`search failed: ${err instanceof Error ? err.message : String(err)}`);
252
+ return [];
253
+ }
254
+ }
255
+
256
+ /** Close the index (test teardown / shutdown). Safe to call when unopened. */
257
+ export async function closeMemoryIndex(): Promise<void> {
258
+ if (db) {
259
+ try {
260
+ await db.close();
261
+ } catch {
262
+ /* ignore */
263
+ }
264
+ }
265
+ db = undefined;
266
+ initPromise = undefined;
267
+ disabled = false;
268
+ warned = false;
269
+ }
@@ -21,10 +21,12 @@ import { join } from "node:path";
21
21
  import { mkdirSync, rmSync, existsSync } from "node:fs";
22
22
 
23
23
  // PGlite + pgvector are script-free WASM (no native build) → survive pi's
24
- // install-script block. Imported lazily so a missing/broken package degrades
25
- // gracefully instead of crashing module load.
26
- import { PGlite, type PGlite as PGliteInstance } from "@electric-sql/pglite";
27
- import { vector } from "@electric-sql/pglite-pgvector";
24
+ // install-script block. The VALUE import is LAZY (dynamic import inside
25
+ // openPgLite) so a missing/broken package degrades to the sync scan instead of
26
+ // crashing module load. A static top-level `import { PGlite }` would throw
27
+ // "Cannot find module" at pi startup and take down the whole extension. The
28
+ // `import type` below is erased at compile time and emits NO runtime load.
29
+ import type { PGlite as PGliteInstance, Extension } from "@electric-sql/pglite";
28
30
 
29
31
  /** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
30
32
  export const EMBEDDING_DIM = 512;
@@ -42,6 +44,12 @@ let db: PGliteInstance | undefined;
42
44
  let initPromise: Promise<PGliteInstance | undefined> | undefined;
43
45
  let disabled = false;
44
46
  let warned = false;
47
+ /** Lazily-loaded PGlite module + pgvector extension (see loadPgLite). */
48
+ let pgliteMod: {
49
+ PGlite: typeof import("@electric-sql/pglite")["PGlite"];
50
+ vector: Extension;
51
+ } | undefined;
52
+ let pgliteLoadFailed = false;
45
53
 
46
54
  function indexDir(): string {
47
55
  const override = process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
@@ -86,6 +94,30 @@ export function initVectorIndex(): Promise<PGliteInstance | undefined> {
86
94
  return initPromise;
87
95
  }
88
96
 
97
+ /**
98
+ * Lazily load the PGlite module + pgvector extension via dynamic import. Caches
99
+ * success and permanent failure. Returns undefined (once, then forever) when the
100
+ * package is missing/broken so callers fall back to the sync scan. Never throws.
101
+ */
102
+ async function loadPgLite(): Promise<
103
+ { PGlite: typeof import("@electric-sql/pglite")["PGlite"]; vector: Extension } | undefined
104
+ > {
105
+ if (pgliteMod) return pgliteMod;
106
+ if (pgliteLoadFailed) return undefined;
107
+ try {
108
+ const [pglitePkg, pgvectorPkg] = await Promise.all([
109
+ import("@electric-sql/pglite"),
110
+ import("@electric-sql/pglite-pgvector"),
111
+ ]);
112
+ pgliteMod = { PGlite: pglitePkg.PGlite, vector: pgvectorPkg.vector };
113
+ return pgliteMod;
114
+ } catch (err) {
115
+ pgliteLoadFailed = true;
116
+ logWarn(`package unavailable: ${err instanceof Error ? err.message : String(err)}`);
117
+ return undefined;
118
+ }
119
+ }
120
+
89
121
  /**
90
122
  * Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level
91
123
  * abort (typically from a corrupted/torn data dir) triggers a delete + one
@@ -95,11 +127,13 @@ async function openPgLite(
95
127
  retryOnCorrupt: boolean,
96
128
  ): Promise<PGliteInstance | undefined> {
97
129
  try {
130
+ const mod = await loadPgLite();
131
+ if (!mod) return undefined;
98
132
  const dir = indexDir();
99
133
  mkdirSync(dir, { recursive: true });
100
- const pg = await new PGlite({
134
+ const pg = await new mod.PGlite({
101
135
  dataDir: dir,
102
- extensions: { vector },
136
+ extensions: { vector: mod.vector },
103
137
  });
104
138
  await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
105
139
  await pg.exec(`