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/README.md +1 -0
- package/dist/extensions/mega-compact.js +16 -1
- package/dist/extensions/mega-config.js +1 -0
- package/dist/extensions/mega-runtime/game-state.js +7 -0
- 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/memoryOps.test.js +16 -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/vectorIndex.js +30 -8
- package/extensions/mega-compact.ts +16 -1
- package/extensions/mega-config.ts +8 -0
- package/extensions/mega-runtime/game-state.ts +9 -0
- 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/memoryOps.test.ts +16 -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/vectorIndex.ts +35 -9
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ Set env vars before starting pi. Defaults are in `src/config/dedup.ts`.
|
|
|
61
61
|
| `MEGACOMPACT_DEDUP_SIM` | `0.90` | Cosine threshold for near-dup collapse |
|
|
62
62
|
| `MEGACOMPACT_CROSSREPO_ENABLED` | `true` | Cross-repo recall on resume |
|
|
63
63
|
| `MEGACOMPACT_EMBEDDING_URL` | _(unset)_ | BYO localhost embedder endpoint |
|
|
64
|
+
| `MEGACOMPACT_TUI_WIDGET` | `true` | Render the above-editor panel. Set to `0` to suppress it — useful when pi runs inside an editor terminal (e.g. Neovim `:terminal`) where you drive scrollback yourself and the panel's repaints fight it. Compaction is unaffected. |
|
|
64
65
|
|
|
65
66
|
Full config reference: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md)
|
|
66
67
|
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* This file is the thin wiring layer: it owns the default export, constructs
|
|
26
26
|
* the runtime, and registers handlers/commands. Behavior is unchanged.
|
|
27
27
|
*/
|
|
28
|
+
import { closeVectorIndex } from "../src/store/vectorIndex.js";
|
|
29
|
+
import { closeMemoryIndex } from "../src/store/memoryIndex.js";
|
|
28
30
|
import { loadConfig } from "./mega-config.js";
|
|
29
31
|
import { MegaRuntime } from "./mega-runtime.js";
|
|
30
32
|
import { registerEventHandlers } from "./mega-events.js";
|
|
@@ -79,5 +81,18 @@ export default function (pi) {
|
|
|
79
81
|
// dispose() is idempotent, and the next snapshot() re-opens the watcher
|
|
80
82
|
// lazily via bindRepo() → ensureGameStateWatcher(), so there is no permanent
|
|
81
83
|
// leak and no per-session fd accumulation.
|
|
82
|
-
|
|
84
|
+
// The PGlite indexes (vectorIndex / memoryIndex) are lazily opened module
|
|
85
|
+
// singletons. closeVectorIndex()/closeMemoryIndex() existed but had no
|
|
86
|
+
// non-test callers, so a session left both open: PGlite is WASM Postgres and
|
|
87
|
+
// its handles keep node's event loop alive, so `pi -p` produced its answer
|
|
88
|
+
// and then hung until killed rather than exiting. dispose() only released the
|
|
89
|
+
// fs.watch handle and the perf interval, neither of which was the culprit
|
|
90
|
+
// (the interval is unref'd).
|
|
91
|
+
//
|
|
92
|
+
// Both closes are idempotent and safe when the index was never opened, and
|
|
93
|
+
// the next initVectorIndex()/initMemoryIndex() re-opens lazily.
|
|
94
|
+
pi.on("session_shutdown", async () => {
|
|
95
|
+
runtime.dispose();
|
|
96
|
+
await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
|
|
97
|
+
});
|
|
83
98
|
}
|
|
@@ -160,6 +160,7 @@ export function loadConfig() {
|
|
|
160
160
|
memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
|
|
161
161
|
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
162
162
|
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
163
|
+
tuiWidget: envBool("MEGACOMPACT_TUI_WIDGET", true),
|
|
163
164
|
debug: envBool("MEGACOMPACT_DEBUG", false),
|
|
164
165
|
};
|
|
165
166
|
}
|
|
@@ -108,6 +108,13 @@ export function ensureGameStateWatcherImpl(self, view) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
});
|
|
111
|
+
// The watcher is a cache-eviction convenience, never a reason to stay
|
|
112
|
+
// alive: an active+referenced fs_event handle holds node's event loop
|
|
113
|
+
// open, so a runtime that was never dispose()d (every extension test, and
|
|
114
|
+
// any `pi -p` run that skips session_shutdown) hangs the process after all
|
|
115
|
+
// work is done. unref'd like the perf interval — while pi runs there are
|
|
116
|
+
// always other referenced handles, so the watcher still fires normally.
|
|
117
|
+
self.gameStateWatcher.unref?.();
|
|
111
118
|
self.gameStateWatchDir = self.currentStateDir;
|
|
112
119
|
}
|
|
113
120
|
catch {
|
|
@@ -32,6 +32,14 @@ export function resetRuntimeImpl(self, sessionId) {
|
|
|
32
32
|
errorRetryCount: 0,
|
|
33
33
|
errorRetryUntil: 0,
|
|
34
34
|
consecutiveErrors: 0,
|
|
35
|
+
lastErrorRetryAt: 0,
|
|
36
|
+
retryNudgePending: false,
|
|
37
|
+
errorRetrySessionCount: 0,
|
|
38
|
+
lastErrorText: undefined,
|
|
39
|
+
errorTextRepeatCount: 0,
|
|
40
|
+
poisonedAdviseSent: false,
|
|
41
|
+
poisonedCompactSignatures: new Set(),
|
|
42
|
+
poisonedCount: 0,
|
|
35
43
|
};
|
|
36
44
|
self.trimCache = null; // v0.8.6: never replay a stale trim into a new session
|
|
37
45
|
self.statusKey = undefined;
|
|
@@ -23,6 +23,7 @@ import { captureModelImpl } from "./capture-model.js";
|
|
|
23
23
|
import { bindRepoImpl } from "./bind-repo.js";
|
|
24
24
|
import { snapshotImpl } from "./runtime-snapshot.js";
|
|
25
25
|
import { pressureImpl, effectiveThresholdImpl, pressureBandImpl, } from "./pressure-getters.js";
|
|
26
|
+
import { resetRuntimeImpl } from "./reset-runtime.js";
|
|
26
27
|
import { appendEventImpl } from "./append-event.js";
|
|
27
28
|
import { getStateDirImpl } from "./get-state-dir.js";
|
|
28
29
|
import { renderWidgetImpl } from "./render-widget.js";
|
|
@@ -56,7 +57,6 @@ export class MegaRuntime {
|
|
|
56
57
|
errorRetryCount: 0,
|
|
57
58
|
errorRetryUntil: 0,
|
|
58
59
|
consecutiveErrors: 0,
|
|
59
|
-
// R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
|
|
60
60
|
lastErrorRetryAt: 0,
|
|
61
61
|
retryNudgePending: false,
|
|
62
62
|
errorRetrySessionCount: 0,
|
|
@@ -255,8 +255,15 @@ export class MegaRuntime {
|
|
|
255
255
|
snapshotImpl(this, ctx);
|
|
256
256
|
}
|
|
257
257
|
/** Width-aware above-editor widget factory registration — thin delegate to
|
|
258
|
-
* `renderWidgetImpl` (render-widget.ts).
|
|
258
|
+
* `renderWidgetImpl` (render-widget.ts).
|
|
259
|
+
*
|
|
260
|
+
* Gated on `config.tuiWidget` (MEGACOMPACT_TUI_WIDGET=0 to disable). This
|
|
261
|
+
* is the single chokepoint every caller funnels through, so returning here
|
|
262
|
+
* means setWidget is never called and the panel is never registered — as
|
|
263
|
+
* opposed to registering an empty one, which would still occupy a row. */
|
|
259
264
|
renderWidget(ctx) {
|
|
265
|
+
if (!this.config.tuiWidget)
|
|
266
|
+
return;
|
|
260
267
|
renderWidgetImpl(this, ctx);
|
|
261
268
|
}
|
|
262
269
|
/** Mirror the dashboard status text onto pi's status line — thin delegate to
|
|
@@ -264,55 +271,10 @@ export class MegaRuntime {
|
|
|
264
271
|
setStatus(ctx, text) {
|
|
265
272
|
setStatusImpl(this, ctx, text);
|
|
266
273
|
}
|
|
267
|
-
/** Per-session state reset (session_start / session_tree) —
|
|
268
|
-
*
|
|
269
|
-
* retired by that branch. */
|
|
274
|
+
/** Per-session state reset (session_start / session_tree) — thin delegate to
|
|
275
|
+
* `resetRuntimeImpl` (reset-runtime.ts). */
|
|
270
276
|
resetRuntime(sessionId) {
|
|
271
|
-
|
|
272
|
-
if (this.rt.sessionId === sid && this.rt.persistedThisSession)
|
|
273
|
-
return; // same session, keep checkpoint memory
|
|
274
|
-
this.rt = {
|
|
275
|
-
sessionId: sid,
|
|
276
|
-
persistedThisSession: false,
|
|
277
|
-
lastCheckpointId: undefined,
|
|
278
|
-
lastCompactedFrom: 0,
|
|
279
|
-
lastCompactedTokens: 0,
|
|
280
|
-
dedupSkips: 0,
|
|
281
|
-
dedupAttempts: 0,
|
|
282
|
-
tokensSaved: 0,
|
|
283
|
-
lastCompactAt: null,
|
|
284
|
-
lastNativeCompactAt: null,
|
|
285
|
-
compactCount: 0,
|
|
286
|
-
recallInjections: 0,
|
|
287
|
-
cacheHitTokens: 0,
|
|
288
|
-
lengthStopPending: false,
|
|
289
|
-
errorRetryCount: 0,
|
|
290
|
-
errorRetryUntil: 0,
|
|
291
|
-
consecutiveErrors: 0,
|
|
292
|
-
// R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
|
|
293
|
-
lastErrorRetryAt: 0,
|
|
294
|
-
retryNudgePending: false,
|
|
295
|
-
errorRetrySessionCount: 0,
|
|
296
|
-
lastErrorText: undefined,
|
|
297
|
-
errorTextRepeatCount: 0,
|
|
298
|
-
poisonedAdviseSent: false,
|
|
299
|
-
poisonedCompactSignatures: new Set(),
|
|
300
|
-
poisonedCount: 0,
|
|
301
|
-
};
|
|
302
|
-
this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
|
|
303
|
-
this.statusKey = undefined;
|
|
304
|
-
this.activeAgents = 0;
|
|
305
|
-
this.currentTurn = 0;
|
|
306
|
-
this.lastActivityAt = 0;
|
|
307
|
-
this.tierTrace = undefined;
|
|
308
|
-
this.ticker.length = 0;
|
|
309
|
-
this.pulsing = false;
|
|
310
|
-
this.savedGoal = 50_000;
|
|
311
|
-
this.lastWhy = undefined;
|
|
312
|
-
// S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
|
|
313
|
-
// that re-binds the repo, so drop the memo too. Cheap; the next
|
|
314
|
-
// getCachedGameState() re-queries lazily.
|
|
315
|
-
this.cachedGameState = undefined;
|
|
277
|
+
resetRuntimeImpl(this, sessionId);
|
|
316
278
|
}
|
|
317
279
|
captureModel(ctx) {
|
|
318
280
|
captureModelImpl(this, ctx);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-shutdown-widget.test.ts — regression cover for two host-integration
|
|
3
|
+
* fixes that the rest of the suite cannot see, because both are about what
|
|
4
|
+
* happens at the boundary with pi rather than inside the compaction engine:
|
|
5
|
+
*
|
|
6
|
+
* 1. session_shutdown must close the PGlite indexes. They are lazily-opened
|
|
7
|
+
* module singletons whose handles keep node's event loop alive, so leaving
|
|
8
|
+
* them open made `pi -p` produce its answer and then hang instead of
|
|
9
|
+
* exiting. closeVectorIndex()/closeMemoryIndex() existed but had no
|
|
10
|
+
* non-test callers.
|
|
11
|
+
*
|
|
12
|
+
* 2. The above-editor widget must be suppressible. It is a persistent,
|
|
13
|
+
* animated, full-width panel that repaints on its own cadence, which fights
|
|
14
|
+
* terminals where the user drives scrollback (pi inside a Neovim
|
|
15
|
+
* `:terminal`). MEGACOMPACT_TUI_WIDGET=0 turns it off without touching
|
|
16
|
+
* compaction.
|
|
17
|
+
*/
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { closeVectorIndex, initVectorIndex, isVectorIndexDisabled, } from "../src/store/vectorIndex.js";
|
|
24
|
+
import { closeMemoryIndex } from "../src/store/memoryIndex.js";
|
|
25
|
+
import { loadConfig } from "./mega-config.js";
|
|
26
|
+
import { MegaRuntime } from "./mega-runtime.js";
|
|
27
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-shutdown-"));
|
|
28
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
|
|
29
|
+
let counter = 0;
|
|
30
|
+
/** Fresh per-test state dir so concurrent runs never collide on disk. */
|
|
31
|
+
function isolate() {
|
|
32
|
+
process.env.MEGACOMPACT_STATE_DIR = join(baseTmp, `run-${counter++}`);
|
|
33
|
+
}
|
|
34
|
+
/** Minimal ExtensionContext slice: renderWidget only reaches ctx.ui.setWidget. */
|
|
35
|
+
function widgetCtx(calls) {
|
|
36
|
+
return {
|
|
37
|
+
ui: {
|
|
38
|
+
setWidget: (key) => calls.push(key),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
test.after(async () => {
|
|
43
|
+
// This file is itself a demonstration of the bug under test: without these
|
|
44
|
+
// closes the PGlite handles opened above keep the event loop alive and the
|
|
45
|
+
// test process never exits.
|
|
46
|
+
await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
|
|
47
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
test("MEGACOMPACT_TUI_WIDGET defaults on and is disabled by 0", () => {
|
|
50
|
+
isolate();
|
|
51
|
+
delete process.env.MEGACOMPACT_TUI_WIDGET;
|
|
52
|
+
assert.equal(loadConfig().tuiWidget, true, "widget should default to on");
|
|
53
|
+
process.env.MEGACOMPACT_TUI_WIDGET = "0";
|
|
54
|
+
assert.equal(loadConfig().tuiWidget, false);
|
|
55
|
+
process.env.MEGACOMPACT_TUI_WIDGET = "1";
|
|
56
|
+
assert.equal(loadConfig().tuiWidget, true);
|
|
57
|
+
delete process.env.MEGACOMPACT_TUI_WIDGET;
|
|
58
|
+
});
|
|
59
|
+
test("renderWidget registers the panel when tuiWidget is on", () => {
|
|
60
|
+
isolate();
|
|
61
|
+
delete process.env.MEGACOMPACT_TUI_WIDGET;
|
|
62
|
+
const runtime = new MegaRuntime(loadConfig());
|
|
63
|
+
try {
|
|
64
|
+
const calls = [];
|
|
65
|
+
runtime.renderWidget(widgetCtx(calls));
|
|
66
|
+
assert.equal(calls.length, 1, "expected one setWidget registration");
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
runtime.dispose();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
test("renderWidget registers nothing when tuiWidget is off", () => {
|
|
73
|
+
isolate();
|
|
74
|
+
process.env.MEGACOMPACT_TUI_WIDGET = "0";
|
|
75
|
+
const runtime = new MegaRuntime(loadConfig());
|
|
76
|
+
try {
|
|
77
|
+
const calls = [];
|
|
78
|
+
// Repeated calls, because the panel is re-registered on every snapshot
|
|
79
|
+
// and every game-state change — one guarded path is not enough.
|
|
80
|
+
runtime.renderWidget(widgetCtx(calls));
|
|
81
|
+
runtime.renderWidget(widgetCtx(calls));
|
|
82
|
+
assert.deepEqual(calls, [], "widget must never be registered when disabled");
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
runtime.dispose();
|
|
86
|
+
delete process.env.MEGACOMPACT_TUI_WIDGET;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
test("the extension's session_shutdown handler awaits index teardown", async () => {
|
|
90
|
+
isolate();
|
|
91
|
+
const handlers = {};
|
|
92
|
+
const pi = {
|
|
93
|
+
on(event, handler) {
|
|
94
|
+
(handlers[event] ??= []).push(handler);
|
|
95
|
+
},
|
|
96
|
+
registerCommand() { },
|
|
97
|
+
registerProvider() { },
|
|
98
|
+
};
|
|
99
|
+
const { default: extension } = await import("./mega-compact.js");
|
|
100
|
+
extension(pi);
|
|
101
|
+
const shutdown = handlers["session_shutdown"] ?? [];
|
|
102
|
+
assert.ok(shutdown.length, "extension must register a session_shutdown handler");
|
|
103
|
+
const event = { type: "session_shutdown" };
|
|
104
|
+
const ctx = {
|
|
105
|
+
ui: { setStatus: () => { }, notify: () => { }, setWidget: () => { } },
|
|
106
|
+
cwd: process.env.MEGACOMPACT_STATE_DIR,
|
|
107
|
+
};
|
|
108
|
+
// Assert the close by identity rather than by "some handler returned a
|
|
109
|
+
// promise": several modules register on session_shutdown and at least one
|
|
110
|
+
// other is already async, so a promise proves nothing about teardown. The
|
|
111
|
+
// index is a module singleton, so if shutdown really closed it, re-init
|
|
112
|
+
// hands back a *different* instance.
|
|
113
|
+
const before = await initVectorIndex();
|
|
114
|
+
if (isVectorIndexDisabled() || !before)
|
|
115
|
+
return; // PGlite unavailable — nothing to close
|
|
116
|
+
await Promise.all(shutdown.map((handler) => handler(event, ctx)));
|
|
117
|
+
const after = await initVectorIndex();
|
|
118
|
+
assert.notEqual(after, before, "session_shutdown must close the PGlite vector index");
|
|
119
|
+
// Idempotent: a second shutdown (reload, double-fire) must not throw.
|
|
120
|
+
await Promise.all(shutdown.map((handler) => handler(event, ctx)));
|
|
121
|
+
});
|
package/dist/src/compact.js
CHANGED
|
@@ -26,7 +26,7 @@ function firstText(m) {
|
|
|
26
26
|
/** Heuristic: does this text look like chatty filler we can collapse? */
|
|
27
27
|
export function isChatty(text) {
|
|
28
28
|
const low = text.toLowerCase();
|
|
29
|
-
if (
|
|
29
|
+
if (/\b(hello|thanks|great|ok)\b/i.test(low)) {
|
|
30
30
|
return true;
|
|
31
31
|
}
|
|
32
32
|
return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
|
|
@@ -141,7 +141,9 @@ export function summarizeMessages(messages) {
|
|
|
141
141
|
const users = messages.filter((m) => m.role === "user").length;
|
|
142
142
|
const assistants = messages.filter((m) => m.role === "assistant").length;
|
|
143
143
|
const tools = messages.filter((m) => m.role === "tool").length;
|
|
144
|
-
const toolNames = [
|
|
144
|
+
const toolNames = [
|
|
145
|
+
...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : []))),
|
|
146
|
+
].sort();
|
|
145
147
|
const lines = [
|
|
146
148
|
"<summary>",
|
|
147
149
|
"Conversation summary:",
|
|
@@ -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";
|
|
@@ -6,6 +21,7 @@ import { join } from "node:path";
|
|
|
6
21
|
import { applyMemoryOps } from "./memoryOps.js";
|
|
7
22
|
import { addMemory, listMemories, replaceMemory, referenceMemory, MEMORY_MAX_CHARS, } from "./store/sqlite.js";
|
|
8
23
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
|
|
24
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
|
|
9
25
|
test("applyMemoryOps: ADD inserts a new memory", async () => {
|
|
10
26
|
const dir = join(baseTmp, "add");
|
|
11
27
|
await applyMemoryOps([{ op: "add", memory: { content: "we use node:sqlite as the store", category: "decision", sourceTurn: 0 } }], dir);
|
|
@@ -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
|
+
});
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|