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.
- package/README.md +12 -9
- package/dist/extensions/mega-compact.js +16 -1
- package/dist/extensions/mega-config.js +1 -0
- package/dist/extensions/mega-pipeline/compact.js +2 -1
- package/dist/extensions/mega-runtime/reset-runtime.js +8 -0
- package/dist/extensions/mega-runtime/runtime.js +12 -50
- package/dist/extensions/mega-shutdown-widget.test.js +121 -0
- package/dist/src/compact.js +4 -2
- package/dist/src/dedup/raptor/tree.js +11 -0
- package/dist/src/memory.test.js +29 -0
- package/dist/src/memoryOps.js +4 -19
- package/dist/src/memoryRecall.test.js +27 -0
- package/dist/src/memoryRoundtrip.test.js +137 -0
- package/dist/src/recall.js +5 -4
- package/dist/src/sprint4x-rag-verification.test.js +93 -0
- package/dist/src/store/memoryIndex.js +29 -7
- package/dist/src/store/pgOpenGuard.js +83 -0
- package/dist/src/store/pgOpenGuard.test.js +74 -0
- package/dist/src/store/repoKey.js +45 -0
- package/dist/src/store/vectorIndex.js +30 -8
- package/dist/src/store/vectorIndex.test.js +25 -1
- package/dist/src/vector-search.js +11 -5
- package/dist/src/vectorStore.js +4 -1
- package/extensions/mega-compact.ts +16 -1
- package/extensions/mega-config.ts +8 -0
- package/extensions/mega-pipeline/compact.ts +2 -1
- package/extensions/mega-runtime/reset-runtime.ts +88 -0
- package/extensions/mega-runtime/runtime.ts +27 -59
- package/extensions/mega-shutdown-widget.test.ts +141 -0
- package/package.json +1 -1
- package/src/compact.ts +209 -174
- package/src/dedup/raptor/tree.ts +11 -0
- package/src/memory.test.ts +47 -1
- package/src/memoryOps.ts +4 -19
- package/src/memoryRecall.test.ts +36 -0
- package/src/memoryRoundtrip.test.ts +155 -0
- package/src/recall.ts +6 -3
- package/src/sprint4x-rag-verification.test.ts +119 -0
- package/src/store/memoryIndex.ts +34 -8
- package/src/store/pgOpenGuard.test.ts +89 -0
- package/src/store/pgOpenGuard.ts +93 -0
- package/src/store/repoKey.ts +50 -0
- package/src/store/vectorIndex.test.ts +25 -1
- package/src/store/vectorIndex.ts +35 -9
- package/src/vector-search.ts +10 -5
- 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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await
|
|
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
|
-
|
|
125
|
-
|
|
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.
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
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
|
|
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({
|
package/dist/src/vectorStore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|