pi-mega-compact 0.9.0 → 0.9.2

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/src/compact.ts CHANGED
@@ -14,30 +14,31 @@ const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
14
14
  const PENDING_WORDS = ["todo", "next", "pending", "follow up", "remaining"];
15
15
 
16
16
  const COMPACT_PREAMBLE =
17
- "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
17
+ "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
18
18
  const RECENT_NOTE = "Recent messages are preserved verbatim.";
19
- const DIRECT_RESUME = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
19
+ const DIRECT_RESUME =
20
+ "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
20
21
 
21
22
  function truncate(s: string, max: number): string {
22
- return s.length <= max ? s : `${s.slice(0, max)}…`;
23
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
23
24
  }
24
25
 
25
26
  function firstText(m: EngineMessage): string | undefined {
26
- // PREVENT crash: pi can hand us a message with text: undefined (pure
27
- // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
28
- // path can't throw the same undefined-text crash the extractive path did.
29
- const raw = m.text ?? "";
30
- const t = raw.trim();
31
- return t.length > 0 ? t : undefined;
27
+ // PREVENT crash: pi can hand us a message with text: undefined (pure
28
+ // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
29
+ // path can't throw the same undefined-text crash the extractive path did.
30
+ const raw = m.text ?? "";
31
+ const t = raw.trim();
32
+ return t.length > 0 ? t : undefined;
32
33
  }
33
34
 
34
35
  /** Heuristic: does this text look like chatty filler we can collapse? */
35
36
  export function isChatty(text: string): boolean {
36
- const low = text.toLowerCase();
37
- if (low.includes("hello") || low.includes("thanks") || low.includes("great") || low.includes("ok")) {
38
- return true;
39
- }
40
- return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
37
+ const low = text.toLowerCase();
38
+ if (/\b(hello|thanks|great|ok)\b/i.test(low)) {
39
+ return true;
40
+ }
41
+ return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
41
42
  }
42
43
 
43
44
  /** Extract plausible file paths (contain '/' + an interesting extension).
@@ -45,101 +46,110 @@ export function isChatty(text: string): boolean {
45
46
  * a message whose `text`/`input`/`output` is undefined (e.g. a pure tool-call
46
47
  * or tool-result message), and `.split` on undefined throws and takes down the
47
48
  * whole compaction. Guard once at the source so every caller is safe. */
