pi-mega-compact 0.8.26 → 0.9.1

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.
Files changed (46) hide show
  1. package/README.md +12 -9
  2. package/dist/extensions/mega-compact.js +16 -1
  3. package/dist/extensions/mega-config.js +1 -0
  4. package/dist/extensions/mega-pipeline/compact.js +2 -1
  5. package/dist/extensions/mega-runtime/reset-runtime.js +8 -0
  6. package/dist/extensions/mega-runtime/runtime.js +12 -50
  7. package/dist/extensions/mega-shutdown-widget.test.js +121 -0
  8. package/dist/src/compact.js +4 -2
  9. package/dist/src/dedup/raptor/tree.js +11 -0
  10. package/dist/src/memory.test.js +29 -0
  11. package/dist/src/memoryOps.js +4 -19
  12. package/dist/src/memoryRecall.test.js +27 -0
  13. package/dist/src/memoryRoundtrip.test.js +137 -0
  14. package/dist/src/recall.js +5 -4
  15. package/dist/src/sprint4x-rag-verification.test.js +93 -0
  16. package/dist/src/store/memoryIndex.js +29 -7
  17. package/dist/src/store/pgOpenGuard.js +83 -0
  18. package/dist/src/store/pgOpenGuard.test.js +74 -0
  19. package/dist/src/store/repoKey.js +45 -0
  20. package/dist/src/store/vectorIndex.js +30 -8
  21. package/dist/src/store/vectorIndex.test.js +25 -1
  22. package/dist/src/vector-search.js +11 -5
  23. package/dist/src/vectorStore.js +4 -1
  24. package/extensions/mega-compact.ts +16 -1
  25. package/extensions/mega-config.ts +8 -0
  26. package/extensions/mega-pipeline/compact.ts +2 -1
  27. package/extensions/mega-runtime/reset-runtime.ts +88 -0
  28. package/extensions/mega-runtime/runtime.ts +27 -59
  29. package/extensions/mega-shutdown-widget.test.ts +141 -0
  30. package/package.json +1 -1
  31. package/src/compact.ts +209 -174
  32. package/src/dedup/raptor/tree.ts +11 -0
  33. package/src/memory.test.ts +47 -1
  34. package/src/memoryOps.ts +4 -19
  35. package/src/memoryRecall.test.ts +36 -0
  36. package/src/memoryRoundtrip.test.ts +155 -0
  37. package/src/recall.ts +6 -3
  38. package/src/sprint4x-rag-verification.test.ts +119 -0
  39. package/src/store/memoryIndex.ts +34 -8
  40. package/src/store/pgOpenGuard.test.ts +89 -0
  41. package/src/store/pgOpenGuard.ts +93 -0
  42. package/src/store/repoKey.ts +50 -0
  43. package/src/store/vectorIndex.test.ts +25 -1
  44. package/src/store/vectorIndex.ts +35 -9
  45. package/src/vector-search.ts +10 -5
  46. package/src/vectorStore.ts +4 -1
