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.
@@ -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
- // ── Existing server? ──────────────────────────────────────────────────────
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
- return Promise.resolve({ port: info.port, url: `http://localhost:${info.port}` });
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
- // stale file, overwrite
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 { /* non-fatal */ }
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.23",
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
+ }
@@ -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
+ });