48
- export function extractFileCandidates(content: string | undefined | null): string[] {
49
- if (!content) return [];
50
- const out: string[] = [];
51
- for (const raw of content.split(/\s+/)) {
52
- // Trim surrounding punctuation only — do NOT strip internal dots, or we
53
- // would erase the extension separator (src/server.ts -> src/server/ts).
54
- const token = raw.replace(/^[^A-Za-z0-9/]+|[^A-Za-z0-9/]+$/g, "");
55
- if (!token.includes("/") || !token.includes(".")) continue;
56
- const ext = token.split(".").pop()?.toLowerCase() ?? "";
57
- if (INTERESTING_EXT.has(ext)) out.push(token);
58
- }
59
- return out;
49
+ export function extractFileCandidates(
50
+ content: string | undefined | null,
51
+ ): string[] {
52
+ if (!content) return [];
53
+ const out: string[] = [];
54
+ for (const raw of content.split(/\s+/)) {
55
+ // Trim surrounding punctuation only — do NOT strip internal dots, or we
56
+ // would erase the extension separator (src/server.ts -> src/server/ts).
57
+ const token = raw.replace(/^[^A-Za-z0-9/]+|[^A-Za-z0-9/]+$/g, "");
58
+ if (!token.includes("/") || !token.includes(".")) continue;
59
+ const ext = token.split(".").pop()?.toLowerCase() ?? "";
60
+ if (INTERESTING_EXT.has(ext)) out.push(token);
61
+ }
62
+ return out;
60
63
  }
61
64
 
62
65
  /** Collect unique key files referenced across a set of messages. */
63
66
  export function collectKeyFiles(messages: EngineMessage[]): string[] {
64
- const files = new Set<string>();
65
- for (const m of messages) {
66
- for (const c of [m.text, m.input, m.output]) {
67
- if (!c) continue;
68
- for (const f of extractFileCandidates(c)) files.add(f);
69
- }
70
- }
71
- return [...files].slice(0, 8);
67
+ const files = new Set<string>();
68
+ for (const m of messages) {
69
+ for (const c of [m.text, m.input, m.output]) {
70
+ if (!c) continue;
71
+ for (const f of extractFileCandidates(c)) files.add(f);
72
+ }
73
+ }
74
+ return [...files].slice(0, 8);
72
75
  }
73
76
 
74
77
  /** Infer pending work from recent messages via keyword scan. */
75
78
  export function inferPendingWork(messages: EngineMessage[]): string[] {
76
- const out: string[] = [];
77
- for (const m of [...messages].reverse()) {
78
- const t = firstText(m);
79
- if (!t) continue;
80
- const low = t.toLowerCase();
81
- if (PENDING_WORDS.some((w) => low.includes(w))) {
82
- out.push(truncate(t, 160));
83
- if (out.length >= 3) break;
84
- }
85
- }
86
- return out.reverse();
79
+ const out: string[] = [];
80
+ for (const m of [...messages].reverse()) {
81
+ const t = firstText(m);
82
+ if (!t) continue;
83
+ const low = t.toLowerCase();
84
+ if (PENDING_WORDS.some((w) => low.includes(w))) {
85
+ out.push(truncate(t, 160));
86
+ if (out.length >= 3) break;
87
+ }
88
+ }
89
+ return out.reverse();
87
90
  }
88
91
 
89
92
  /** Latest user request (for "current work" line). */
90
- export function inferCurrentWork(messages: EngineMessage[]): string | undefined {
91
- for (const m of [...messages].reverse()) {
92
- const t = firstText(m);
93
- if (t && m.role === "user") return truncate(t, 200);
94
- }
95
- return undefined;
93
+ export function inferCurrentWork(
94
+ messages: EngineMessage[],
95
+ ): string | undefined {
96
+ for (const m of [...messages].reverse()) {
97
+ const t = firstText(m);
98
+ if (t && m.role === "user") return truncate(t, 200);
99
+ }
100
+ return undefined;
96
101
  }
97
102
 
98
103
  /** Last N user requests, in original order. */
99
- export function collectRecentUserRequests(messages: EngineMessage[], limit: number): string[] {
100
- const reqs = messages
101
- .filter((m) => m.role === "user")
102
- .map((m) => firstText(m))
103
- .filter((t): t is string => Boolean(t))
104
- .map((t) => truncate(t, 160));
105
- return reqs.slice(-limit);
104
+ export function collectRecentUserRequests(
105
+ messages: EngineMessage[],
106
+ limit: number,
107
+ ): string[] {
108
+ const reqs = messages
109
+ .filter((m) => m.role === "user")
110
+ .map((m) => firstText(m))
111
+ .filter((t): t is string => Boolean(t))
112
+ .map((t) => truncate(t, 160));
113
+ return reqs.slice(-limit);
106
114
  }
107
115
 
108
116
  /** Summarize a block to a one-line description. */
109
117
  function summarizeBlock(m: EngineMessage): string {
110
- if (m.role === "tool") return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
111
- if (m.toolName) return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
112
- return truncate(m.text, 160);
118
+ if (m.role === "tool")
119
+ return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
120
+ if (m.toolName)
121
+ return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
122
+ return truncate(m.text, 160);
113
123
  }
114
124
 
115
125
  function stripTag(block: string, tag: string): string {
116
- const start = `<${tag}>`;
117
- const end = `</${tag}>`;
118
- const s = block.indexOf(start);
119
- const e = block.indexOf(end);
120
- if (s === -1 || e === -1) return block;
121
- return block.slice(0, s) + block.slice(e + end.length);
126
+ const start = `<${tag}>`;
127
+ const end = `</${tag}>`;
128
+ const s = block.indexOf(start);
129
+ const e = block.indexOf(end);
130
+ if (s === -1 || e === -1) return block;
131
+ return block.slice(0, s) + block.slice(e + end.length);
122
132
  }
123
133
 
124
134
  function extractTag(block: string, tag: string): string | undefined {
125
- const s = block.indexOf(`<${tag}>`);
126
- const e = block.indexOf(`</${tag}>`);
127
- if (s === -1 || e === -1) return undefined;
128
- return block.slice(s + `<${tag}>`.length, e);
135
+ const s = block.indexOf(`<${tag}>`);
136
+ const e = block.indexOf(`</${tag}>`);
137
+ if (s === -1 || e === -1) return undefined;
138
+ return block.slice(s + `<${tag}>`.length, e);
129
139
  }
130
140
 
131
141
  /** Normalize a raw summary into user-facing "Summary: ..." text. */
132
142
  export function formatCompactSummary(summary: string): string {
133
- const withoutAnalysis = stripTag(summary, "analysis");
134
- let formatted = withoutAnalysis;
135
- const content = extractTag(withoutAnalysis, "summary");
136
- if (content !== undefined) {
137
- formatted = withoutAnalysis.replace(
138
- `<summary>${content}</summary>`,
139
- `Summary:\n${content.trim()}`,
140
- );
141
- }
142
- return formatted.replace(/\n{3,}/g, "\n\n").trim();
143
+ const withoutAnalysis = stripTag(summary, "analysis");
144
+ let formatted = withoutAnalysis;
145
+ const content = extractTag(withoutAnalysis, "summary");
146
+ if (content !== undefined) {
147
+ formatted = withoutAnalysis.replace(
148
+ `<summary>${content}</summary>`,
149
+ `Summary:\n${content.trim()}`,
150
+ );
151
+ }
152
+ return formatted.replace(/\n{3,}/g, "\n\n").trim();
143
153
  }
144
154
 
145
155
  /**
@@ -147,125 +157,150 @@ export function formatCompactSummary(summary: string): string {
147
157
  * Mirrors claw-code summarize_messages.
148
158
  */
149
159
  export function summarizeMessages(messages: EngineMessage[]): string {
150
- const users = messages.filter((m) => m.role === "user").length;
151
- const assistants = messages.filter((m) => m.role === "assistant").length;
152
- const tools = messages.filter((m) => m.role === "tool").length;
160
+ const users = messages.filter((m) => m.role === "user").length;
161
+ const assistants = messages.filter((m) => m.role === "assistant").length;
162
+ const tools = messages.filter((m) => m.role === "tool").length;
153
163
 
154
- const toolNames = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
164
+ const toolNames = [
165
+ ...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : []))),
166
+ ].sort();
155
167
 
156
- const lines: string[] = [
157
- "<summary>",
158
- "Conversation summary:",
159
- `- Scope: ${messages.length} earlier messages compacted (user=${users}, assistant=${assistants}, tool=${tools}).`,
160
- ];
161
- if (toolNames.length) lines.push(`- Tools mentioned: ${toolNames.join(", ")}.`);
168
+ const lines: string[] = [
169
+ "<summary>",
170
+ "Conversation summary:",
171
+ `- Scope: ${messages.length} earlier messages compacted (user=${users}, assistant=${assistants}, tool=${tools}).`,
172
+ ];
173
+ if (toolNames.length)
174
+ lines.push(`- Tools mentioned: ${toolNames.join(", ")}.`);
162
175
 
163
- const recent = collectRecentUserRequests(messages, 3);
164
- if (recent.length) {
165
- lines.push("- Recent user requests:");
166
- recent.forEach((r) => lines.push(` - ${r}`));
167
- }
176
+ const recent = collectRecentUserRequests(messages, 3);
177
+ if (recent.length) {
178
+ lines.push("- Recent user requests:");
179
+ recent.forEach((r) => lines.push(` - ${r}`));
180
+ }
168
181
 
169
- const pending = inferPendingWork(messages);
170
- if (pending.length) {
171
- lines.push("- Pending work:");
172
- pending.forEach((p) => lines.push(` - ${p}`));
173
- }
182
+ const pending = inferPendingWork(messages);
183
+ if (pending.length) {
184
+ lines.push("- Pending work:");
185
+ pending.forEach((p) => lines.push(` - ${p}`));
186
+ }
174
187
 
175
- const files = collectKeyFiles(messages);
176
- if (files.length) lines.push(`- Key files referenced: ${files.join(", ")}.`);
188
+ const files = collectKeyFiles(messages);
189
+ if (files.length) lines.push(`- Key files referenced: ${files.join(", ")}.`);
177
190
 
178
- const current = inferCurrentWork(messages);
179
- if (current) lines.push(`- Current work: ${current}`);
191
+ const current = inferCurrentWork(messages);
192
+ if (current) lines.push(`- Current work: ${current}`);
180
193
 
181
- lines.push("- Key timeline:");
182
- for (const m of messages) {
183
- const role = m.role;
184
- lines.push(` - ${role}: ${summarizeBlock(m)}`);
185
- }
186
- lines.push("</summary>");
187
- return lines.join("\n");
194
+ lines.push("- Key timeline:");
195
+ for (const m of messages) {
196
+ const role = m.role;
197
+ lines.push(` - ${role}: ${summarizeBlock(m)}`);
198
+ }
199
+ lines.push("</summary>");
200
+ return lines.join("\n");
188
201
  }
