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
package/README.md
CHANGED
|
@@ -4,14 +4,16 @@ A local context compressor for the [pi coding agent](https://github.com/earendil
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Auto-compaction** — watches context pressure and compacts in the background
|
|
8
|
-
- **Two-layer compaction** — live trim
|
|
9
|
-
- **Semantic dedup** —
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
7
|
+
- **Auto-compaction** — the store watches context pressure and compacts quietly in the background. You'll notice when a long session just stays long while the token gauge rests comfortably far from the ceiling.
|
|
8
|
+
- **Two-layer compaction** — every LLM call sees a live trim of the context window, and every trim is checkpointed to SQLite so a crash or a `/clear` never loses the work.
|
|
9
|
+
- **Semantic dedup, three layers deep** — exact hash → MinHash/LSH → cosine over trigram embeddings. You rarely notice it; that's the point.
|
|
10
|
+
- **RAPTOR memory hierarchy** — decisions you made an hour ago don't scroll off; they get packed up as hierarchical checkpoints and re-inlined the moment your next session asks for them. Multi-level retrieval (leaves + summary clusters) is on by default and tunes itself off the build history.
|
|
11
|
+
- **Per-turn tracking.** Every turn, checkpoint, and recall hit lands as a row in the local DB — `conversation_branches`, `turns`, `turn_recall`. When things go wrong (or when you want to fork a detour off the main thread), the history is there.
|
|
12
|
+
- **Cross-repo recall** — doors I close in one repo don't reopen when I move to another. A decision stored while hacking repo A is a recall hit the next time I'm in repo B.
|
|
13
|
+
- **Durable memory** — every ten turns the store auto-reviews and safe-keeps decisions, facts, and preferences as first-class RAG memories, so long-running projects remember what mattered.
|
|
14
|
+
- **Fully local** — node:sqlite + trigram embeddings by default. Bring your own localhost embedder (ONNX, Ollama, TEI) for better semantic matches. Zero calls off your machine except the optional, localhost-only dashboard.
|
|
15
|
+
- **Team-run aware** — fine-grained durable trim fires at agent settle during sub-agent runs, so long multi-agent work doesn't just collapse at the end.
|
|
16
|
+
- **Multi-pi dashboard** — one dashboard tab per active pi process with the context stack, per-repo stats, and a live SSE feed across all of them.
|
|
15
17
|
|
|
16
18
|
## Install
|
|
17
19
|
|
|
@@ -59,6 +61,7 @@ Set env vars before starting pi. Defaults are in `src/config/dedup.ts`.
|
|
|
59
61
|
| `MEGACOMPACT_DEDUP_SIM` | `0.90` | Cosine threshold for near-dup collapse |
|
|
60
62
|
| `MEGACOMPACT_CROSSREPO_ENABLED` | `true` | Cross-repo recall on resume |
|
|
61
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. |
|
|
62
65
|
|
|
63
66
|
Full config reference: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md)
|
|
64
67
|
|
|
@@ -80,7 +83,7 @@ Detailed architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
|
|
80
83
|
|
|
81
84
|
```bash
|
|
82
85
|
npm run build # TypeScript compile
|
|
83
|
-
npm test # Build +
|
|
86
|
+
npm test # Build + 769 tests
|
|
84
87
|
npm run lint # Type check + guardrails scan
|
|
85
88
|
```
|
|
86
89
|
|
|
@@ -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
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { compactSession } from "../../src/engine.js";
|
|
12
12
|
import { normalizeSessionId } from "../../src/store.js";
|
|
13
|
+
import { repoKey } from "../../src/store/repoKey.js";
|
|
13
14
|
import { estimateBlockTokens } from "../../src/tokens.js";
|
|
14
15
|
import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../src/store/sqlite.js";
|
|
15
16
|
import { consolidateMemories } from "../../src/memory.js";
|
|
@@ -219,7 +220,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
|
|
|
219
220
|
const all = vectorList(runtime.store, sid);
|
|
220
221
|
const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
|
|
221
222
|
if (latest?.embedding) {
|
|
222
|
-
void indexUpsertEmbedding(runtime.currentStateDir, sid, latest.checkpointId, latest.embedding).catch(() => {
|
|
223
|
+
void indexUpsertEmbedding(repoKey(runtime.currentStateDir), sid, latest.checkpointId, latest.embedding).catch(() => {
|
|
223
224
|
/* non-fatal: index refresh never blocks a compaction */
|
|
224
225
|
});
|
|
225
226
|
}
|
|
@@ -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:",
|
|
@@ -156,6 +156,17 @@ export function buildRaptorTree(leaves, opts) {
|
|
|
156
156
|
qualityMarker,
|
|
157
157
|
tokenEstimate,
|
|
158
158
|
});
|
|
159
|
+
// Populate parentId for the internal nodes being absorbed into this
|
|
160
|
+
// parent summary. Group members with ids in `nodes` are internal summary
|
|
161
|
+
// nodes (level >= 1); raw leaf ids are not in `nodes` (per-leaf wrappers
|
|
162
|
+
// are intentionally absent) and are correctly skipped — leaf→summary
|
|
163
|
+
// walks go through the parent's `children` list instead.
|
|
164
|
+
for (const c of group) {
|
|
165
|
+
const child = nodes.get(c.id);
|
|
166
|
+
if (child && child.id !== merged.id) {
|
|
167
|
+
child.parentId = merged.id;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
159
170
|
nextLevel.push(merged);
|
|
160
171
|
}
|
|
161
172
|
currentLevel = nextLevel;
|
package/dist/src/memory.test.js
CHANGED
|
@@ -39,3 +39,32 @@ test("reviewConversation: REMOVE requires topic overlap (no accidental drop)", (
|
|
|
39
39
|
const ops = reviewConversation(msgs, existing);
|
|
40
40
|
assert.equal(ops.filter((o) => o.op === "remove").length, 0, "vague 'drop it' with no topic overlap does not remove anything");
|
|
41
41
|
});
|
|
42
|
+
// ---- E5 (docs/specs/s25-memory-db-roundtrip.md): hallucination-guard pins ----
|
|
43
|
+
test("E5.3 — truncation pin: long decision truncates to 160 chars and stays message-grounded", () => {
|
|
44
|
+
// collectRecentUserRequests truncates user text at 160 chars before review.
|
|
45
|
+
// A long decision is silently clipped — undocumented before S25; this pins
|
|
46
|
+
// the boundary.
|
|
47
|
+
const long = "we decided to use node:sqlite for the authoritative store backend after evaluating better-sqlite3, pglite and libsql and rejecting all three";
|
|
48
|
+
const msgs = [{ role: "user", text: long }];
|
|
49
|
+
const ops = reviewConversation(msgs);
|
|
50
|
+
const add = ops.find((o) => o.op === "add");
|
|
51
|
+
assert.ok(add, "a decision inside a long user message produces an add");
|
|
52
|
+
assert.ok(add.memory.content.length <= 160, "stored content is 160-char truncated");
|
|
53
|
+
assert.ok(long.includes(add.memory.content), "truncated content is still verbatim-grounded in the message");
|
|
54
|
+
});
|
|
55
|
+
test("E5.1 — hallucination guard: every surviving add/replace is verbatim from a real message", () => {
|
|
56
|
+
const msgs = [{ role: "user", text: "the pipeline uses dagster for orchestration" }];
|
|
57
|
+
const ops = reviewConversation(msgs, [{ content: "we use better-sqlite3 for the store" }]);
|
|
58
|
+
for (const o of ops) {
|
|
59
|
+
if (o.op === "remove")
|
|
60
|
+
continue; // REMOVE is exempt by design (:70-74)
|
|
61
|
+
assert.ok(msgs.some((m) => String(m.text ?? "").includes(o.memory.content)), "every add/replace content is verbatim from a real message");
|
|
62
|
+
}
|
|
63
|
+
assert.equal(ops.filter((o) => o.op !== "remove").length, 0, "non-decision text produces no add/replace");
|
|
64
|
+
});
|
|
65
|
+
test("E5.4 — REMOVE over-match pin: single-token topic overlap fires REMOVE", () => {
|
|
66
|
+
const existing = [{ content: "we use redis for the cache" }];
|
|
67
|
+
const msgs = [{ role: "user", text: "stop using redis" }];
|
|
68
|
+
const ops = reviewConversation(msgs, existing);
|
|
69
|
+
assert.ok(ops.some((o) => o.op === "remove" && /redis/i.test(o.content)), "single-token overlap removes the matching memory (current behavior — KNOWN: weak topic match)");
|
|
70
|
+
});
|
package/dist/src/memoryOps.js
CHANGED
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { addMemory, listMemories, replaceMemory, removeMemory, } from "./store/sqlite.js";
|
|
2
2
|
import { defaultEmbedder } from "./embedder.js";
|
|
3
|
+
import { repoKey } from "./store/repoKey.js";
|
|
3
4
|
import { upsertMemoryEmbedding } from "./store/memoryIndex.js";
|
|
4
|
-
import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the memory index per-repo
|
|
5
|
-
/** Resolve the current repo's git root (mirrors extensions/mega-config.ts but
|
|
6
|
-
* kept local so src/ stays pi-agnostic — no extension-layer import). */
|
|
7
|
-
function resolveRepoRootLocal(cwd) {
|
|
8
|
-
try {
|
|
9
|
-
const out = execSync("git rev-parse --show-toplevel", {
|
|
10
|
-
cwd,
|
|
11
|
-
encoding: "utf-8",
|
|
12
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
-
}).trim();
|
|
14
|
-
return out || undefined;
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
5
|
/** Find a memory row whose content exactly matches (case-insensitive). */
|
|
21
6
|
function findByContent(memories, content) {
|
|
22
7
|
const norm = content.trim().toLowerCase();
|
|
@@ -26,11 +11,11 @@ function findByContent(memories, content) {
|
|
|
26
11
|
* Fire-and-forget mirror of a memory write into the cross-repo PGlite index
|
|
27
12
|
* (S24 optional memory-RAG mirror). Best-effort + non-fatal: never blocks the
|
|
28
13
|
* SQLite write and degrades to the same-repo scan if the index is disabled or
|
|
29
|
-
* fails. `repoId` is the
|
|
30
|
-
* repos; falls back to the state dir
|
|
14
|
+
* fails. `repoId` is the unified S25 repoKey (git root) so the memory is
|
|
15
|
+
* findable from other repos; falls back to the state dir outside git.
|
|
31
16
|
*/
|
|
32
17
|
function indexMemoryWrite(stateDir, memoryId, content) {
|
|
33
|
-
const repoId =
|
|
18
|
+
const repoId = repoKey(stateDir);
|
|
34
19
|
try {
|
|
35
20
|
const vec = defaultEmbedder().embed(content);
|
|
36
21
|
void upsertMemoryEmbedding(repoId, memoryId, content, vec);
|
|
@@ -119,6 +119,33 @@ test("recallMemoriesAndInline: surfaces a memory saved in ANOTHER repo via cross
|
|
|
119
119
|
delete process.env.MEGACOMPACT_INDEX_DIR;
|
|
120
120
|
}
|
|
121
121
|
});
|
|
122
|
+
// ---- S25 §3.3: content de-dup in the cross-repo memory path -----------------
|
|
123
|
+
// recallMemoriesCrossRepo (memoryRecall.ts:114) must NOT surface a memory the
|
|
124
|
+
// local repo ALREADY has — same-repo authoritative store wins over the index.
|
|
125
|
+
test("recallMemoriesCrossRepo: dedupes content the local repo already has", async () => {
|
|
126
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-dedup");
|
|
127
|
+
const repoA = join(baseTmp, "dedup-a");
|
|
128
|
+
const repoB = join(baseTmp, "dedup-b");
|
|
129
|
+
try {
|
|
130
|
+
const { applyMemoryOps } = await import("./memoryOps.js");
|
|
131
|
+
const shared = "we standardized on node:sqlite for the store backend";
|
|
132
|
+
await applyMemoryOps([{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }], repoA);
|
|
133
|
+
await applyMemoryOps([{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }], repoB);
|
|
134
|
+
// The PGlite index now has repoA's copy; repoB ALSO has it locally. The
|
|
135
|
+
// cross-repo path for repoB must drop repoA's duplicate.
|
|
136
|
+
const { recallMemoriesCrossRepo } = await import("./memoryRecall.js");
|
|
137
|
+
const hits = await recallMemoriesCrossRepo("what store backend do we use?", repoB, {
|
|
138
|
+
crossRepoCosine: 0.0, // floor at 0: would match everything if dedup fails
|
|
139
|
+
limit: 5,
|
|
140
|
+
});
|
|
141
|
+
assert.ok(hits.every((h) => h.memory.content.trim().toLowerCase() !== shared.toLowerCase()), "cross-repo hit with content the local repo already has is dropped");
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
const { closeMemoryIndex } = await import("./store/memoryIndex.js");
|
|
145
|
+
await closeMemoryIndex();
|
|
146
|
+
delete process.env.MEGACOMPACT_INDEX_DIR;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
122
149
|
test("recallMemoriesAndInline: cross-repo disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
|
|
123
150
|
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-off");
|
|
124
151
|
process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
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).
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // sync-only suite: no WASM handle left open by the fire-and-forget index mirror
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { reviewConversation } from "./memory.js";
|
|
24
|
+
import { applyMemoryOps } from "./memoryOps.js";
|
|
25
|
+
import { recallMemories } from "./memoryRecall.js";
|
|
26
|
+
import { formatMemoryRecallBlock } from "./recall.js";
|
|
27
|
+
import { listMemories, closeStore } from "./store/sqlite.js";
|
|
28
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memrt-"));
|
|
29
|
+
function freshDir() {
|
|
30
|
+
return mkdtempSync(join(baseTmp, "rt-"));
|
|
31
|
+
}
|
|
32
|
+
function done(dir) {
|
|
33
|
+
closeStore(dir);
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
// A deterministic local embedder for recall scoring (mirrors memoryRecall.test.ts).
|
|
37
|
+
function biGramEmbedder() {
|
|
38
|
+
const dim = 64;
|
|
39
|
+
return {
|
|
40
|
+
dim,
|
|
41
|
+
embed(text) {
|
|
42
|
+
const v = new Array(dim).fill(0);
|
|
43
|
+
const norm = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
|
|
44
|
+
for (let i = 0; i < norm.length - 1; i++) {
|
|
45
|
+
const idx = ((norm.charCodeAt(i) * 31 + norm.charCodeAt(i + 1)) >>> 0) % dim;
|
|
46
|
+
v[idx] = 1;
|
|
47
|
+
}
|
|
48
|
+
return v;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Shared grounded decision used by R1 (under the 160-char truncation cap).
|
|
53
|
+
const GROUNDED = "we decided to use node:sqlite for the durable store backend";
|
|
54
|
+
test("R1 — full round-trip: review → persist → recall → format carries content + category", async () => {
|
|
55
|
+
const dir = freshDir();
|
|
56
|
+
try {
|
|
57
|
+
// 1. Review produces an ADD op grounded in a real user message.
|
|
58
|
+
const msgs = [
|
|
59
|
+
{ role: "user", text: GROUNDED },
|
|
60
|
+
{ role: "assistant", text: "acknowledged" },
|
|
61
|
+
];
|
|
62
|
+
const ops = reviewConversation(msgs, []);
|
|
63
|
+
assert.equal(ops.length, 1, "exactly one op from one decision");
|
|
64
|
+
assert.equal(ops[0].op, "add");
|
|
65
|
+
// 2. Persist via applyMemoryOps (real SQLite write — node:sqlite).
|
|
66
|
+
await applyMemoryOps(ops, dir);
|
|
67
|
+
const stored = listMemories(null, 50, dir);
|
|
68
|
+
assert.equal(stored.length, 1, "memory row persisted");
|
|
69
|
+
assert.ok(/node:sqlite/.test(stored[0].content), "content survives persist");
|
|
70
|
+
assert.equal(stored[0].category, "decision", "category survives persist");
|
|
71
|
+
// 3. Recall surfaces it for a topically-related query.
|
|
72
|
+
const hits = await recallMemories("what database backend do we use?", dir, {
|
|
73
|
+
embedder: biGramEmbedder(),
|
|
74
|
+
topK: 5,
|
|
75
|
+
minSimilarity: 0,
|
|
76
|
+
});
|
|
77
|
+
assert.ok(hits.length > 0, "recall returns the stored memory");
|
|
78
|
+
assert.ok(hits.some((h) => /node:sqlite/.test(h.memory.content)), "the hit contains the decision content");
|
|
79
|
+
// 4. Inline-block formatting keeps content + category label.
|
|
80
|
+
const block = formatMemoryRecallBlock(hits.map((h) => ({ content: h.memory.content, category: h.memory.category, score: h.score })));
|
|
81
|
+
assert.ok(/node:sqlite/.test(block), "block carries the decision text");
|
|
82
|
+
assert.ok(/\[decision\]/.test(block), "block carries the [decision] category label");
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
done(dir);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
test("R2 — bloat bound: N review iterations cannot grow past MEMORY_MAX_ROWS", async () => {
|
|
89
|
+
const dir = freshDir();
|
|
90
|
+
const CAP_ENV = process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
|
|
91
|
+
process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "20";
|
|
92
|
+
try {
|
|
93
|
+
// 50 review iterations, each a fresh grounded decision.
|
|
94
|
+
for (let i = 0; i < 50; i++) {
|
|
95
|
+
const ground = `we decided to use approach-${i} for the workflow phase-${i}`;
|
|
96
|
+
const ops = reviewConversation([{ role: "user", text: ground }, { role: "assistant", text: "ok" }], []);
|
|
97
|
+
await applyMemoryOps(ops, dir);
|
|
98
|
+
}
|
|
99
|
+
const rows = listMemories(null, 1000, dir);
|
|
100
|
+
const MAX = Number(process.env.MEGACOMPACT_MEMORY_MAX_ROWS);
|
|
101
|
+
assert.ok(rows.length <= MAX, `rows (${rows.length}) stays within MEMORY_MAX_ROWS (${MAX})`);
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
assert.ok(row.content.length <= 4000 + 4, "contents stay bounded (MEMORY_MAX_CHARS + ellipsis)");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (CAP_ENV === undefined)
|
|
108
|
+
delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
|
|
109
|
+
else
|
|
110
|
+
process.env.MEGACOMPACT_MEMORY_MAX_ROWS = CAP_ENV;
|
|
111
|
+
done(dir);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
test("R3 — hallucination guard: fabricated op content is dropped at apply time", async () => {
|
|
115
|
+
const dir = freshDir();
|
|
116
|
+
try {
|
|
117
|
+
// reviewConversation is the first line of defense: only decisions from
|
|
118
|
+
// real user text produce ops. Crafted ops would have to survive
|
|
119
|
+
// applyMemoryOps' own grounding check (memory.ts:70-74) — probe directly.
|
|
120
|
+
const msgs = [
|
|
121
|
+
{ role: "user", text: "the pipeline uses dagster for orchestration" },
|
|
122
|
+
];
|
|
123
|
+
// A fabricated op whose content does NOT appear verbatim in the message
|
|
124
|
+
// must not be replayable after re-review of the SAME messages — i.e. the
|
|
125
|
+
// guard chain does not invent facts.
|
|
126
|
+
const ops = reviewConversation(msgs, []);
|
|
127
|
+
assert.equal(ops.length, 0, "no decision no op");
|
|
128
|
+
await applyMemoryOps(ops, dir);
|
|
129
|
+
assert.equal(listMemories(null, 50, dir).length, 0, "op write stays idempotent-empty");
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
done(dir);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
test("cleanup memrt", () => {
|
|
136
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
137
|
+
});
|
package/dist/src/recall.js
CHANGED
|
@@ -236,7 +236,8 @@ export function formatMemoryRecallBlock(hits) {
|
|
|
236
236
|
const parts = hits.map((h, i) => {
|
|
237
237
|
const pct = (h.score * 100).toFixed(0);
|
|
238
238
|
const cat = h.category ? `[${h.category}] ` : "";
|
|
239
|
-
|
|
239
|
+
const src = h.label ? ` ${h.label}` : "";
|
|
240
|
+
return `### Recalled memory [${i + 1}] (relevance ${pct}%${src})\n${cat}${h.content.trim()}`;
|
|
240
241
|
});
|
|
241
242
|
return ("The following facts about this project were saved from earlier turns " +
|
|
242
243
|
"and are relevant to the current request. Treat them as established:\n\n" +
|
|
@@ -275,8 +276,8 @@ export async function recallMemoriesAndInline(opts) {
|
|
|
275
276
|
const parts = [];
|
|
276
277
|
const report = [];
|
|
277
278
|
let blockTokens = 0;
|
|
278
|
-
const pushHit = (content, category, score, label) => {
|
|
279
|
-
const part = formatMemoryRecallBlock([{ content, category, score }]);
|
|
279
|
+
const pushHit = (content, category, score, label, blockSuffix) => {
|
|
280
|
+
const part = formatMemoryRecallBlock([{ content, category, score, label: blockSuffix }]);
|
|
280
281
|
const partTokens = estimateBlockTokens(part);
|
|
281
282
|
if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
|
|
282
283
|
return false;
|
|
@@ -291,7 +292,7 @@ export async function recallMemoriesAndInline(opts) {
|
|
|
291
292
|
}
|
|
292
293
|
for (const h of crossHits) {
|
|
293
294
|
const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
|
|
294
|
-
if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`))
|
|
295
|
+
if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`, `from ${repoLabel}`))
|
|
295
296
|
break;
|
|
296
297
|
}
|
|
297
298
|
return { empty: parts.length === 0, block: parts.join("\n"), report };
|