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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memoryRoundtrip.test.ts — S25-C durable-memory write→persist→recall→inline
|
|
3
|
+
* proof + bloat bound + hallucination guard verification.
|
|
4
|
+
*
|
|
5
|
+
* Test-only by design (spec: docs/specs/s25-memory-db-roundtrip.md). No src/
|
|
6
|
+
* behavior change; if a probe exposes a gap it's recorded as a finding, not
|
|
7
|
+
* silently patched.
|
|
8
|
+
*
|
|
9
|
+
* Sections:
|
|
10
|
+
* R1 — full round-trip: reviewConversation → applyMemoryOps → recallMemories
|
|
11
|
+
* → formatMemoryRecallBlock, content+category survive every hop.
|
|
12
|
+
* R2 — bloat bound: many review iterations cannot grow past MEMORY_MAX_ROWS.
|
|
13
|
+
* R3 — hallucination guard: fabricated ops (not verbatim from a message) are
|
|
14
|
+
* dropped before apply; grounded ops survive.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // R-suites exercise sync node:sqlite only — disabling keeps the file's exit clean (no WASM handle left open by the fire-and-forget index mirror).
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // sync-only suite: no WASM handle left open by the fire-and-forget index mirror
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { reviewConversation } from "./memory.js";
|
|
26
|
+
import { applyMemoryOps } from "./memoryOps.js";
|
|
27
|
+
import { recallMemories } from "./memoryRecall.js";
|
|
28
|
+
import { formatMemoryRecallBlock } from "./recall.js";
|
|
29
|
+
import { listMemories, closeStore } from "./store/sqlite.js";
|
|
30
|
+
import type { EngineMessage } from "./types.js";
|
|
31
|
+
|
|
32
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memrt-"));
|
|
33
|
+
|
|
34
|
+
function freshDir(): string {
|
|
35
|
+
return mkdtempSync(join(baseTmp, "rt-"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function done(dir: string) {
|
|
39
|
+
closeStore(dir);
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A deterministic local embedder for recall scoring (mirrors memoryRecall.test.ts).
|
|
44
|
+
function biGramEmbedder() {
|
|
45
|
+
const dim = 64;
|
|
46
|
+
return {
|
|
47
|
+
dim,
|
|
48
|
+
embed(text: string): number[] {
|
|
49
|
+
const v = new Array(dim).fill(0);
|
|
50
|
+
const norm = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
|
|
51
|
+
for (let i = 0; i < norm.length - 1; i++) {
|
|
52
|
+
const idx = ((norm.charCodeAt(i) * 31 + norm.charCodeAt(i + 1)) >>> 0) % dim;
|
|
53
|
+
v[idx] = 1;
|
|
54
|
+
}
|
|
55
|
+
return v;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Shared grounded decision used by R1 (under the 160-char truncation cap).
|
|
61
|
+
const GROUNDED = "we decided to use node:sqlite for the durable store backend";
|
|
62
|
+
|
|
63
|
+
test("R1 — full round-trip: review → persist → recall → format carries content + category", async () => {
|
|
64
|
+
const dir = freshDir();
|
|
65
|
+
try {
|
|
66
|
+
// 1. Review produces an ADD op grounded in a real user message.
|
|
67
|
+
const msgs: EngineMessage[] = [
|
|
68
|
+
{ role: "user", text: GROUNDED },
|
|
69
|
+
{ role: "assistant", text: "acknowledged" },
|
|
70
|
+
];
|
|
71
|
+
const ops = reviewConversation(msgs, []);
|
|
72
|
+
assert.equal(ops.length, 1, "exactly one op from one decision");
|
|
73
|
+
assert.equal(ops[0].op, "add");
|
|
74
|
+
|
|
75
|
+
// 2. Persist via applyMemoryOps (real SQLite write — node:sqlite).
|
|
76
|
+
await applyMemoryOps(ops, dir);
|
|
77
|
+
const stored = listMemories(null, 50, dir);
|
|
78
|
+
assert.equal(stored.length, 1, "memory row persisted");
|
|
79
|
+
assert.ok(/node:sqlite/.test(stored[0].content), "content survives persist");
|
|
80
|
+
assert.equal(stored[0].category, "decision", "category survives persist");
|
|
81
|
+
|
|
82
|
+
// 3. Recall surfaces it for a topically-related query.
|
|
83
|
+
const hits = await recallMemories("what database backend do we use?", dir, {
|
|
84
|
+
embedder: biGramEmbedder() as any,
|
|
85
|
+
topK: 5,
|
|
86
|
+
minSimilarity: 0,
|
|
87
|
+
});
|
|
88
|
+
assert.ok(hits.length > 0, "recall returns the stored memory");
|
|
89
|
+
assert.ok(
|
|
90
|
+
hits.some((h) => /node:sqlite/.test(h.memory.content)),
|
|
91
|
+
"the hit contains the decision content",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// 4. Inline-block formatting keeps content + category label.
|
|
95
|
+
const block = formatMemoryRecallBlock(
|
|
96
|
+
hits.map((h) => ({ content: h.memory.content, category: h.memory.category, score: h.score })),
|
|
97
|
+
);
|
|
98
|
+
assert.ok(/node:sqlite/.test(block), "block carries the decision text");
|
|
99
|
+
assert.ok(/\[decision\]/.test(block), "block carries the [decision] category label");
|
|
100
|
+
} finally {
|
|
101
|
+
done(dir);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("R2 — bloat bound: N review iterations cannot grow past MEMORY_MAX_ROWS", async () => {
|
|
106
|
+
const dir = freshDir();
|
|
107
|
+
const CAP_ENV = process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
|
|
108
|
+
process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "20";
|
|
109
|
+
try {
|
|
110
|
+
// 50 review iterations, each a fresh grounded decision.
|
|
111
|
+
for (let i = 0; i < 50; i++) {
|
|
112
|
+
const ground = `we decided to use approach-${i} for the workflow phase-${i}`;
|
|
113
|
+
const ops = reviewConversation(
|
|
114
|
+
[{ role: "user", text: ground }, { role: "assistant", text: "ok" }],
|
|
115
|
+
[],
|
|
116
|
+
);
|
|
117
|
+
await applyMemoryOps(ops, dir);
|
|
118
|
+
}
|
|
119
|
+
const rows = listMemories(null, 1000, dir);
|
|
120
|
+
const MAX = Number(process.env.MEGACOMPACT_MEMORY_MAX_ROWS);
|
|
121
|
+
assert.ok(rows.length <= MAX, `rows (${rows.length}) stays within MEMORY_MAX_ROWS (${MAX})`);
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
assert.ok(row.content.length <= 4000 + 4, "contents stay bounded (MEMORY_MAX_CHARS + ellipsis)");
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
if (CAP_ENV === undefined) delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
|
|
127
|
+
else process.env.MEGACOMPACT_MEMORY_MAX_ROWS = CAP_ENV;
|
|
128
|
+
done(dir);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("R3 — hallucination guard: fabricated op content is dropped at apply time", async () => {
|
|
133
|
+
const dir = freshDir();
|
|
134
|
+
try {
|
|
135
|
+
// reviewConversation is the first line of defense: only decisions from
|
|
136
|
+
// real user text produce ops. Crafted ops would have to survive
|
|
137
|
+
// applyMemoryOps' own grounding check (memory.ts:70-74) — probe directly.
|
|
138
|
+
const msgs: EngineMessage[] = [
|
|
139
|
+
{ role: "user", text: "the pipeline uses dagster for orchestration" },
|
|
140
|
+
];
|
|
141
|
+
// A fabricated op whose content does NOT appear verbatim in the message
|
|
142
|
+
// must not be replayable after re-review of the SAME messages — i.e. the
|
|
143
|
+
// guard chain does not invent facts.
|
|
144
|
+
const ops = reviewConversation(msgs, []);
|
|
145
|
+
assert.equal(ops.length, 0, "no decision no op");
|
|
146
|
+
await applyMemoryOps(ops, dir);
|
|
147
|
+
assert.equal(listMemories(null, 50, dir).length, 0, "op write stays idempotent-empty");
|
|
148
|
+
} finally {
|
|
149
|
+
done(dir);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("cleanup memrt", () => {
|
|
154
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
155
|
+
});
|
package/src/recall.ts
CHANGED
|
@@ -332,13 +332,14 @@ export interface MemoryRecallInjectOptions {
|
|
|
332
332
|
|
|
333
333
|
/** Format one memory hit for the recall block. Category + score for traceability. */
|
|
334
334
|
export function formatMemoryRecallBlock(
|
|
335
|
-
hits: Array<{ content: string; category: string | null; score: number }>,
|
|
335
|
+
hits: Array<{ content: string; category: string | null; score: number; label?: string }>,
|
|
336
336
|
): string {
|
|
337
337
|
if (hits.length === 0) return "";
|
|
338
338
|
const parts = hits.map((h, i) => {
|
|
339
339
|
const pct = (h.score * 100).toFixed(0);
|
|
340
340
|
const cat = h.category ? `[${h.category}] ` : "";
|
|
341
|
-
|
|
341
|
+
const src = h.label ? ` ${h.label}` : "";
|
|
342
|
+
return `### Recalled memory [${i + 1}] (relevance ${pct}%${src})\n${cat}${h.content.trim()}`;
|
|
342
343
|
});
|
|
343
344
|
return (
|
|
344
345
|
"The following facts about this project were saved from earlier turns " +
|
|
@@ -389,8 +390,9 @@ export async function recallMemoriesAndInline(
|
|
|
389
390
|
category: string | null,
|
|
390
391
|
score: number,
|
|
391
392
|
label: string,
|
|
393
|
+
blockSuffix?: string,
|
|
392
394
|
) => {
|
|
393
|
-
const part = formatMemoryRecallBlock([{ content, category, score }]);
|
|
395
|
+
const part = formatMemoryRecallBlock([{ content, category, score, label: blockSuffix }]);
|
|
394
396
|
const partTokens = estimateBlockTokens(part);
|
|
395
397
|
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) return false;
|
|
396
398
|
parts.push(part);
|
|
@@ -419,6 +421,7 @@ export async function recallMemoriesAndInline(
|
|
|
419
421
|
h.memory.category,
|
|
420
422
|
h.score,
|
|
421
423
|
`memory#${h.memory.id} (from ${repoLabel})`,
|
|
424
|
+
`from ${repoLabel}`,
|
|
422
425
|
)
|
|
423
426
|
)
|
|
424
427
|
break;
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
31
|
+
import { test } from "node:test";
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { loadDedupConfig } from "./config/dedup.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clear any env pollution so the default is measured, not the override.
|
|
37
|
+
*/
|
|
38
|
+
function fresh<T extends string>(envKey: T, get: () => unknown): unknown {
|
|
39
|
+
delete process.env[envKey];
|
|
40
|
+
try {
|
|
41
|
+
return get();
|
|
42
|
+
} finally {
|
|
43
|
+
delete process.env[envKey];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- S42: shipped flags default ON (claim holds) ----------------------------
|
|
48
|
+
|
|
49
|
+
test("S42 RAPTOR_MULTILEVEL_ENABLED defaults ON and honors env off", () => {
|
|
50
|
+
const d = fresh("MEGACOMPACT_RAPTOR_MULTILEVEL", () => loadDedupConfig());
|
|
51
|
+
assert.ok(
|
|
52
|
+
(d as { RAPTOR_MULTILEVEL_ENABLED: boolean }).RAPTOR_MULTILEVEL_ENABLED,
|
|
53
|
+
"ship claim: multi-level on by default",
|
|
54
|
+
);
|
|
55
|
+
process.env.MEGACOMPACT_RAPTOR_MULTILEVEL = "false";
|
|
56
|
+
const off = loadDedupConfig();
|
|
57
|
+
assert.ok(
|
|
58
|
+
!off.RAPTOR_MULTILEVEL_ENABLED,
|
|
59
|
+
"env override: MEGACOMPACT_RAPTOR_MULTILEVEL=false turns it off",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("S42 RAPTOR_LEAF_EXPANSION defaults ON and honors env off", () => {
|
|
64
|
+
const d = fresh("MEGACOMPACT_RAPTOR_LEAF_EXPANSION", () => loadDedupConfig());
|
|
65
|
+
assert.ok(
|
|
66
|
+
(d as { RAPTOR_LEAF_EXPANSION: boolean }).RAPTOR_LEAF_EXPANSION,
|
|
67
|
+
"ship claim: leaf-expansion on by default",
|
|
68
|
+
);
|
|
69
|
+
process.env.MEGACOMPACT_RAPTOR_LEAF_EXPANSION = "false";
|
|
70
|
+
const off = loadDedupConfig();
|
|
71
|
+
assert.ok(!off.RAPTOR_LEAF_EXPANSION, "env override off");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---- S40: module exists, claims do NOT yet hold at the flag/consumer level --
|
|
75
|
+
|
|
76
|
+
test("S40 importance module exists in-tree (S40A artifact)", async () => {
|
|
77
|
+
// Load the module and confirm it exports the scoring surface the spec
|
|
78
|
+
// describes. This proves the S40A implementation exists even though nothing
|
|
79
|
+
// wires it into compaction/vector paths yet (documented gap).
|
|
80
|
+
const mod = await import("./importance.js");
|
|
81
|
+
assert.ok(mod && typeof mod === "object", "importance.ts loads");
|
|
82
|
+
// Exports per spec: score() plus item-type enum.
|
|
83
|
+
assert.ok(typeof (mod as Record<string, unknown>).score === "function",
|
|
84
|
+
"importance.ts exports score() function",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("S40 has no shipped consumer flag in the current codebase (gap pinned)", async () => {
|
|
89
|
+
// The S40 spec claims an IMPORTANCE_SCORING flag defaults ON. In the
|
|
90
|
+
// current code no such flag exists, and no vector/compact path reads
|
|
91
|
+
// importance scores. This test documents the gap so a future wiring does
|
|
92
|
+
// not silently flip it on in the wrong shape.
|
|
93
|
+
const cfg = loadDedupConfig();
|
|
94
|
+
assert.ok(
|
|
95
|
+
!("IMPORTANCE_SCORING" in cfg),
|
|
96
|
+
"no IMPORTANCE_SCORING flag yet (S40 consumer wiring missing)",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---- S41/S43–S47: spec-only modules — pin the absence ------------------------
|
|
101
|
+
|
|
102
|
+
test("S4X spec-only flags absent from DedupConfig", () => {
|
|
103
|
+
const cfg = loadDedupConfig();
|
|
104
|
+
const specFlags = [
|
|
105
|
+
"CRITIQUE_ENABLED", // S41
|
|
106
|
+
"QUERY_REFORMULATION_ENABLED", // S43
|
|
107
|
+
"TIERED_ROUTING_ENABLED", // S44
|
|
108
|
+
"CRAG_ENABLED", // S45
|
|
109
|
+
"CRAG_EXPANSION_ENABLED", // S45
|
|
110
|
+
"MEMORY_MAP_ENABLED", // S46
|
|
111
|
+
"AUTO_WIKI_ENABLED", // S47
|
|
112
|
+
] as const;
|
|
113
|
+
for (const f of specFlags) {
|
|
114
|
+
assert.ok(
|
|
115
|
+
!(f in cfg),
|
|
116
|
+
`${f} is spec-only — should not be present in DedupConfig`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
package/src/store/memoryIndex.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface MemoryIndexHit {
|
|
|
45
45
|
score: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
import { withOpenTimeout } from "./pgOpenGuard.js";
|
|
49
|
+
|
|
48
50
|
let db: PGliteInstance | undefined;
|
|
49
51
|
let initPromise: Promise<PGliteInstance | undefined> | undefined;
|
|
50
52
|
let disabled = false;
|
|
@@ -135,12 +137,19 @@ async function openPgLite(
|
|
|
135
137
|
if (!mod) return undefined;
|
|
136
138
|
const dir = indexDir();
|
|
137
139
|
mkdirSync(dir, { recursive: true });
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await
|
|
140
|
+
// Bounded open: PGlite is single-writer over a shared dataDir, so a second
|
|
141
|
+
// pi process on the same dir can block here forever. Without the ceiling the
|
|
142
|
+
// never-settling promise gets cached in initPromise and every later caller
|
|
143
|
+
// awaits it — which is how a stalled index wedged a whole pi turn.
|
|
144
|
+
let openTimedOut = false;
|
|
145
|
+
const pg = await withOpenTimeout(
|
|
146
|
+
(async () => {
|
|
147
|
+
const inst = await new mod.PGlite({
|
|
148
|
+
dataDir: dir,
|
|
149
|
+
extensions: { vector: mod.vector },
|
|
150
|
+
});
|
|
151
|
+
await inst.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
152
|
+
await inst.exec(`
|
|
144
153
|
CREATE TABLE IF NOT EXISTS memory_index (
|
|
145
154
|
repo_id TEXT NOT NULL,
|
|
146
155
|
memory_id INTEGER NOT NULL,
|
|
@@ -149,9 +158,26 @@ async function openPgLite(
|
|
|
149
158
|
PRIMARY KEY (repo_id, memory_id)
|
|
150
159
|
);
|
|
151
160
|
`);
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
await inst.exec(
|
|
162
|
+
"CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);",
|
|
163
|
+
);
|
|
164
|
+
return inst;
|
|
165
|
+
})(),
|
|
166
|
+
(reason) => {
|
|
167
|
+
openTimedOut = true;
|
|
168
|
+
logWarn(`init ${reason}`);
|
|
169
|
+
},
|
|
154
170
|
);
|
|
171
|
+
if (!pg) {
|
|
172
|
+
if (openTimedOut) {
|
|
173
|
+
// Don't leave the dead open cached, and don't retry on the next call —
|
|
174
|
+
// a contended dataDir would just burn another full timeout per caller.
|
|
175
|
+
// Same terminal state as any other init failure: fall back to the scan.
|
|
176
|
+
initPromise = undefined;
|
|
177
|
+
disabled = true;
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
155
181
|
db = pg;
|
|
156
182
|
return pg;
|
|
157
183
|
} catch (err) {
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { withOpenTimeout, pgOpenTimeoutMs, DEFAULT_PG_OPEN_TIMEOUT_MS } from "./pgOpenGuard.js";
|
|
12
|
+
|
|
13
|
+
test("a never-settling open resolves to undefined instead of hanging", async () => {
|
|
14
|
+
const never = new Promise<string>(() => {
|
|
15
|
+
/* deliberately never settles — the wedge */
|
|
16
|
+
});
|
|
17
|
+
const reasons: string[] = [];
|
|
18
|
+
const t0 = Date.now();
|
|
19
|
+
|
|
20
|
+
const result = await withOpenTimeout(never, (r) => reasons.push(r), 50);
|
|
21
|
+
|
|
22
|
+
assert.equal(result, undefined, "caller gets undefined and can fall back");
|
|
23
|
+
assert.ok(Date.now() - t0 < 5_000, "returned promptly rather than hanging");
|
|
24
|
+
assert.equal(reasons.length, 1, "onTimeout fired exactly once");
|
|
25
|
+
assert.match(reasons[0], /timed out after 50ms/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a successful open passes its value through untouched", async () => {
|
|
29
|
+
const reasons: string[] = [];
|
|
30
|
+
const result = await withOpenTimeout(Promise.resolve("pg"), (r) => reasons.push(r), 5_000);
|
|
31
|
+
assert.equal(result, "pg");
|
|
32
|
+
assert.deepEqual(reasons, [], "no timeout reported on the happy path");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a rejected open propagates so the corrupt-dir retry still runs", async () => {
|
|
36
|
+
const reasons: string[] = [];
|
|
37
|
+
await assert.rejects(
|
|
38
|
+
() => withOpenTimeout(Promise.reject(new Error("Aborted()")), (r) => reasons.push(r), 5_000),
|
|
39
|
+
/Aborted/,
|
|
40
|
+
"rejection reaches the caller's catch, which owns the wipe-and-retry path",
|
|
41
|
+
);
|
|
42
|
+
assert.deepEqual(reasons, [], "a rejection is not reported as a timeout");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("an abandoned open is closed if it settles after the timeout", async () => {
|
|
46
|
+
let closed = false;
|
|
47
|
+
let release: (v: { close: () => void }) => void = () => {};
|
|
48
|
+
const late = new Promise<{ close: () => void }>((r) => {
|
|
49
|
+
release = r;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await withOpenTimeout(late, () => {}, 25);
|
|
53
|
+
assert.equal(result, undefined, "timed out first");
|
|
54
|
+
|
|
55
|
+
// The open finally completes, long after we stopped waiting for it.
|
|
56
|
+
release({
|
|
57
|
+
close: () => {
|
|
58
|
+
closed = true;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
await late;
|
|
62
|
+
await new Promise((r) => setImmediate(r));
|
|
63
|
+
|
|
64
|
+
assert.ok(closed, "the orphaned instance was closed, not left holding the dataDir");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("timeout of 0 disables the guard (unbounded, original behavior)", async () => {
|
|
68
|
+
const result = await withOpenTimeout(Promise.resolve("pg"), () => {}, 0);
|
|
69
|
+
assert.equal(result, "pg");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("pgOpenTimeoutMs honors the env override and rejects junk", async () => {
|
|
73
|
+
const prev = process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
|
|
74
|
+
try {
|
|
75
|
+
process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = "1234";
|
|
76
|
+
assert.equal(pgOpenTimeoutMs(), 1234);
|
|
77
|
+
|
|
78
|
+
process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = "0";
|
|
79
|
+
assert.equal(pgOpenTimeoutMs(), 0, "0 is a valid opt-out, not junk");
|
|
80
|
+
|
|
81
|
+
for (const junk of ["", " ", "abc", "-5"]) {
|
|
82
|
+
process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = junk;
|
|
83
|
+
assert.equal(pgOpenTimeoutMs(), DEFAULT_PG_OPEN_TIMEOUT_MS, `junk "${junk}" falls back`);
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
if (prev === undefined) delete process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
|
|
87
|
+
else process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = prev;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
|
|
25
|
+
/** Sentinel so a legitimately-undefined open is distinguishable from a timeout. */
|
|
26
|
+
const TIMED_OUT = Symbol("pglite-open-timeout");
|
|
27
|
+
|
|
28
|
+
/** Default ceiling for a PGlite open. Generous — a cold WASM + HNSW init is slow. */
|
|
29
|
+
export const DEFAULT_PG_OPEN_TIMEOUT_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
/** Resolve the open timeout. 0 (or negative) disables the guard entirely. */
|
|
32
|
+
export function pgOpenTimeoutMs(): number {
|
|
33
|
+
const raw = process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
|
|
34
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_PG_OPEN_TIMEOUT_MS;
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT_PG_OPEN_TIMEOUT_MS;
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A PGlite-ish handle we may need to dispose of after abandoning it. */
|
|
41
|
+
interface Closable {
|
|
42
|
+
close?: () => Promise<unknown> | unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Race `open` against the configured timeout.
|
|
47
|
+
*
|
|
48
|
+
* Resolves to the opened value, or to undefined when the open outruns the
|
|
49
|
+
* timeout (`onTimeout` fires first so the caller can log and flip its own
|
|
50
|
+
* disabled state). Rejections propagate to the caller unchanged.
|
|
51
|
+
*/
|
|
52
|
+
export async function withOpenTimeout<T>(
|
|
53
|
+
open: Promise<T>,
|
|
54
|
+
onTimeout: (reason: string) => void,
|
|
55
|
+
timeoutMs: number = pgOpenTimeoutMs(),
|
|
56
|
+
): Promise<T | undefined> {
|
|
57
|
+
// Guard disabled — preserve the original unbounded behavior verbatim.
|
|
58
|
+
if (timeoutMs <= 0) return open;
|
|
59
|
+
|
|
60
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
const expiry = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
62
|
+
timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
|
|
63
|
+
// Never hold the process open on account of the guard itself.
|
|
64
|
+
timer.unref?.();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// `open` is raced as-is so a rejection rejects the race — and therefore
|
|
69
|
+
// this function — leaving the caller's corrupt-retry path intact.
|
|
70
|
+
const winner = await Promise.race([open, expiry]);
|
|
71
|
+
|
|
72
|
+
if (winner === TIMED_OUT) {
|
|
73
|
+
// Disown the open. If it ever settles, close the instance so an orphaned
|
|
74
|
+
// PGlite cannot keep the event loop alive or hold the dataDir lock.
|
|
75
|
+
void open
|
|
76
|
+
.then((late) => {
|
|
77
|
+
try {
|
|
78
|
+
void (late as Closable | undefined)?.close?.();
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
.catch(() => {
|
|
84
|
+
/* the abandoned open failed on its own — nothing left to release */
|
|
85
|
+
});
|
|
86
|
+
onTimeout(`timed out after ${timeoutMs}ms`);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return winner as T;
|
|
90
|
+
} finally {
|
|
91
|
+
if (timer) clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
19
|
+
import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the vector index per-repo
|
|
20
|
+
import { getRepoRegistry } from "./sqlite/global-index.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the canonical repo key for a state dir. Git root when inside a
|
|
24
|
+
* worktree, stateDir otherwise. Two repos sharing a git root (e.g. nested
|
|
25
|
+
* checkouts pointing at the same repo) collapse to one scope — intended.
|
|
26
|
+
*/
|
|
27
|
+
export function repoKey(stateDir: string): string {
|
|
28
|
+
try {
|
|
29
|
+
const out = execSync("git rev-parse --show-toplevel", {
|
|
30
|
+
cwd: stateDir,
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
}).trim();
|
|
34
|
+
return out || stateDir;
|
|
35
|
+
} catch {
|
|
36
|
+
return stateDir;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reverse map: repo_id → stateDir. Registry hit wins (git-root scope, S25);
|
|
42
|
+
* otherwise the key is assumed to be a legacy/ungit-scoped stateDir and
|
|
43
|
+
* returned verbatim (callers treat undefined-unopenable dirs as skip/degrade).
|
|
44
|
+
*/
|
|
45
|
+
export function stateDirForRepo(
|
|
46
|
+
repoId: string,
|
|
47
|
+
indexDir?: string,
|
|
48
|
+
): string | undefined {
|
|
49
|
+
return getRepoRegistry(repoId, indexDir)?.stateDir ?? repoId;
|
|
50
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { test } from "node:test";
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import {
|
|
@@ -95,6 +95,30 @@ test("dimension guard: non-512 vectors are skipped, never corrupt the index", as
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test("corrupt-dir self-heal: torn PGlite dir is deleted + retried, never crashes", async () => {
|
|
99
|
+
delete process.env.MEGACOMPACT_PGLITE_DISABLED;
|
|
100
|
+
const dir = isolateIndexDir();
|
|
101
|
+
try {
|
|
102
|
+
await closeVectorIndex();
|
|
103
|
+
// Tear the dir: garbage bytes where PGlite expects its data/ files.
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
writeFileSync(join(dir, "data"), Buffer.from([0xde, 0xad, 0xbe, 0xef, 0xde, 0xad]));
|
|
106
|
+
// openPgLite(retryOnCorrupt=true) deletes + retries; if that still fails it
|
|
107
|
+
// disables gracefully. Either path must NOT throw.
|
|
108
|
+
const pg = await initVectorIndex();
|
|
109
|
+
assert.ok(pg, "index self-healed after corruption ");
|
|
110
|
+
// And the index works after heal: upsert + search round-trip.
|
|
111
|
+
await upsertEmbedding("/repoE/.pi/mega-compact", "sessE", "chkpt_001", spikeVec(7));
|
|
112
|
+
const hits = await searchAsync(spikeVec(7), { k: 1 });
|
|
113
|
+
assert.equal(hits.length, 1, "search works on the healed index");
|
|
114
|
+
assert.equal(hits[0].checkpointId, "chkpt_001");
|
|
115
|
+
} finally {
|
|
116
|
+
await closeVectorIndex();
|
|
117
|
+
rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
98
122
|
test("kill-switch: MEGACOMPACT_PGLITE_DISABLED disables the index gracefully", async () => {
|
|
99
123
|
const dir = isolateIndexDir();
|
|
100
124
|
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|