189
202
 
190
203
  /** Extract the prior "highlights" + "timeline" sections from an existing summary. */
191
204
  function extractSummaryHighlights(summary: string): string[] {
192
- const lines = formatCompactSummary(summary).split("\n");
193
- const out: string[] = [];
194
- let inTimeline = false;
195
- for (const line of lines) {
196
- const t = line.trimEnd();
197
- if (!t || t === "Summary:" || t === "Conversation summary:") continue;
198
- if (t === "- Key timeline:") { inTimeline = true; continue; }
199
- if (inTimeline) continue;
200
- out.push(t);
201
- }
202
- return out;
205
+ const lines = formatCompactSummary(summary).split("\n");
206
+ const out: string[] = [];
207
+ let inTimeline = false;
208
+ for (const line of lines) {
209
+ const t = line.trimEnd();
210
+ if (!t || t === "Summary:" || t === "Conversation summary:") continue;
211
+ if (t === "- Key timeline:") {
212
+ inTimeline = true;
213
+ continue;
214
+ }
215
+ if (inTimeline) continue;
216
+ out.push(t);
217
+ }
218
+ return out;
203
219
  }
204
220
 
205
221
  function extractSummaryTimeline(summary: string): string[] {
206
- const lines = formatCompactSummary(summary).split("\n");
207
- const out: string[] = [];
208
- let inTimeline = false;
209
- for (const line of lines) {
210
- const t = line.trimEnd();
211
- if (t === "- Key timeline:") { inTimeline = true; continue; }
212
- if (!inTimeline) continue;
213
- if (!t) break;
214
- out.push(t);
215
- }
216
- return out;
222
+ const lines = formatCompactSummary(summary).split("\n");
223
+ const out: string[] = [];
224
+ let inTimeline = false;
225
+ for (const line of lines) {
226
+ const t = line.trimEnd();
227
+ if (t === "- Key timeline:") {
228
+ inTimeline = true;
229
+ continue;
230
+ }
231
+ if (!inTimeline) continue;
232
+ if (!t) break;
233
+ out.push(t);
234
+ }
235
+ return out;
217
236
  }