@@ -0,0 +1,93 @@
1
+ /**
2
+ * sprint4x-rag-verification.test.ts — run the S40–S47 RAG suite once against
3
+ * the current implementation and assert the documented "default enable" claims.
4
+ *
5
+ * This is the verification pass demanded by the roadmap/backlog: each spec
6
+ * claims its feature flags DEFAULT ON. The honest check (not a re-statement of
7
+ * the spec) is: which flags actually exist in code, what do they default to,
8
+ * and is the consuming path wired?
9
+ *
10
+ * Findings recorded here, per spec:
11
+ * S40 importance-scoring — module src/importance.ts exists (S40A) but is
12
+ * NOT consumed by compactSession/vector-paths;
13
+ * no shipped flag, no adapter wiring.
14
+ * S41 self-rag-quality-gate — spec-only; NO flag, NO consumer.
15
+ * S42 raptor-multilevel — PARTIALLY SHIPPED: RAPTOR_MULTILEVEL_ENABLED
16
+ * and RAPTOR_LEAF_EXPANSION both default true
17
+ * with real consumers; the spec's claim holds.
18
+ * S43 hyde-vague-queries — spec-only (QUERY_REFORMULATION_ENABLED absent).
19
+ * S44 three-tier-latency-routing — spec-only (TIERED_ROUTING_ENABLED absent).
20
+ * S45 crag-quality-metrics — spec-only (CRAG_ENABLED absent).
21
+ * S46 visual-memory-map — spec-only (MEMORY_MAP_ENABLED absent;
22
+ * memory-graph endpoint not in dashboard-server).
23
+ * S47 auto-categorizing-wiki — spec-only (AUTO_WIKI_ENABLED absent).
24
+ *
25
+ * Policy: the suite runs against the flags that EXIST today. Unimplemented spec
26
+ * claims are pinned by the S4X_SPEC_ONLY tests so regressions can't silently
27
+ * add them in the wrong state, and this file records exactly which
28
+ * spec-vs-implementation gaps remain.
29
+ */
30
+ import { test } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { loadDedupConfig } from "./config/dedup.js";
33
+ /**
34
+ * Clear any env pollution so the default is measured, not the override.
35
+ */
36
+ function fresh(envKey, get) {
37
+ delete process.env[envKey];
38
+ try {
39
+ return get();
40
+ }
41
+ finally {
42
+ delete process.env[envKey];
43
+ }
44
+ }
45
+ // ---- S42: shipped flags default ON (claim holds) ----------------------------
46
+ test("S42 RAPTOR_MULTILEVEL_ENABLED defaults ON and honors env off", () => {
47
+ const d = fresh("MEGACOMPACT_RAPTOR_MULTILEVEL", () => loadDedupConfig());
48
+ assert.ok(d.RAPTOR_MULTILEVEL_ENABLED, "ship claim: multi-level on by default");
49
+ process.env.MEGACOMPACT_RAPTOR_MULTILEVEL = "false";
50
+ const off = loadDedupConfig();
51
+ assert.ok(!off.RAPTOR_MULTILEVEL_ENABLED, "env override: MEGACOMPACT_RAPTOR_MULTILEVEL=false turns it off");
52
+ });
53
+ test("S42 RAPTOR_LEAF_EXPANSION defaults ON and honors env off", () => {
54
+ const d = fresh("MEGACOMPACT_RAPTOR_LEAF_EXPANSION", () => loadDedupConfig());
55
+ assert.ok(d.RAPTOR_LEAF_EXPANSION, "ship claim: leaf-expansion on by default");
56
+ process.env.MEGACOMPACT_RAPTOR_LEAF_EXPANSION = "false";
57
+ const off = loadDedupConfig();
58
+ assert.ok(!off.RAPTOR_LEAF_EXPANSION, "env override off");
59
+ });
60
+ // ---- S40: module exists, claims do NOT yet hold at the flag/consumer level --
61
+ test("S40 importance module exists in-tree (S40A artifact)", async () => {
62
+ // Load the module and confirm it exports the scoring surface the spec
63
+ // describes. This proves the S40A implementation exists even though nothing
64
+ // wires it into compaction/vector paths yet (documented gap).
65
+ const mod = await import("./importance.js");
66
+ assert.ok(mod && typeof mod === "object", "importance.ts loads");
67
+ // Exports per spec: score() plus item-type enum.
68
+ assert.ok(typeof mod.score === "function", "importance.ts exports score() function");
69
+ });
70
+ test("S40 has no shipped consumer flag in the current codebase (gap pinned)", async () => {
71
+ // The S40 spec claims an IMPORTANCE_SCORING flag defaults ON. In the
72
+ // current code no such flag exists, and no vector/compact path reads
73
+ // importance scores. This test documents the gap so a future wiring does
74
+ // not silently flip it on in the wrong shape.
75
+ const cfg = loadDedupConfig();
76
+ assert.ok(!("IMPORTANCE_SCORING" in cfg), "no IMPORTANCE_SCORING flag yet (S40 consumer wiring missing)");
77
+ });
78
+ // ---- S41/S43–S47: spec-only modules — pin the absence ------------------------
79
+ test("S4X spec-only flags absent from DedupConfig", () => {
80
+ const cfg = loadDedupConfig();
81
+ const specFlags = [
82
+ "CRITIQUE_ENABLED", // S41
83
+ "QUERY_REFORMULATION_ENABLED", // S43
84
+ "TIERED_ROUTING_ENABLED", // S44
85
+ "CRAG_ENABLED", // S45
86
+ "CRAG_EXPANSION_ENABLED", // S45
87
+ "MEMORY_MAP_ENABLED", // S46
88
+ "AUTO_WIKI_ENABLED", // S47
89
+ ];
90
+ for (const f of specFlags) {
91
+ assert.ok(!(f in cfg), `${f} is spec-only — should not be present in DedupConfig`);
92
+ }
93
+ });
@@ -24,6 +24,7 @@ import { join } from "node:path";
24
24
  import { mkdirSync, rmSync, existsSync } from "node:fs";
