pi-mega-compact 0.4.23 → 0.4.25
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/dashboard-server.js +57 -6
- package/dist/extensions/dashboard-server.test.js +77 -0
- package/dist/extensions/mega-dashboard-cmds.js +32 -2
- 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 +210 -0
- package/dist/src/store/vectorIndex.test.js +99 -0
- package/dist/src/vectorStore.js +66 -1
- package/dist/src/wordpiece.js +129 -0
- package/extensions/dashboard-server.test.ts +77 -0
- package/extensions/dashboard-server.ts +53 -7
- package/extensions/mega-dashboard-cmds.ts +23 -2
- 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 +243 -0
- package/src/vectorStore.ts +77 -0
|
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
|
|
|
10
10
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// helpers
|
|
@@ -122,3 +123,79 @@ describe("port.pid file", () => {
|
|
|
122
123
|
rmSync(dir, { recursive: true });
|
|
123
124
|
});
|
|
124
125
|
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Lifecycle integration — launch the compiled server as a real subprocess
|
|
129
|
+
// (the same way the /dashboard command spawns it) and assert the two failure
|
|
130
|
+
// modes that historically produced a silent "failed to start":
|
|
131
|
+
// 1. a stale port.pid pointing at a dead port is dropped, and the server
|
|
132
|
+
// binds fresh (instead of returning the dead port);
|
|
133
|
+
// 2. a module-load crash is captured to the launch log instead of going
|
|
134
|
+
// silent under stdio:"ignore".
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
|
|
138
|
+
|
|
139
|
+
function waitFor(cond: () => boolean | Promise<boolean>, timeoutMs = 6000): Promise<void> {
|
|
140
|
+
const start = Date.now();
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const tick = async () => {
|
|
143
|
+
if (await cond()) return resolve();
|
|
144
|
+
if (Date.now() - start > timeoutMs) return reject(new Error("timeout"));
|
|
145
|
+
setTimeout(tick, 50);
|
|
146
|
+
};
|
|
147
|
+
tick();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("server lifecycle", () => {
|
|
152
|
+
test("drops a stale port.pid and binds a fresh port", async () => {
|
|
153
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
|
|
154
|
+
// A marker claiming a port where nothing is listening.
|
|
155
|
+
writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
|
|
156
|
+
|
|
157
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
158
|
+
try {
|
|
159
|
+
// Wait for the server to actually be live (not just any port.pid — the
|
|
160
|
+
// stale marker already exists at t=0 and would pass a naive check).
|
|
161
|
+
await waitFor(async () => {
|
|
162
|
+
try {
|
|
163
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
164
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
165
|
+
return res.ok;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
171
|
+
assert.equal(typeof raw.port, "number");
|
|
172
|
+
assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
|
|
173
|
+
// And a real server must answer on it.
|
|
174
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
175
|
+
assert.equal(res.ok, true);
|
|
176
|
+
} finally {
|
|
177
|
+
child.kill("SIGTERM");
|
|
178
|
+
rmSync(dir, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("writes a dashboard.log with startup lines", async () => {
|
|
183
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-log-"));
|
|
184
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
185
|
+
try {
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
try {
|
|
188
|
+
return /server running/.test(readFileSync(join(dir, "dashboard.log"), "utf-8"));
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
const log = readFileSync(join(dir, "dashboard.log"), "utf-8");
|
|
194
|
+
assert.match(log, /\[mega-compact\]\[dashboard\]/);
|
|
195
|
+
assert.match(log, /server running/);
|
|
196
|
+
} finally {
|
|
197
|
+
child.kill("SIGTERM");
|
|
198
|
+
rmSync(dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -12,12 +12,32 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, appendFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join, dirname } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { DatabaseSync } from "node:sqlite";
|
|
20
20
|
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Local runtime log
|
|
23
|
+
//
|
|
24
|
+
// The dashboard server is spawned as a DETACHED child. When it is launched with
|
|
25
|
+
// `stdio: "ignore"` (the old default) any crash before the first console.log is
|
|
26
|
+
// invisible — there is no log to "check". We therefore mirror every lifecycle
|
|
27
|
+
// line to a file in the state dir so a failed start is always diagnosable. The
|
|
28
|
+
// launcher also captures stderr, so this doubles as defense-in-depth.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
let LOG_PATH: string | null = null;
|
|
32
|
+
function log(...parts: unknown[]): void {
|
|
33
|
+
const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.error(line); // stderr — captured by the launcher pipe
|
|
36
|
+
if (LOG_PATH) {
|
|
37
|
+
try { appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n"); } catch { /* non-fatal */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
22
42
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
23
43
|
// (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
|
|
@@ -738,7 +758,7 @@ function dashboardHtml(tierName: string): string {
|
|
|
738
758
|
// Server
|
|
739
759
|
// ---------------------------------------------------------------------------
|
|
740
760
|
|
|
741
|
-
export function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
|
|
761
|
+
export async function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
|
|
742
762
|
// Our own package version — exposed at /api/version so the launcher can
|
|
743
763
|
// detect a stale server (started by an older build) and replace it on
|
|
744
764
|
// upgrade instead of reuse it.
|
|
@@ -757,17 +777,37 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
|
|
|
757
777
|
const portFile = join(stateDir, "port.pid");
|
|
758
778
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
759
779
|
const eventsPath = join(stateDir, "events.log");
|
|
760
|
-
|
|
761
|
-
|
|
780
|
+
LOG_PATH = join(stateDir, "dashboard.log");
|
|
781
|
+
log("launch invoked", { stateDir });
|
|
782
|
+
|
|
783
|
+
// ── Existing server? ───────────────────────────────────────────────────────
|
|
784
|
+
// A stale port.pid pointing at a dead/competing process is the classic cause
|
|
785
|
+
// of "dashboard failed to start" — we return a port that is NOT actually
|
|
786
|
+
// serving. Probe for a live server on that port first; only reuse the marker
|
|
787
|
+
// when something real answers /api/version. Otherwise drop it and start fresh.
|
|
762
788
|
if (existsSync(portFile)) {
|
|
763
789
|
try {
|
|
764
790
|
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
765
791
|
if (info && info.port) {
|
|
766
|
-
|
|
792
|
+
let live = false;
|
|
793
|
+
try {
|
|
794
|
+
const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) });
|
|
795
|
+
live = probe.ok;
|
|
796
|
+
} catch {
|
|
797
|
+
live = false;
|
|
798
|
+
}
|
|
799
|
+
if (live) {
|
|
800
|
+
log("reusing live server from port.pid", { port: info.port });
|
|
801
|
+
return { port: info.port, url: `http://localhost:${info.port}` };
|
|
802
|
+
}
|
|
803
|
+
log("port.pid present but no live server — treating as stale", { port: info.port });
|
|
767
804
|
}
|
|
768
805
|
} catch {
|
|
769
|
-
|
|
806
|
+
log("port.pid unparseable — treating as stale");
|
|
770
807
|
}
|
|
808
|
+
// stale file, remove so the fresh bind does not collide with a lingering
|
|
809
|
+
// process that still holds the port
|
|
810
|
+
try { unlinkSync(portFile); } catch { /* ignore */ }
|
|
771
811
|
}
|
|
772
812
|
|
|
773
813
|
// ── New server ────────────────────────────────────────────────────────────
|
|
@@ -891,20 +931,26 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
|
|
|
891
931
|
function tryPort(port: number) {
|
|
892
932
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
893
933
|
if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
|
|
934
|
+
log("port in use, trying next", { port });
|
|
894
935
|
tryPort(port + 1);
|
|
895
936
|
} else {
|
|
937
|
+
log("listen failed", { port, code: err.code, message: err.message });
|
|
896
938
|
reject(err);
|
|
897
939
|
}
|
|
898
940
|
});
|
|
899
941
|
|
|
900
942
|
server.listen(port, "127.0.0.1", () => {
|
|
901
943
|
const url = `http://localhost:${port}`;
|
|
944
|
+
log("server running", { url });
|
|
945
|
+
// eslint-disable-next-line no-console
|
|
902
946
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
903
947
|
|
|
904
948
|
// Write port.pid
|
|
905
949
|
try {
|
|
906
950
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
907
|
-
} catch {
|
|
951
|
+
} catch (e) {
|
|
952
|
+
log("could not write port.pid", { error: String(e) });
|
|
953
|
+
}
|
|
908
954
|
|
|
909
955
|
// Graceful cleanup
|
|
910
956
|
const cleanup = () => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { join, dirname, sep } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
12
|
+
import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
|
|
13
13
|
import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
|
|
14
14
|
import { MegaRuntime } from "./mega-runtime.js";
|
|
15
15
|
|
|
@@ -209,11 +209,32 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
|
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// Clear any stale marker so a fresh bind never collides with a lingering
|
|
213
|
+
// orphan, and truncate the launch log so the next error report shows only
|
|
214
|
+
// this attempt's output.
|
|
215
|
+
try { unlinkSync(portFile); } catch { /* ignore */ }
|
|
216
|
+
try { writeFileSync(launchLog, ""); } catch { /* ignore */ }
|
|
217
|
+
|
|
212
218
|
const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
|
|
219
|
+
// Redirect the child's stderr to the launch log so that a CRASH BEFORE the
|
|
220
|
+
// runner's own __fail handler runs (e.g. an ESM module-load / parse error,
|
|
221
|
+
// or a missing entry) is still captured. With the old `stdio: "ignore"`
|
|
222
|
+
// these failures were completely silent and the "check logs" message
|
|
223
|
+
// pointed at an empty file. We open the fd in the parent and pass it to the
|
|
224
|
+
// child; once spawned we close our copy (the child keeps its own dup).
|
|
225
|
+
let stderrFd: number;
|
|
226
|
+
try {
|
|
227
|
+
stderrFd = openSync(launchLog, "a");
|
|
228
|
+
} catch {
|
|
229
|
+
stderrFd = -1; // fall back to ignored stderr
|
|
230
|
+
}
|
|
213
231
|
const child = spawn(process.execPath, args, {
|
|
214
232
|
detached: true,
|
|
215
|
-
stdio: "ignore",
|
|
233
|
+
stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
|
|
216
234
|
});
|
|
235
|
+
if (stderrFd >= 0) {
|
|
236
|
+
try { closeSync(stderrFd); } catch { /* ignore */ }
|
|
237
|
+
}
|
|
217
238
|
child.unref();
|
|
218
239
|
|
|
219
240
|
// Poll for a live server (port 9320–9329) instead of relying solely on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.25",
|
|
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-2-Clause",
|
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
"typescript": "^5.4.0"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
+
"@electric-sql/pglite": "^0.5.4",
|
|
61
|
+
"@electric-sql/pglite-pgvector": "^0.0.5",
|
|
60
62
|
"@mongodb-js/zstd": "^7.0.0"
|
|
61
63
|
}
|
|
62
64
|
}
|
package/src/recall.ts
CHANGED
|
@@ -137,3 +137,66 @@ export function recallAndInline(
|
|
|
137
137
|
empty: toInject.length === 0,
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Slice 2 async cross-repo recall. Same dedup/bound/inline contract as
|
|
143
|
+
* `recallAndInline`, but backed by `VectorStore.searchAsync` so it can recall
|
|
144
|
+
* across repos (HNSW NN over the global PGlite index) when `opts.crossRepo` is
|
|
145
|
+
* set. The synchronous `recallAndInline` is unchanged and remains the default
|
|
146
|
+
* per-session path. Inline-window dedupe + token cap (Fix C) apply here too.
|
|
147
|
+
*
|
|
148
|
+
* `store` must provide `searchAsync` (the live VectorStore does). Errors fall
|
|
149
|
+
* back to an empty result — recall is a bonus, never a hard dependency.
|
|
150
|
+
*/
|
|
151
|
+
export async function recallAndInlineAsync(
|
|
152
|
+
opts: RecallInjectOptions & { crossRepo?: boolean; repoId?: string },
|
|
153
|
+
store: Pick<VectorStore, "searchAsync" | "wasInjected" | "markInjected">,
|
|
154
|
+
): Promise<RecallInjectResult> {
|
|
155
|
+
const limit = opts.limit ?? 3;
|
|
156
|
+
const skip = opts.skipInjected ?? true;
|
|
157
|
+
const maxTokens = opts.recallMaxTokens ?? 0;
|
|
158
|
+
const doWindowDedupe = opts.windowDedupe ?? false;
|
|
159
|
+
const dedupSim = opts.dedupSim ?? 0.9;
|
|
160
|
+
|
|
161
|
+
let hits: SearchHit[] = [];
|
|
162
|
+
try {
|
|
163
|
+
hits = await store.searchAsync(opts.sessionId, opts.query, limit, {
|
|
164
|
+
crossRepo: opts.crossRepo,
|
|
165
|
+
repoId: opts.repoId,
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
hits = [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let liveEmbeddings: number[][] = [];
|
|
172
|
+
if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
|
|
173
|
+
const embedder = defaultEmbedder();
|
|
174
|
+
liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const toInject: SearchHit[] = [];
|
|
178
|
+
const parts: string[] = [];
|
|
179
|
+
let blockTokens = 0;
|
|
180
|
+
|
|
181
|
+
for (const h of hits) {
|
|
182
|
+
if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId)) continue;
|
|
183
|
+
if (doWindowDedupe && liveEmbeddings.length > 0) {
|
|
184
|
+
const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
|
|
185
|
+
if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
|
|
186
|
+
}
|
|
187
|
+
const part = formatRecallBlock([h]);
|
|
188
|
+
const partTokens = estimateBlockTokens(part);
|
|
189
|
+
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
|
|
190
|
+
parts.push(part);
|
|
191
|
+
toInject.push(h);
|
|
192
|
+
blockTokens += partTokens;
|
|
193
|
+
store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const block = parts.join("\n");
|
|
197
|
+
const report = toInject.map(
|
|
198
|
+
(h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return { toInject, report, block, empty: toInject.length === 0 };
|
|
202
|
+
}
|
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
|
+
});
|