218
237
 
219
238
  /** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
220
- export function mergeCompactSummaries(existing: string | undefined, newSummary: string): string {
221
- if (!existing) return newSummary;
222
- const prevHighlights = extractSummaryHighlights(existing);
223
- const newHighlights = extractSummaryHighlights(formatCompactSummary(newSummary));
224
- const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
239
+ export function mergeCompactSummaries(
240
+ existing: string | undefined,
241
+ newSummary: string,
242
+ ): string {
243
+ if (!existing) return newSummary;
244
+ const prevHighlights = extractSummaryHighlights(existing);
245
+ const newHighlights = extractSummaryHighlights(
246
+ formatCompactSummary(newSummary),
247
+ );
248
+ const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
225
249
 
226
- const lines = ["<summary>", "Conversation summary:"];
227
- if (prevHighlights.length) {
228
- lines.push("- Previously compacted context:");
229
- prevHighlights.forEach((l) => lines.push(` ${l}`));
230
- }
231
- if (newHighlights.length) {
232
- lines.push("- Newly compacted context:");
233
- newHighlights.forEach((l) => lines.push(` ${l}`));
234
- }
235
- if (newTimeline.length) {
236
- lines.push("- Key timeline:");
237
- newTimeline.forEach((l) => lines.push(` ${l}`));
238
- }
239
- lines.push("</summary>");
240
- return lines.join("\n");
250
+ const lines = ["<summary>", "Conversation summary:"];
251
+ if (prevHighlights.length) {
252
+ lines.push("- Previously compacted context:");
253
+ prevHighlights.forEach((l) => lines.push(` ${l}`));
254
+ }
255
+ if (newHighlights.length) {
256
+ lines.push("- Newly compacted context:");
257
+ newHighlights.forEach((l) => lines.push(` ${l}`));
258
+ }
259
+ if (newTimeline.length) {
260
+ lines.push("- Key timeline:");
261
+ newTimeline.forEach((l) => lines.push(` ${l}`));
262
+ }
263
+ lines.push("</summary>");
264
+ return lines.join("\n");
241
265
  }
242
266
 
243
267
  /** True when the compactable portion exceeds the budget. */