25
25
  /** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
26
26
  export const MEMORY_INDEX_DIM = 512;
27
+ import { withOpenTimeout } from "./pgOpenGuard.js";
27
28
  let db;
28
29
  let initPromise;
29
30
  let disabled = false;
@@ -110,12 +111,18 @@ async function openPgLite(retryOnCorrupt) {
110
111
  return undefined;
111
112
  const dir = indexDir();
112
113
  mkdirSync(dir, { recursive: true });
113
- const pg = await new mod.PGlite({
114
- dataDir: dir,
115
- extensions: { vector: mod.vector },
116
- });
117
- await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
118
- await pg.exec(`
114
+ // Bounded open: PGlite is single-writer over a shared dataDir, so a second
115
+ // pi process on the same dir can block here forever. Without the ceiling the
116
+ // never-settling promise gets cached in initPromise and every later caller
117
+ // awaits it — which is how a stalled index wedged a whole pi turn.
118
+ let openTimedOut = false;
119
+ const pg = await withOpenTimeout((async () => {
120
+ const inst = await new mod.PGlite({
121
+ dataDir: dir,
122
+ extensions: { vector: mod.vector },
123
+ });
124
+ await inst.exec("CREATE EXTENSION IF NOT EXISTS vector;");
125
+ await inst.exec(`
119
126
  CREATE TABLE IF NOT EXISTS memory_index (
120
127
  repo_id TEXT NOT NULL,
121
128
  memory_id INTEGER NOT NULL,
@@ -124,7 +131,22 @@ async function openPgLite(retryOnCorrupt) {
124
131
  PRIMARY KEY (repo_id, memory_id)
125
132
  );
126
133
  `);
127
- await pg.exec("CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);");
134
+ await inst.exec("CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);");
135
+ return inst;
136
+ })(), (reason) => {
137
+ openTimedOut = true;
138
+ logWarn(`init ${reason}`);
139
+ });
140
+ if (!pg) {
141
+ if (openTimedOut) {
142
+ // Don't leave the dead open cached, and don't retry on the next call —
143
+ // a contended dataDir would just burn another full timeout per caller.
144
+ // Same terminal state as any other init failure: fall back to the scan.
145
+ initPromise = undefined;
146
+ disabled = true;
147
+ }
148
+ return undefined;
149
+ }
128
150
  db = pg;
129
151
  return pg;
130
152
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * pgOpenGuard.ts — bound the PGlite open so a stalled WASM init can never wedge
3
+ * a pi turn.
4
+ *
5
+ * Both index modules (vectorIndex / memoryIndex) cache their in-flight open in a
6
+ * module-level `initPromise`. That cache is what turns a single stalled open
7
+ * into a permanent hang: PGlite is a single-writer WASM Postgres over a shared
8
+ * dataDir (~/.pi/mega-compact-vector), so a second pi process opening the same
9
+ * dir can block indefinitely. `await new PGlite(...)` then never settles, the
10
+ * never-settling promise is cached, and every later caller awaits that same dead
11
+ * promise — with no timers and no sockets left, node reports
12
+ * "Promise resolution is still pending but the event loop has already resolved"
13
+ * and the pi turn that awaited it never ends.
14
+ *
15
+ * withOpenTimeout() puts a ceiling on that wait. On timeout the caller gets
16
+ * undefined (both modules already degrade to a synchronous scan), and the
17
+ * abandoned open is disowned: if it does eventually settle, the instance is
18
+ * closed so a stray PGlite can't keep the loop alive or hold the dataDir lock.
19
+ *
20
+ * A rejected open is NOT swallowed — it propagates so the callers' existing
21
+ * corrupt-dir detection (Aborted / RuntimeError → wipe + one retry) still runs.
22
+ * Only the timeout resolves to undefined.
23
+ */
24
+ /** Sentinel so a legitimately-undefined open is distinguishable from a timeout. */
25
+ const TIMED_OUT = Symbol("pglite-open-timeout");
26
+ /** Default ceiling for a PGlite open. Generous — a cold WASM + HNSW init is slow. */
27
+ export const DEFAULT_PG_OPEN_TIMEOUT_MS = 30_000;
28
+ /** Resolve the open timeout. 0 (or negative) disables the guard entirely. */
29
+ export function pgOpenTimeoutMs() {
30
+ const raw = process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
31
+ if (raw === undefined || raw.trim() === "")
32
+ return DEFAULT_PG_OPEN_TIMEOUT_MS;
33
+ const n = Number(raw);
34
+ if (!Number.isFinite(n) || n < 0)
35
+ return DEFAULT_PG_OPEN_TIMEOUT_MS;
36
+ return n;
37
+ }
38
+ /**
39
+ * Race `open` against the configured timeout.
40
+ *
41
+ * Resolves to the opened value, or to undefined when the open outruns the
42
+ * timeout (`onTimeout` fires first so the caller can log and flip its own
43
+ * disabled state). Rejections propagate to the caller unchanged.
44
+ */
45
+ export async function withOpenTimeout(open, onTimeout, timeoutMs = pgOpenTimeoutMs()) {
46
+ // Guard disabled — preserve the original unbounded behavior verbatim.
47
+ if (timeoutMs <= 0)
48
+ return open;
49
+ let timer;
50
+ const expiry = new Promise((resolve) => {
51
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
52
+ // Never hold the process open on account of the guard itself.
53
+ timer.unref?.();
54
+ });
55
+ try {
56
+ // `open` is raced as-is so a rejection rejects the race — and therefore
57
+ // this function — leaving the caller's corrupt-retry path intact.
58
+ const winner = await Promise.race([open, expiry]);
59
+ if (winner === TIMED_OUT) {
60
+ // Disown the open. If it ever settles, close the instance so an orphaned
61
+ // PGlite cannot keep the event loop alive or hold the dataDir lock.
62
+ void open
63
+ .then((late) => {
64
+ try {
65
+ void late?.close?.();
66
+ }
67
+ catch {
68
+ /* ignore */
69
+ }
70
+ })
71
+ .catch(() => {
72
+ /* the abandoned open failed on its own — nothing left to release */
73
+ });
74
+ onTimeout(`timed out after ${timeoutMs}ms`);
75
+ return undefined;
76
+ }
77
+ return winner;
78
+ }
79
+ finally {
80
+ if (timer)
81
+ clearTimeout(timer);
82
+ }
83
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * pgOpenGuard.test.ts — the PGlite open must never hang a turn.
3
+ *
4
+ * Regression cover for the wedge: a stalled `await new PGlite(...)` was cached
5
+ * in initPromise, so every later caller awaited a promise that could not settle
6
+ * and the pi turn awaiting it never ended.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { withOpenTimeout, pgOpenTimeoutMs, DEFAULT_PG_OPEN_TIMEOUT_MS } from "./pgOpenGuard.js";
11
+ test("a never-settling open resolves to undefined instead of hanging", async () => {
12
+ const never = new Promise(() => {
13
+ /* deliberately never settles — the wedge */
14
+ });
15
+ const reasons = [];
16
+ const t0 = Date.now();
17
+ const result = await withOpenTimeout(never, (r) => reasons.push(r), 50);
18
+ assert.equal(result, undefined, "caller gets undefined and can fall back");
19
+ assert.ok(Date.now() - t0 < 5_000, "returned promptly rather than hanging");
20
+ assert.equal(reasons.length, 1, "onTimeout fired exactly once");
21
+ assert.match(reasons[0], /timed out after 50ms/);
22
+ });
23
+ test("a successful open passes its value through untouched", async () => {
24
+ const reasons = [];
25
+ const result = await withOpenTimeout(Promise.resolve("pg"), (r) => reasons.push(r), 5_000);
26
+ assert.equal(result, "pg");
27
+ assert.deepEqual(reasons, [], "no timeout reported on the happy path");
28
+ });
29
+ test("a rejected open propagates so the corrupt-dir retry still runs", async () => {
30
+ const reasons = [];
31
+ await assert.rejects(() => withOpenTimeout(Promise.reject(new Error("Aborted()")), (r) => reasons.push(r), 5_000), /Aborted/, "rejection reaches the caller's catch, which owns the wipe-and-retry path");
32
+ assert.deepEqual(reasons, [], "a rejection is not reported as a timeout");
33
+ });
34
+ test("an abandoned open is closed if it settles after the timeout", async () => {
35
+ let closed = false;
36
+ let release = () => { };
37
+ const late = new Promise((r) => {
38
+ release = r;
39
+ });
40
+ const result = await withOpenTimeout(late, () => { }, 25);
41
+ assert.equal(result, undefined, "timed out first");
42
+ // The open finally completes, long after we stopped waiting for it.
43
+ release({
44
+ close: () => {
45
+ closed = true;
46
+ },
47
+ });
48
+ await late;
49
+ await new Promise((r) => setImmediate(r));
50
+ assert.ok(closed, "the orphaned instance was closed, not left holding the dataDir");
51
+ });
52
+ test("timeout of 0 disables the guard (unbounded, original behavior)", async () => {
53
+ const result = await withOpenTimeout(Promise.resolve("pg"), () => { }, 0);
54
+ assert.equal(result, "pg");
55
+ });
56
+ test("pgOpenTimeoutMs honors the env override and rejects junk", async () => {
57
+ const prev = process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
58
+ try {
59
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = "1234";
60
+ assert.equal(pgOpenTimeoutMs(), 1234);
61
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = "0";
62
+ assert.equal(pgOpenTimeoutMs(), 0, "0 is a valid opt-out, not junk");
63
+ for (const junk of ["", " ", "abc", "-5"]) {
64
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = junk;
65
+ assert.equal(pgOpenTimeoutMs(), DEFAULT_PG_OPEN_TIMEOUT_MS, `junk "${junk}" falls back`);
66
+ }
67
+ }
68
+ finally {
69
+ if (prev === undefined)
70
+ delete process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
71
+ else
72
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = prev;
73
+ }
74
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * repoKey.ts — S25-B: the single repo-scope key for BOTH global PGlite indexes.
3
+ *
4
+ * Before S25 the checkpoint index (vector_index) keyed on stateDir while the
5
+ * memory index (memory_index) keyed on the git root — two scopes that meant
6
+ * cross-repo checkpoint hydration had no way back from repo_id → the repo's
7
+ * store. This helper unifies both indexes on ONE key: the resolved git root,
8
+ * falling back to stateDir outside git worktrees.
9
+ *
10
+ * stateDirForRepo() reverses the mapping via the machine-wide
11
+ * repo_registry (src/store/sqlite/global-index.ts): a repo_id hit from the
12
+ * index resolves to that repo's stateDir so getCheckpoint() can hydrate from
13
+ * the authoritative node:sqlite store. Returns undefined when unresolvable —
14
+ * the caller skips the hit (degrade, never crash).
15
+ *
16
+ * PREVENT-PI-004: `git rev-parse` is local + read-only (annotated below).
17
+ */
18
+ import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the vector index per-repo
19
+ import { getRepoRegistry } from "./sqlite/global-index.js";
20
+ /**
21
+ * Resolve the canonical repo key for a state dir. Git root when inside a
22
+ * worktree, stateDir otherwise. Two repos sharing a git root (e.g. nested
23
+ * checkouts pointing at the same repo) collapse to one scope — intended.
24
+ */
25
+ export function repoKey(stateDir) {
26
+ try {
27
+ const out = execSync("git rev-parse --show-toplevel", {
28
+ cwd: stateDir,
29
+ encoding: "utf-8",
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ }).trim();
32
+ return out || stateDir;
33
+ }
34
+ catch {
35
+ return stateDir;
36
+ }
37
+ }
38
+ /**
39
+ * Reverse map: repo_id → stateDir. Registry hit wins (git-root scope, S25);
40
+ * otherwise the key is assumed to be a legacy/ungit-scoped stateDir and
41
+ * returned verbatim (callers treat undefined-unopenable dirs as skip/degrade).
42
+ */
43
+ export function stateDirForRepo(repoId, indexDir) {
44
+ return getRepoRegistry(repoId, indexDir)?.stateDir ?? repoId;
45
+ }
@@ -20,6 +20,7 @@ import { join } from "node:path";
20
20
  import { mkdirSync, rmSync, existsSync } from "node:fs";
21
21
  /** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
22
22
  export const EMBEDDING_DIM = 512;
23
+ import { withOpenTimeout } from "./pgOpenGuard.js";
23
24
  let db;
24
25
  let initPromise;
25
26
  let disabled = false;
@@ -107,12 +108,18 @@ async function openPgLite(retryOnCorrupt) {
107
108
  return undefined;
108
109
  const dir = indexDir();
109
110
  mkdirSync(dir, { recursive: true });
110
- const pg = await new mod.PGlite({
111
- dataDir: dir,
112
- extensions: { vector: mod.vector },
113
- });
114
- await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
115
- await pg.exec(`
111
+ // Bounded open: PGlite is single-writer over a shared dataDir, so a second
112
+ // pi process on the same dir can block here forever. Without the ceiling the
113
+ // never-settling promise gets cached in initPromise and every later caller
114
+ // awaits it — which is how a stalled index wedged a whole pi turn.
115
+ let openTimedOut = false;
116
+ const pg = await withOpenTimeout((async () => {
117
+ const inst = await new mod.PGlite({
118
+ dataDir: dir,
119
+ extensions: { vector: mod.vector },
120
+ });
121
+ await inst.exec("CREATE EXTENSION IF NOT EXISTS vector;");
122
+ await inst.exec(`
116
123
  CREATE TABLE IF NOT EXISTS vector_index (
117
124
  repo_id TEXT NOT NULL,
118
125
  session_id TEXT NOT NULL,
@@ -121,8 +128,23 @@ async function openPgLite(retryOnCorrupt) {
121
128
  PRIMARY KEY (repo_id, session_id, checkpoint_id)
122
129
  );
123
130
  `);
124
- // HNSW index over cosine distance for fast NN. Created idempotently.
125
- await pg.exec("CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);");
131
+ // HNSW index over cosine distance for fast NN. Created idempotently.
132
+ await inst.exec("CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);");
133
+ return inst;
134
+ })(), (reason) => {
135
+ openTimedOut = true;
136
+ logWarn(`init ${reason}`);
137
+ });
138
+ if (!pg) {
139
+ if (openTimedOut) {
140
+ // Don't leave the dead open cached, and don't retry on the next call —
141
+ // a contended dataDir would just burn another full timeout per caller.
142
+ // Same terminal state as any other init failure: fall back to the scan.
143
+ initPromise = undefined;
144
+ disabled = true;
145
+ }
146
+ return undefined;
147
+ }
126
148
  db = pg;
127
149
  return pg;
128
150
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { test } from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { mkdtempSync, rmSync } from "node:fs";
14
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { EMBEDDING_DIM, initVectorIndex, upsertEmbedding, searchAsync, closeVectorIndex, isVectorIndexDisabled, } from "./vectorIndex.js";
@@ -77,6 +77,30 @@ test("dimension guard: non-512 vectors are skipped, never corrupt the index", as
77
77
  delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
78
78
  }
79
79
  });
80
+ test("corrupt-dir self-heal: torn PGlite dir is deleted + retried, never crashes", async () => {
81
+ delete process.env.MEGACOMPACT_PGLITE_DISABLED;
82
+ const dir = isolateIndexDir();
83
+ try {
84
+ await closeVectorIndex();
85
+ // Tear the dir: garbage bytes where PGlite expects its data/ files.
86
+ mkdirSync(dir, { recursive: true });
87
+ writeFileSync(join(dir, "data"), Buffer.from([0xde, 0xad, 0xbe, 0xef, 0xde, 0xad]));
88
+ // openPgLite(retryOnCorrupt=true) deletes + retries; if that still fails it
89
+ // disables gracefully. Either path must NOT throw.
90
+ const pg = await initVectorIndex();
91
+ assert.ok(pg, "index self-healed after corruption ");
92
+ // And the index works after heal: upsert + search round-trip.
93
+ await upsertEmbedding("/repoE/.pi/mega-compact", "sessE", "chkpt_001", spikeVec(7));
94
+ const hits = await searchAsync(spikeVec(7), { k: 1 });
95
+ assert.equal(hits.length, 1, "search works on the healed index");
96
+ assert.equal(hits[0].checkpointId, "chkpt_001");
97
+ }
98
+ finally {
99
+ await closeVectorIndex();
100
+ rmSync(dir, { recursive: true, force: true });
101
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
102
+ }
103
+ });
80
104
  test("kill-switch: MEGACOMPACT_PGLITE_DISABLED disables the index gracefully", async () => {
81
105
  const dir = isolateIndexDir();
82
106
  process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
@@ -13,6 +13,7 @@
13
13
  import { cosineSimilarity } from "./embedder.js";
14
14
  import { normalizeSessionId } from "./store.js";
15
15
  import { mmrRerank } from "./dedup/mmr.js";
16
+ import { stateDirForRepo } from "./store/repoKey.js";
16
17
  import { topK } from "./dedup/topk.js";
17
18
  import { listCheckpoints, getCheckpoint, maxCheckpointTimestamp, maxRaptorNodeBuiltAt, } from "./store/sqlite.js";
18
19
  import { initVectorIndex, searchAsync as vectorIndexSearch, } from "./store/vectorIndex.js";
@@ -255,13 +256,18 @@ export async function vectorSearchAsync(store, sessionId, query, k = 3, opts = {
255
256
  // Index empty/unavailable → synchronous per-session fallback (this repo).
256
257
  return vectorSearch(store, sid, query, k);
257
258
  }
258
- // Hydrate each index hit from the authoritative node:sqlite store. repoId is
259
- // that repo's stateDir, so cross-repo hits resolve against their own store.
260
- // Tag cross-repo hits with their source repoId so the recall block can label
261
- // them ("from repo <name>"); same-repo hits stay unlabeled.
259
+ // Hydrate each index hit from the authoritative node:sqlite store. The
260
+ // index keys on repo_id (S25: git root via repoKey; legacy rows keyed by
261
+ // stateDir) resolve repo_id stateDir via stateDirForRepo, and skip
262
+ // unresolvable/foreign hits (degrade, never crash). Cross-repo hits carry
263
+ // their source repoId so the recall block can label them; same-repo stays
264
+ // unlabeled.
262
265
  const hydrated = [];
263
266
  for (const h of indexHits) {
264
- const cp = getCheckpoint(h.sessionId, h.checkpointId, h.repoId);
267
+ const hitStateDir = stateDirForRepo(h.repoId);
268
+ if (!hitStateDir)
269
+ continue;
270
+ const cp = getCheckpoint(h.sessionId, h.checkpointId, hitStateDir);
265
271
  if (cp && cp.dedupStatus !== "removed") {
266
272
  const crossRepo = opts.crossRepo && selfRepo && h.repoId && h.repoId !== selfRepo;
267
273
  hydrated.push({
@@ -11,6 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
12
12
  import { loadDedupConfig, } from "./config/dedup.js";
13
13
  import { logDecision } from "./monitoring.js";
14
+ import { repoKey } from "./store/repoKey.js";
14
15
  import { getStateDir, normalizeSessionId, compressSmart } from "./store.js";
15
16
  import { computeContentDigest } from "./dedup/digest.js";
16
17
  import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES, } from "./dedup/l1-minhash.js";
@@ -54,7 +55,9 @@ export class VectorStore {
54
55
  constructor(opts = {}) {
55
56
  this.embedder = opts.embedder ?? defaultEmbedder();
56
57
  this.stateDir = opts.stateDir ?? getStateDir();
57
- this.repoId = opts.repoId ?? this.stateDir;
58
+ // S25: single repo-scope key shared with the memory index (git-root
59
+ // scoped; falls back to stateDir outside git).
60
+ this.repoId = opts.repoId ?? repoKey(this.stateDir);
58
61
  // Sprint 14: all tier flags/thresholds flow from the single config source
59
62
  // (DedupConfig). The legacy opts.dedupSim / opts.l2Enabled remain accepted
60
63
  // for backward-compat callers but flags are authoritative via `cfg`.
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { closeVectorIndex } from "../src/store/vectorIndex.js";
31
+ import { closeMemoryIndex } from "../src/store/memoryIndex.js";
30
32
  import { loadConfig } from "./mega-config.js";
31
33
  import { MegaRuntime } from "./mega-runtime.js";
32
34
  import { registerEventHandlers } from "./mega-events.js";
@@ -82,5 +84,18 @@ export default function (pi: ExtensionAPI) {
82
84
  // dispose() is idempotent, and the next snapshot() re-opens the watcher
83
85
  // lazily via bindRepo() → ensureGameStateWatcher(), so there is no permanent
84
86
  // leak and no per-session fd accumulation.
85
- pi.on("session_shutdown", () => runtime.dispose());
87
+ // The PGlite indexes (vectorIndex / memoryIndex) are lazily opened module
88
+ // singletons. closeVectorIndex()/closeMemoryIndex() existed but had no
89
+ // non-test callers, so a session left both open: PGlite is WASM Postgres and
90
+ // its handles keep node's event loop alive, so `pi -p` produced its answer
91
+ // and then hung until killed rather than exiting. dispose() only released the
92
+ // fs.watch handle and the perf interval, neither of which was the culprit
93
+ // (the interval is unref'd).
94
+ //
95
+ // Both closes are idempotent and safe when the index was never opened, and
96
+ // the next initVectorIndex()/initMemoryIndex() re-opens lazily.
97
+ pi.on("session_shutdown", async () => {
98
+ runtime.dispose();
99
+ await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
100
+ });
86
101
  }
@@ -147,6 +147,13 @@ export interface MegaConfig {
147
147
  * a hit whose summary is ≥ dedupSim similar to a live message — "dedupe on
148
148
  * inline/read" so we never re-inject context already resident. */
149
149
  windowDedupe: boolean;
150
+ /** Render the above-editor TUI widget (MEGACOMPACT_TUI_WIDGET). Default true.
151
+ * Set to 0 to suppress the panel entirely — the widget is a persistent,
152
+ * animated, full-width region that repaints on its own cadence, which fights
153
+ * terminals where the user drives scrollback themselves (notably pi running
154
+ * inside a Neovim `:terminal`, where every repaint yanks the view back to
155
+ * the bottom). Compaction is unaffected; only the panel is suppressed. */
156
+ tuiWidget: boolean;
150
157
  debug: boolean;
151
158
  }
152
159
 
@@ -292,6 +299,7 @@ export function loadConfig(): MegaConfig {
292
299
  memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
293
300
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
294
301
  windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
302
+ tuiWidget: envBool("MEGACOMPACT_TUI_WIDGET", true),
295
303
  debug: envBool("MEGACOMPACT_DEBUG", false),
296
304
  };
297
305
  }
@@ -14,6 +14,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
14
  import { compactSession } from "../../src/engine.js";
15
15
  import type { EngineMessage } from "../../src/types.js";
16
16
  import { normalizeSessionId } from "../../src/store.js";
17
+ import { repoKey } from "../../src/store/repoKey.js";
17
18
  import { estimateBlockTokens } from "../../src/tokens.js";
18
19
  import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../src/store/sqlite.js";
19
20
  import { consolidateMemories } from "../../src/memory.js";
@@ -265,7 +266,7 @@ function doCompact(
265
266
  const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
266
267
  if (latest?.embedding) {
267
268
  void indexUpsertEmbedding(
268
- runtime.currentStateDir,
269
+ repoKey(runtime.currentStateDir),
269
270
  sid,
270
271
  latest.checkpointId,
271
272
  latest.embedding,