pi-mega-compact 0.4.24 → 0.4.26
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-pipeline.js +19 -0
- package/dist/extensions/openclaw-mega-compact.js +291 -0
- package/dist/src/minilm.js +92 -0
- package/dist/src/recall.js +55 -0
- package/dist/src/store/sqlite.js +8 -0
- package/dist/src/store/vectorIndex.js +236 -0
- package/dist/src/store/vectorIndex.test.js +99 -0
- package/dist/src/vectorStore.js +60 -1
- package/dist/src/wordpiece.js +129 -0
- package/extensions/mega-pipeline.ts +24 -0
- package/package.json +3 -1
- package/src/recall.ts +63 -0
- package/src/store/sqlite.ts +13 -0
- package/src/store/vectorIndex.test.ts +116 -0
- package/src/store/vectorIndex.ts +273 -0
- package/src/vectorStore.ts +70 -0
package/src/store/sqlite.ts
CHANGED
|
@@ -872,6 +872,19 @@ export function hasCheckpoint(sessionId: string, checkpointId: string, stateDir:
|
|
|
872
872
|
return row !== undefined;
|
|
873
873
|
}
|
|
874
874
|
|
|
875
|
+
/** Fetch a single checkpoint by (session, id), or undefined if absent. */
|
|
876
|
+
export function getCheckpoint(
|
|
877
|
+
sessionId: string,
|
|
878
|
+
checkpointId: string,
|
|
879
|
+
stateDir: string = getStateDir(),
|
|
880
|
+
): StoredCheckpoint | undefined {
|
|
881
|
+
const db = openStore(stateDir);
|
|
882
|
+
const row = db
|
|
883
|
+
.prepare("SELECT * FROM context_chunks WHERE session_id = ? AND id = ? LIMIT 1")
|
|
884
|
+
.get(normalizeSessionId(sessionId), checkpointId) as any;
|
|
885
|
+
return row ? rowToCheckpoint(row) : undefined;
|
|
886
|
+
}
|
|
887
|
+
|
|
875
888
|
/** Mark a checkpoint's dedup_status (e.g. 'removed' by SemDeDup). */
|
|
876
889
|
export function setDedupStatus(
|
|
877
890
|
checkpointId: string,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vectorIndex.test.ts — Slice 2 async PGlite/HNSW vector index.
|
|
3
|
+
*
|
|
4
|
+
* Proves: cross-repo nearest-neighbor recall, repoId scoping, the dimension
|
|
5
|
+
* guard (non-512 vectors skipped, never corrupt the index), and graceful
|
|
6
|
+
* degradation when the index is disabled (kill-switch) — all without touching
|
|
7
|
+
* the synchronous node:sqlite store.
|
|
8
|
+
*
|
|
9
|
+
* The index is a WASM Postgres (PGlite) — fully local, zero network
|
|
10
|
+
* (PREVENT-PI-004). Each test isolates state via MEGACOMPACT_VECTOR_INDEX_DIR.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
EMBEDDING_DIM,
|
|
20
|
+
initVectorIndex,
|
|
21
|
+
upsertEmbedding,
|
|
22
|
+
searchAsync,
|
|
23
|
+
closeVectorIndex,
|
|
24
|
+
isVectorIndexDisabled,
|
|
25
|
+
} from "./vectorIndex.js";
|
|
26
|
+
|
|
27
|
+
/** A 512-dim unit-ish vector with a single spike at `idx` (deterministic NN). */
|
|
28
|
+
function spikeVec(idx: number, magnitude = 1): number[] {
|
|
29
|
+
const v = new Array<number>(EMBEDDING_DIM).fill(0);
|
|
30
|
+
v[idx % EMBEDDING_DIM] = magnitude;
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isolateIndexDir(): string {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "mc-vecidx-"));
|
|
36
|
+
process.env.MEGACOMPACT_VECTOR_INDEX_DIR = dir;
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("cross-repo HNSW nearest-neighbor recall across repos + repoId scoping", async () => {
|
|
41
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
42
|
+
const dir = isolateIndexDir();
|
|
43
|
+
try {
|
|
44
|
+
await closeVectorIndex(); // ensure a fresh singleton for this dir
|
|
45
|
+
const pg = await initVectorIndex();
|
|
46
|
+
assert.ok(pg, "index should initialize (PGlite WASM available)");
|
|
47
|
+
|
|
48
|
+
// repoA: two checkpoints; repoB: one checkpoint. Distinct spike directions.
|
|
49
|
+
await upsertEmbedding("/repoA/.pi/mega-compact", "sessA", "chkpt_001", spikeVec(0));
|
|
50
|
+
await upsertEmbedding("/repoA/.pi/mega-compact", "sessA", "chkpt_002", spikeVec(5));
|
|
51
|
+
await upsertEmbedding("/repoB/.pi/mega-compact", "sessB", "chkpt_001", spikeVec(0));
|
|
52
|
+
|
|
53
|
+
// Cross-repo query near spike(0): nearest are the two spike(0) rows, one per repo.
|
|
54
|
+
const cross = await searchAsync(spikeVec(0), { k: 2 });
|
|
55
|
+
assert.equal(cross.length, 2, "cross-repo returns two nearest");
|
|
56
|
+
const repos = new Set(cross.map((h) => h.repoId));
|
|
57
|
+
assert.ok(repos.has("/repoA/.pi/mega-compact"), "hit from repoA");
|
|
58
|
+
assert.ok(repos.has("/repoB/.pi/mega-compact"), "hit from repoB");
|
|
59
|
+
assert.ok(cross[0].score > 0.99, "top hit is near-identical (cosine ~1)");
|
|
60
|
+
|
|
61
|
+
// Scoped to repoA only: excludes repoB even though repoB has an identical vec.
|
|
62
|
+
const scoped = await searchAsync(spikeVec(0), { k: 5, repoId: "/repoA/.pi/mega-compact" });
|
|
63
|
+
assert.ok(scoped.length >= 1, "scoped returns repoA hits");
|
|
64
|
+
assert.ok(
|
|
65
|
+
scoped.every((h) => h.repoId === "/repoA/.pi/mega-compact"),
|
|
66
|
+
"repoId filter excludes other repos",
|
|
67
|
+
);
|
|
68
|
+
} finally {
|
|
69
|
+
await closeVectorIndex();
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("dimension guard: non-512 vectors are skipped, never corrupt the index", async () => {
|
|
76
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
77
|
+
const dir = isolateIndexDir();
|
|
78
|
+
try {
|
|
79
|
+
await closeVectorIndex();
|
|
80
|
+
await initVectorIndex();
|
|
81
|
+
// Wrong-dimension vector (BYO embedder mismatch) must be silently skipped.
|
|
82
|
+
await upsertEmbedding("/repoC/.pi/mega-compact", "sessC", "chkpt_001", [1, 2, 3]);
|
|
83
|
+
const hits = await searchAsync(spikeVec(0), { k: 5 });
|
|
84
|
+
assert.equal(hits.length, 0, "no rows stored for a mismatched-dim vector");
|
|
85
|
+
|
|
86
|
+
// A correct-dim vector still stores fine afterward (index not corrupted).
|
|
87
|
+
await upsertEmbedding("/repoC/.pi/mega-compact", "sessC", "chkpt_002", spikeVec(3));
|
|
88
|
+
const ok = await searchAsync(spikeVec(3), { k: 1 });
|
|
89
|
+
assert.equal(ok.length, 1, "valid vector stored after a skipped one");
|
|
90
|
+
assert.equal(ok[0].checkpointId, "chkpt_002");
|
|
91
|
+
} finally {
|
|
92
|
+
await closeVectorIndex();
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("kill-switch: MEGACOMPACT_PGLITE_DISABLED disables the index gracefully", async () => {
|
|
99
|
+
const dir = isolateIndexDir();
|
|
100
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|
|
101
|
+
try {
|
|
102
|
+
await closeVectorIndex();
|
|
103
|
+
assert.equal(isVectorIndexDisabled(), true, "kill-switch reported disabled");
|
|
104
|
+
const pg = await initVectorIndex();
|
|
105
|
+
assert.equal(pg, undefined, "init returns undefined when disabled");
|
|
106
|
+
// Upsert + search are no-ops that never throw and return empty.
|
|
107
|
+
await upsertEmbedding("/repoD/.pi/mega-compact", "sessD", "chkpt_001", spikeVec(0));
|
|
108
|
+
const hits = await searchAsync(spikeVec(0), { k: 3 });
|
|
109
|
+
assert.deepEqual(hits, [], "search returns [] when disabled");
|
|
110
|
+
} finally {
|
|
111
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
112
|
+
await closeVectorIndex();
|
|
113
|
+
rmSync(dir, { recursive: true, force: true });
|
|
114
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vectorIndex.ts — Slice 2 async vector index (PGlite/pgvector HNSW).
|
|
3
|
+
*
|
|
4
|
+
* A REDUNDANT, additive, ASYNC index layered over the synchronous node:sqlite
|
|
5
|
+
* store (which remains the authoritative source of truth). The sync linear
|
|
6
|
+
* cosine scan over `embedding_blob` stays the DEFAULT recall path; this index
|
|
7
|
+
* exists only to provide real cross-repo / cross-session HNSW nearest-neighbor
|
|
8
|
+
* recall. It is best-effort and non-fatal: any init/write failure degrades to
|
|
9
|
+
* the sync scan and must NEVER break add(), compaction, or extension load.
|
|
10
|
+
*
|
|
11
|
+
* PREVENT-PI-004: PGlite is WASM Postgres — fully local, zero network.
|
|
12
|
+
*
|
|
13
|
+
* Index topology (decision 2026-07-15): ONE global PGlite DB, `repo_id` is a
|
|
14
|
+
* first-class column. `searchAsync(q, k, {repoId?})` → omit repoId for cross-repo
|
|
15
|
+
* NN, pass repoId to scope to a single repo. The sync store is per-repo (state
|
|
16
|
+
* dir); this global index is the thing that makes cross-repo recall possible.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
22
|
+
|
|
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";
|
|
28
|
+
|
|
29
|
+
/** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
|
|
30
|
+
export const EMBEDDING_DIM = 512;
|
|
31
|
+
|
|
32
|
+
/** A single recall hit returned by the async index. */
|
|
33
|
+
export interface VectorIndexHit {
|
|
34
|
+
repoId: string;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
checkpointId: string;
|
|
37
|
+
/** Cosine similarity in [0,1] (1 = identical). */
|
|
38
|
+
score: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let db: PGliteInstance | undefined;
|
|
42
|
+
let initPromise: Promise<PGliteInstance | undefined> | undefined;
|
|
43
|
+
let disabled = false;
|
|
44
|
+
let warned = false;
|
|
45
|
+
|
|
46
|
+
function indexDir(): string {
|
|
47
|
+
const override = process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
48
|
+
if (override && override.trim() !== "") return override;
|
|
49
|
+
try {
|
|
50
|
+
return join(homedir(), ".pi", "mega-compact-vector");
|
|
51
|
+
} catch {
|
|
52
|
+
return join("/tmp", ".mega-compact-vector");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function logWarn(msg: string): void {
|
|
57
|
+
// Never throw — degradation is the whole point. One warning per process.
|
|
58
|
+
if (warned) return;
|
|
59
|
+
warned = true;
|
|
60
|
+
try {
|
|
61
|
+
console.warn(`[mega-compact:vectorIndex] ${msg} (falling back to sync scan)`);
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Honor the emergency kill-switch. When set, the index is fully disabled. */
|
|
68
|
+
export function isVectorIndexDisabled(): boolean {
|
|
69
|
+
return (
|
|
70
|
+
disabled ||
|
|
71
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED === "true" ||
|
|
72
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED === "1"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Lazily open + schema-init the global PGlite DB. Idempotent and safe to call
|
|
78
|
+
* from many places. Returns undefined when disabled/unavailable so callers can
|
|
79
|
+
* fall back to the synchronous scan. Never throws.
|
|
80
|
+
*/
|
|
81
|
+
export function initVectorIndex(): Promise<PGliteInstance | undefined> {
|
|
82
|
+
if (isVectorIndexDisabled()) return Promise.resolve(undefined);
|
|
83
|
+
if (db) return Promise.resolve(db);
|
|
84
|
+
if (initPromise) return initPromise;
|
|
85
|
+
initPromise = openPgLite(/* retryOnCorrupt */ true);
|
|
86
|
+
return initPromise;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level
|
|
91
|
+
* abort (typically from a corrupted/torn data dir) triggers a delete + one
|
|
92
|
+
* retry — the dir is rebuilt from scratch by PGlite's initdb.
|
|
93
|
+
*/
|
|
94
|
+
async function openPgLite(
|
|
95
|
+
retryOnCorrupt: boolean,
|
|
96
|
+
): Promise<PGliteInstance | undefined> {
|
|
97
|
+
try {
|
|
98
|
+
const dir = indexDir();
|
|
99
|
+
mkdirSync(dir, { recursive: true });
|
|
100
|
+
const pg = await new PGlite({
|
|
101
|
+
dataDir: dir,
|
|
102
|
+
extensions: { vector },
|
|
103
|
+
});
|
|
104
|
+
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
105
|
+
await pg.exec(`
|
|
106
|
+
CREATE TABLE IF NOT EXISTS vector_index (
|
|
107
|
+
repo_id TEXT NOT NULL,
|
|
108
|
+
session_id TEXT NOT NULL,
|
|
109
|
+
checkpoint_id TEXT NOT NULL,
|
|
110
|
+
embedding vector(${EMBEDDING_DIM}) NOT NULL,
|
|
111
|
+
PRIMARY KEY (repo_id, session_id, checkpoint_id)
|
|
112
|
+
);
|
|
113
|
+
`);
|
|
114
|
+
// HNSW index over cosine distance for fast NN. Created idempotently.
|
|
115
|
+
await pg.exec(
|
|
116
|
+
"CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);",
|
|
117
|
+
);
|
|
118
|
+
db = pg;
|
|
119
|
+
return pg;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
122
|
+
// Self-heal: a WASM Aborted() typically means the data dir is corrupted
|
|
123
|
+
// (torn WAL from concurrent access). Delete it and retry once.
|
|
124
|
+
if (
|
|
125
|
+
retryOnCorrupt &&
|
|
126
|
+
(msg.includes("Aborted") || msg.includes("RuntimeError"))
|
|
127
|
+
) {
|
|
128
|
+
try {
|
|
129
|
+
const dir = indexDir();
|
|
130
|
+
if (existsSync(dir)) {
|
|
131
|
+
rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
// Clear singleton state so the retry starts fresh.
|
|
134
|
+
initPromise = undefined;
|
|
135
|
+
return openPgLite(/* retryOnCorrupt */ false);
|
|
136
|
+
} catch {
|
|
137
|
+
// Self-heal failed — fall through to disable.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
disabled = true;
|
|
141
|
+
logWarn(`init failed: ${msg}`);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toVectorLiteral(v: number[]): string {
|
|
147
|
+
// pgvector text form: [a,b,c]. Guard against NaN/Inf for a clean literal.
|
|
148
|
+
const parts = v.map((x) => (Number.isFinite(x) ? x : 0));
|
|
149
|
+
return `[${parts.join(",")}]`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Best-effort upsert of one checkpoint embedding into the global index.
|
|
154
|
+
* Dimension-mismatched vectors (e.g. a BYO embedder with dim ≠ 512) are skipped
|
|
155
|
+
* rather than corrupting the index. Fire-and-forget: resolved promise only;
|
|
156
|
+
* callers must NOT await this on the sync path. Never throws.
|
|
157
|
+
*/
|
|
158
|
+
export async function upsertEmbedding(
|
|
159
|
+
repoId: string,
|
|
160
|
+
sessionId: string,
|
|
161
|
+
checkpointId: string,
|
|
162
|
+
embedding: number[],
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
if (isVectorIndexDisabled()) return;
|
|
165
|
+
if (!embedding || embedding.length !== EMBEDDING_DIM) {
|
|
166
|
+
// Dimension guard: skip without corrupting the fixed-dim index.
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const pg = await initVectorIndex();
|
|
171
|
+
if (!pg) return;
|
|
172
|
+
const lit = toVectorLiteral(embedding);
|
|
173
|
+
await pg.query(
|
|
174
|
+
`INSERT INTO vector_index (repo_id, session_id, checkpoint_id, embedding)
|
|
175
|
+
VALUES ($1, $2, $3, $4::vector)
|
|
176
|
+
ON CONFLICT (repo_id, session_id, checkpoint_id)
|
|
177
|
+
DO UPDATE SET embedding = EXCLUDED.embedding;`,
|
|
178
|
+
[repoId, sessionId, checkpointId, lit],
|
|
179
|
+
);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
disabled = true;
|
|
182
|
+
logWarn(`upsert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface SearchAsyncOpts {
|
|
187
|
+
/** When provided, scope the NN search to a single repo; omit for cross-repo. */
|
|
188
|
+
repoId?: string;
|
|
189
|
+
/** Max hits (default 3). */
|
|
190
|
+
k?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Cross-repo (or single-repo) HNSW nearest-neighbor search. Returns hits sorted
|
|
195
|
+
* by descending similarity. Never throws — on any failure returns [].
|
|
196
|
+
*/
|
|
197
|
+
export async function searchAsync(
|
|
198
|
+
query: number[],
|
|
199
|
+
opts: SearchAsyncOpts = {},
|
|
200
|
+
): Promise<VectorIndexHit[]> {
|
|
201
|
+
if (isVectorIndexDisabled() || !query || query.length !== EMBEDDING_DIM) return [];
|
|
202
|
+
const k = opts.k ?? 3;
|
|
203
|
+
const repoId = opts.repoId;
|
|
204
|
+
try {
|
|
205
|
+
const pg = await initVectorIndex();
|
|
206
|
+
if (!pg) return [];
|
|
207
|
+
const lit = toVectorLiteral(query);
|
|
208
|
+
const params: unknown[] = [lit, k];
|
|
209
|
+
let sql =
|
|
210
|
+
"SELECT repo_id, session_id, checkpoint_id, 1 - (embedding <=> $1::vector) AS score " +
|
|
211
|
+
"FROM vector_index";
|
|
212
|
+
if (repoId) {
|
|
213
|
+
sql += " WHERE repo_id = $3";
|
|
214
|
+
params.push(repoId);
|
|
215
|
+
}
|
|
216
|
+
sql += " ORDER BY embedding <=> $1::vector LIMIT $2";
|
|
217
|
+
const res = await pg.query(sql, params);
|
|
218
|
+
return res.rows.map((r: any) => ({
|
|
219
|
+
repoId: r.repo_id as string,
|
|
220
|
+
sessionId: r.session_id as string,
|
|
221
|
+
checkpointId: r.checkpoint_id as string,
|
|
222
|
+
score: r.score as number,
|
|
223
|
+
}));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
disabled = true;
|
|
226
|
+
logWarn(`search failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Close the index (test teardown / shutdown). Safe to call when unopened. */
|
|
232
|
+
export async function closeVectorIndex(): Promise<void> {
|
|
233
|
+
if (db) {
|
|
234
|
+
try {
|
|
235
|
+
await db.close();
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
db = undefined;
|
|
241
|
+
initPromise = undefined;
|
|
242
|
+
disabled = false;
|
|
243
|
+
warned = false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Rebuild the entire index from the authoritative node:sqlite store. Used for
|
|
248
|
+
* backfill + DR. `enumerateRepoStateDirs` yields each repo's state dir; we read
|
|
249
|
+
* its checkpoint embeddings and bulk upsert. Best-effort: counts successes and
|
|
250
|
+
* skips failures. Returns {upserted, errors}.
|
|
251
|
+
*/
|
|
252
|
+
export async function rebuildFromSqlite(
|
|
253
|
+
enumerateRepoStateDirs: () => Iterable<{ repoId: string; stateDir: string }>,
|
|
254
|
+
readCheckpoints: (
|
|
255
|
+
stateDir: string,
|
|
256
|
+
) => Iterable<{ sessionId: string; checkpointId: string; embedding: number[] }>,
|
|
257
|
+
): Promise<{ upserted: number; errors: number }> {
|
|
258
|
+
let upserted = 0;
|
|
259
|
+
let errors = 0;
|
|
260
|
+
const pg = await initVectorIndex();
|
|
261
|
+
if (!pg) return { upserted, errors: 1 };
|
|
262
|
+
for (const repo of enumerateRepoStateDirs()) {
|
|
263
|
+
for (const cp of readCheckpoints(repo.stateDir)) {
|
|
264
|
+
try {
|
|
265
|
+
await upsertEmbedding(repo.repoId, cp.sessionId, cp.checkpointId, cp.embedding);
|
|
266
|
+
upserted++;
|
|
267
|
+
} catch {
|
|
268
|
+
errors++;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { upserted, errors };
|
|
273
|
+
}
|
package/src/vectorStore.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
listCheckpoints,
|
|
27
27
|
nextCheckpointId,
|
|
28
28
|
upsertCheckpoint,
|
|
29
|
+
getCheckpoint,
|
|
29
30
|
loadSessionState,
|
|
30
31
|
saveSessionState,
|
|
31
32
|
upsertMinhashSignature,
|
|
@@ -38,6 +39,11 @@ import {
|
|
|
38
39
|
repoStats as repoStatsFromStore,
|
|
39
40
|
dataInvariantStats,
|
|
40
41
|
} from "./store/sqlite.js";
|
|
42
|
+
import {
|
|
43
|
+
initVectorIndex,
|
|
44
|
+
searchAsync as vectorIndexSearch,
|
|
45
|
+
type VectorIndexHit,
|
|
46
|
+
} from "./store/vectorIndex.js";
|
|
41
47
|
import { rehydrateRaptorTree } from "./dedup/raptor/index.js";
|
|
42
48
|
import { stagedExpansion } from "./dedup/raptor/retrieval.js";
|
|
43
49
|
import { migrateJsonToSqlite } from "./store/migrate.js";
|
|
@@ -98,6 +104,13 @@ export class VectorStore {
|
|
|
98
104
|
private readonly cfg: DedupConfigShape;
|
|
99
105
|
/** Optional monitoring target (Sprint 14). Undefined → no monitoring. */
|
|
100
106
|
private readonly eventsPath?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Repo key for the async PGlite vector index (Slice 2). We use the stateDir
|
|
109
|
+
* itself as the repo id — it is already unique per repo and available here
|
|
110
|
+
* without crossing into the pi-runtime layer (src/ stays pi-agnostic). The
|
|
111
|
+
* global index keys on repoId so recall can span repos.
|
|
112
|
+
*/
|
|
113
|
+
private readonly repoId: string;
|
|
101
114
|
|
|
102
115
|
constructor(
|
|
103
116
|
opts: {
|
|
@@ -110,10 +123,13 @@ export class VectorStore {
|
|
|
110
123
|
config?: DedupConfigShape;
|
|
111
124
|
/** Optional events.log path for decision monitoring (Sprint 14). */
|
|
112
125
|
eventsPath?: string;
|
|
126
|
+
/** Repo id for the async cross-repo vector index. Defaults to stateDir. */
|
|
127
|
+
repoId?: string;
|
|
113
128
|
} = {},
|
|
114
129
|
) {
|
|
115
130
|
this.embedder = opts.embedder ?? defaultEmbedder();
|
|
116
131
|
this.stateDir = opts.stateDir ?? getStateDir();
|
|
132
|
+
this.repoId = opts.repoId ?? this.stateDir;
|
|
117
133
|
// Sprint 14: all tier flags/thresholds flow from the single config source
|
|
118
134
|
// (DedupConfig). The legacy opts.dedupSim / opts.l2Enabled remain accepted
|
|
119
135
|
// for backward-compat callers but flags are authoritative via `cfg`.
|
|
@@ -472,6 +488,60 @@ export class VectorStore {
|
|
|
472
488
|
return ranked;
|
|
473
489
|
}
|
|
474
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Slice 2: async cross-repo (or single-repo) recall via the PGlite/HNSW index.
|
|
493
|
+
*
|
|
494
|
+
* This is the ONLY async recall surface and is a BONUS path — the synchronous
|
|
495
|
+
* `search()` above remains the default. `opts.repoId` scopes to one repo; omit
|
|
496
|
+
* it for cross-repo nearest-neighbor recall (the headline capability the sync
|
|
497
|
+
* per-session scan cannot provide).
|
|
498
|
+
*
|
|
499
|
+
* Best-effort: if the index is disabled/empty/failing, we fall back to the
|
|
500
|
+
* synchronous per-session `search()` for THIS repo so callers always get a
|
|
501
|
+
* sensible result. Hydrates each hit's StoredCheckpoint from the authoritative
|
|
502
|
+
* node:sqlite store (the hit's repoId doubles as that repo's stateDir), then
|
|
503
|
+
* MMR-dedupes the merged set.
|
|
504
|
+
*/
|
|
505
|
+
async searchAsync(
|
|
506
|
+
sessionId: string,
|
|
507
|
+
query: string,
|
|
508
|
+
k = 3,
|
|
509
|
+
opts: { repoId?: string; crossRepo?: boolean } = {},
|
|
510
|
+
): Promise<SearchHit[]> {
|
|
511
|
+
const sid = normalizeSessionId(sessionId);
|
|
512
|
+
const qv = this.embedder.embed(query);
|
|
513
|
+
// repoId filter: explicit opts.repoId wins; else this repo unless crossRepo.
|
|
514
|
+
const repoId = opts.repoId ?? (opts.crossRepo ? undefined : this.repoId);
|
|
515
|
+
let indexHits: VectorIndexHit[] = [];
|
|
516
|
+
try {
|
|
517
|
+
await initVectorIndex();
|
|
518
|
+
indexHits = await vectorIndexSearch(qv, { k: Math.max(k * 2, k), repoId });
|
|
519
|
+
} catch {
|
|
520
|
+
indexHits = [];
|
|
521
|
+
}
|
|
522
|
+
if (indexHits.length === 0) {
|
|
523
|
+
// Index empty/unavailable → synchronous per-session fallback (this repo).
|
|
524
|
+
return this.search(sid, query, k);
|
|
525
|
+
}
|
|
526
|
+
// Hydrate each index hit from the authoritative node:sqlite store. repoId is
|
|
527
|
+
// that repo's stateDir, so cross-repo hits resolve against their own store.
|
|
528
|
+
const hydrated: SearchHit[] = [];
|
|
529
|
+
for (const h of indexHits) {
|
|
530
|
+
const cp = getCheckpoint(h.sessionId, h.checkpointId, h.repoId);
|
|
531
|
+
if (cp && cp.dedupStatus !== "removed") {
|
|
532
|
+
hydrated.push({ checkpoint: cp, score: h.score });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (hydrated.length === 0) return this.search(sid, query, k);
|
|
536
|
+
// MMR-dedupe the merged candidate set for diversity (mirrors sync search).
|
|
537
|
+
const mmrItems: MmrItem<SearchHit>[] = hydrated.map((h) => ({
|
|
538
|
+
item: h,
|
|
539
|
+
vector: h.checkpoint.embedding,
|
|
540
|
+
relevance: h.score,
|
|
541
|
+
}));
|
|
542
|
+
return mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
|
|
543
|
+
}
|
|
544
|
+
|
|
475
545
|
/**
|
|
476
546
|
* Serve the RAPTOR tree for a query (Fix D): rehydrate the persisted tree and
|
|
477
547
|
* return its staged-expansion leaf hits as SearchHits. Returns [] when no tree
|