244
- export function shouldCompact(messages: EngineMessage[], maxEstimatedTokens: number, preserveRecent: number): boolean {
245
- if (messages.length <= preserveRecent) return false;
246
- const compactable = messages.slice(0, messages.length - preserveRecent);
247
- return estimateSessionTokens(compactable) >= maxEstimatedTokens;
268
+ export function shouldCompact(
269
+ messages: EngineMessage[],
270
+ maxEstimatedTokens: number,
271
+ preserveRecent: number,
272
+ ): boolean {
273
+ if (messages.length <= preserveRecent) return false;
274
+ const compactable = messages.slice(0, messages.length - preserveRecent);
275
+ return estimateSessionTokens(compactable) >= maxEstimatedTokens;
248
276
  }
249
277
 
250
278
  /** Local reimplementation of memory-mcp auto_compact_check. */
251
- export function autoCompactCheck(currentTokens: number, threshold = 50000): {
252
- shouldCompact: boolean;
253
- currentTokens: number;
254
- threshold: number;
255
- utilizationPct: number;
279
+ export function autoCompactCheck(
280
+ currentTokens: number,
281
+ threshold = 50000,
282
+ ): {
283
+ shouldCompact: boolean;
284
+ currentTokens: number;
285
+ threshold: number;
286
+ utilizationPct: number;
256
287
  } {
257
- return {
258
- shouldCompact: currentTokens >= threshold,
259
- currentTokens,
260
- threshold,
261
- utilizationPct: Math.round((currentTokens / threshold) * 1000) / 10,
262
- };
288
+ return {
289
+ shouldCompact: currentTokens >= threshold,
290
+ currentTokens,
291
+ threshold,
292
+ utilizationPct: Math.round((currentTokens / threshold) * 1000) / 10,
293
+ };
263
294
  }
264
295
 
265
296
  /** Build the synthetic continuation message (system-prompt prepend form). */
266
- export function getContinuationMessage(summary: string, suppressFollowUp: boolean, recentPreserved: boolean): string {
267
- let base = COMPACT_PREAMBLE + formatCompactSummary(summary);
268
- if (recentPreserved) base += `\n\n${RECENT_NOTE}`;
269
- if (suppressFollowUp) base += `\n${DIRECT_RESUME}`;
270
- return base;
297
+ export function getContinuationMessage(
298
+ summary: string,
299
+ suppressFollowUp: boolean,
300
+ recentPreserved: boolean,
301
+ ): string {
302
+ let base = COMPACT_PREAMBLE + formatCompactSummary(summary);
303
+ if (recentPreserved) base += `\n\n${RECENT_NOTE}`;
304
+ if (suppressFollowUp) base += `\n${DIRECT_RESUME}`;
305
+ return base;
271
306
  }
@@ -1,3 +1,18 @@
1
+ // This file exercises the sync node:sqlite memory ops only, but every
2
+ // applyMemoryOps() write also fires indexMemoryWrite() → upsertMemoryEmbedding()
3
+ // at the machine-wide PGlite index, fire-and-forget, keyed by repoKey(stateDir)
4
+ // — which for these tmp state dirs is the tmp path itself. Left alone that lands
5
+ // in the developer's real ~/.pi/mega-compact-vector, where the rows stay
6
+ // eligible for cross-repo recall forever: a live session can be handed
7
+ // "threshold is 50k" and "the threshold is 100k" from this file as if they were
8
+ // another project's memories. scripts/run-tests.mjs sets MEGACOMPACT_INDEX_DIR
9
+ // per child, so the suite was already safe; running the file directly was not.
10
+ //
11
+ // Disabling PGlite is the same guard memoryRoundtrip.test.ts uses for the same
12
+ // reason, and it also keeps the file's exit clean (no WASM handle, and no
13
+ // initdb still running when the first test ends). MEGACOMPACT_INDEX_DIR is set
14
+ // as well so the isolation holds if a later test needs the index re-enabled.
15
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
1
16
  import { test } from "node:test";
2
17
  import assert from "node:assert/strict";
3
18
  import { mkdtempSync, rmSync } from "node:fs";
@@ -13,6 +28,7 @@ import {
13
28
  } from "./store/sqlite.js";
14
29
 
15
30
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
31
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
16
32
 
17
33
  test("applyMemoryOps: ADD inserts a new memory", async () => {
18
34
  const dir = join(baseTmp, "add");
@@ -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
- const pg = await new mod.PGlite({
139
- dataDir: dir,
140
- extensions: { vector: mod.vector },
141
- });
142
- await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
143
- await pg.exec(`
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
- await pg.exec(
153
- "CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);",
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
+ });