pi-mega-compact 0.4.28 → 0.5.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 +47 -2
- package/dist/extensions/dashboard-server.js +66 -3
- package/dist/extensions/dashboard-server.test.js +95 -3
- package/dist/extensions/mega-commands.js +25 -9
- package/dist/extensions/mega-compact.test.js +133 -31
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-conflict-cmds.js +79 -0
- package/dist/extensions/mega-dashboard-cmds.js +6 -4
- package/dist/extensions/mega-events.js +144 -27
- package/dist/extensions/mega-pipeline.js +84 -1
- package/dist/extensions/mega-runtime.js +35 -2
- package/dist/extensions/mega-trim.js +48 -0
- package/dist/extensions/mega-trim.test.js +58 -0
- package/dist/src/config/dedup.js +1 -0
- package/dist/src/driftDetection.js +103 -0
- package/dist/src/driftDetection.test.js +87 -0
- package/dist/src/memory.js +147 -0
- package/dist/src/memory.test.js +41 -0
- package/dist/src/memoryConsolidate.test.js +38 -0
- package/dist/src/memoryOps.js +58 -0
- package/dist/src/memoryOps.test.js +41 -0
- package/dist/src/memoryRecall.js +60 -0
- package/dist/src/memoryRecall.test.js +92 -0
- package/dist/src/recall.js +70 -1
- package/dist/src/recall.test.js +69 -1
- package/dist/src/store/sqlite.js +127 -11
- package/dist/src/vectorStore.js +6 -1
- package/extensions/dashboard-server.test.ts +115 -3
- package/extensions/dashboard-server.ts +69 -4
- package/extensions/mega-commands.ts +24 -9
- package/extensions/mega-compact.test.ts +134 -31
- package/extensions/mega-config.ts +22 -0
- package/extensions/mega-conflict-cmds.ts +81 -0
- package/extensions/mega-dashboard-cmds.ts +6 -4
- package/extensions/mega-events.ts +139 -28
- package/extensions/mega-pipeline.ts +94 -1
- package/extensions/mega-runtime.ts +35 -2
- package/extensions/mega-trim.test.ts +64 -0
- package/extensions/mega-trim.ts +75 -0
- package/extensions/openclaw-mega-compact.ts +24 -9
- package/package.json +2 -2
- package/src/config/dedup.ts +2 -0
- package/src/driftDetection.test.ts +100 -0
- package/src/driftDetection.ts +136 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +164 -0
- package/src/memoryConsolidate.test.ts +47 -0
- package/src/memoryOps.test.ts +53 -0
- package/src/memoryOps.ts +75 -0
- package/src/memoryRecall.test.ts +100 -0
- package/src/memoryRecall.ts +83 -0
- package/src/recall.test.ts +77 -1
- package/src/recall.ts +94 -1
- package/src/store/sqlite.ts +188 -11
- package/src/store.ts +3 -0
- package/src/vectorStore.ts +10 -1
|
@@ -17,6 +17,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
|
+
import { closeVectorIndex } from "../src/store/vectorIndex.js";
|
|
20
21
|
const require = createRequire(import.meta.url);
|
|
21
22
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-ext-"));
|
|
22
23
|
// Isolate the machine-wide repo index so test runs (which call bindRepo ->
|
|
@@ -163,14 +164,18 @@ function harness(opts = {}) {
|
|
|
163
164
|
session,
|
|
164
165
|
};
|
|
165
166
|
}
|
|
166
|
-
test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
|
|
167
|
+
test("auto-trigger (legacy): past threshold persists a chkpt and starts a durable trim via ctx.compact", async () => {
|
|
167
168
|
const h = harness();
|
|
168
169
|
const messages = h.session;
|
|
169
170
|
// The mock session is tiny (~100 tokens). piCompactWouldNoop() would skip
|
|
170
171
|
// ctx.compact() for a transcript under pi's keepRecentTokens budget — so
|
|
171
172
|
// lower the floor to 0 to simulate a transcript large enough that pi WOULD
|
|
172
173
|
// compact (the positive path this test exercises).
|
|
174
|
+
// S16: this is the LEGACY path — the default no longer calls ctx.compact()
|
|
175
|
+
// (it returns a live-trimmed view instead). Set the legacy flag to exercise
|
|
176
|
+
// the v0.4.28 ctx.compact durable-trim flow this test asserts.
|
|
173
177
|
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
|
|
178
|
+
process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "true";
|
|
174
179
|
try {
|
|
175
180
|
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
176
181
|
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
@@ -178,34 +183,113 @@ test("auto-trigger: past threshold persists a chkpt and starts a durable trim",
|
|
|
178
183
|
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
179
184
|
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
|
|
180
185
|
assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
|
|
181
|
-
// The context handler
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
assert.equal(
|
|
185
|
-
assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
|
|
186
|
+
// The legacy context handler triggers pi's compaction flow (ctx.compact),
|
|
187
|
+
// which calls our session_before_compact handler to supply the DURABLE trim.
|
|
188
|
+
assert.equal(res, undefined, "legacy context handler returns nothing (no local drop)");
|
|
189
|
+
assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim (legacy path)");
|
|
186
190
|
// The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
|
|
187
191
|
assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
|
|
188
192
|
}
|
|
189
193
|
finally {
|
|
190
194
|
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
195
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
191
196
|
}
|
|
192
197
|
});
|
|
193
|
-
test("auto-trigger: skips ctx.compact() when pi would no-op (session too small)", async () => {
|
|
198
|
+
test("auto-trigger: skips ctx.compact() when pi would no-op (session too small, legacy path)", async () => {
|
|
194
199
|
const h = harness();
|
|
195
200
|
const messages = h.session;
|
|
196
201
|
// Default floor (20000): the tiny mock transcript is below pi's
|
|
197
202
|
// keepRecentTokens budget, so piCompactWouldNoop() must skip ctx.compact()
|
|
198
203
|
// rather than surface pi's "Nothing to compact (session too small)" throw.
|
|
204
|
+
// S16: exercised under the legacy flag (the default path never calls ctx.compact).
|
|
199
205
|
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
206
|
+
process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "true";
|
|
207
|
+
try {
|
|
208
|
+
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
209
|
+
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
210
|
+
assert.equal(res, undefined, "legacy context handler returns nothing (no local drop)");
|
|
211
|
+
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called — pi would no-op");
|
|
212
|
+
// Our recall checkpoint still persisted (Path A) — the durable trim is the
|
|
213
|
+
// only thing skipped; recall is independent of it.
|
|
214
|
+
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
215
|
+
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "recall checkpoint still persisted");
|
|
216
|
+
assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel still appended");
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
test("auto-trigger (S16): trims the live view and does NOT call ctx.compact()", async () => {
|
|
223
|
+
const h = harness();
|
|
224
|
+
const messages = h.session;
|
|
225
|
+
// S16 default: live context-event trim. No legacy flag. Lower the anchor floor
|
|
226
|
+
// so the trimmed recent window (4 messages, 2 user) clears the anchor check
|
|
227
|
+
// and the live trim actually fires — mirrors how the legacy test lowers the
|
|
228
|
+
// durable floor to exercise its positive path.
|
|
229
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
230
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
231
|
+
process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
|
|
232
|
+
try {
|
|
233
|
+
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
234
|
+
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
235
|
+
// S16: context handler returns a TRIMMED messages array (live trim), not undefined.
|
|
236
|
+
assert.ok(res && typeof res === "object", "context handler returns a result object (live trim)");
|
|
237
|
+
assert.ok(Array.isArray(res.messages), "result has a trimmed messages array");
|
|
238
|
+
// The trimmed view starts with the compacted summary (user-role) + is shorter.
|
|
239
|
+
assert.ok(res.messages.length < messages.length, "trimmed view is shorter than the full session");
|
|
240
|
+
// S16: ctx.compact() is NEVER called (it would stop the agent).
|
|
241
|
+
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called — compact-and-continue");
|
|
242
|
+
// The recall checkpoint is still persisted (the durable value).
|
|
243
|
+
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
244
|
+
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "recall checkpoint persisted under live trim");
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
test("auto-trigger (S16): does not trim when below the anchor floor (returns undefined, no ctx.compact)", async () => {
|
|
251
|
+
const h = harness();
|
|
252
|
+
// A session so short that buildLiveTrimmedView's anchor floor can't hold — the
|
|
253
|
+
// live trim skips this call (returns undefined, the next context event retries).
|
|
254
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
255
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
256
|
+
const shortSession = [h.session[0], h.session[1]]; // one user + one assistant
|
|
200
257
|
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
201
|
-
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
202
|
-
|
|
203
|
-
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
258
|
+
const res = await h.fire("context", { type: "context", messages: shortSession }, ctx);
|
|
259
|
+
// Either it skipped (undefined) or trimmed safely — but it must never call ctx.compact.
|
|
260
|
+
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called under live trim (short session)");
|
|
261
|
+
if (res === undefined) {
|
|
262
|
+
// skipped path is fine
|
|
263
|
+
assert.ok(true, "below anchor floor → no trim this call (retries next event)");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
test("auto-trigger (S16): sendUserMessage resume nudge fires only when idle + queued + not already nudged", async () => {
|
|
267
|
+
const h = harness();
|
|
268
|
+
// No queued messages → the nudge must NOT fire (the guard prevents busy-loops).
|
|
269
|
+
// We assert the extension did not throw and did not push a spurious resume.
|
|
270
|
+
const ctx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false });
|
|
271
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, ctx);
|
|
272
|
+
// No throw + no spurious nudge side-effect is the contract; appended stays
|
|
273
|
+
// free of any auto "continue" marker when there is no queued work.
|
|
274
|
+
assert.equal(h.appended.some((a) => a.t && /continue/i.test(String(a.d ?? ""))), false, "no spurious continue when no queued work");
|
|
275
|
+
});
|
|
276
|
+
test("auto-trigger (S16): durable trim still happens via pi native auto-compaction (session_before_compact)", async () => {
|
|
277
|
+
const h = harness();
|
|
278
|
+
// pi's native auto-compaction fires at agent-end with reason "threshold" (the
|
|
279
|
+
// CONTINUING path). Our session_before_compact handler must still supply the
|
|
280
|
+
// durable trim summary — independent of the live context-event trim.
|
|
281
|
+
const prep = {
|
|
282
|
+
firstKeptEntryId: "e2",
|
|
283
|
+
messagesToSummarize: h.session.slice(0, 4),
|
|
284
|
+
tokensBefore: 500,
|
|
285
|
+
};
|
|
286
|
+
const res = await h.fire("session_before_compact", {
|
|
287
|
+
type: "session_before_compact", reason: "threshold", willRetry: false,
|
|
288
|
+
signal: undefined, preparation: prep,
|
|
289
|
+
}, h.ctx());
|
|
290
|
+
assert.ok(res?.compaction, "we supply a durable compaction result to pi's native path");
|
|
291
|
+
assert.ok(res.compaction.firstKeptEntryId === "e2", "reuses pi's boundary (PREVENT-PI-002)");
|
|
292
|
+
assert.ok(res.compaction.summary.length > 0, "summary is non-empty");
|
|
209
293
|
});
|
|
210
294
|
test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
|
|
211
295
|
const h = harness();
|
|
@@ -323,10 +407,18 @@ test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
|
|
|
323
407
|
});
|
|
324
408
|
// ---- /dashboard commands ----------------------------------------------------
|
|
325
409
|
test("/dashboard-status reports no server when pid file missing", async () => {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
410
|
+
// Private base so this asserts "no server" on a range nothing else uses,
|
|
411
|
+
// not the machine-global 9320 family (which may hold a leftover/production server).
|
|
412
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = "49320";
|
|
413
|
+
try {
|
|
414
|
+
const h = harness();
|
|
415
|
+
const ctx = h.ctx();
|
|
416
|
+
await h.commands["mega-dashboard-status"].handler("", ctx);
|
|
417
|
+
assert.ok(h.notifies.some((n) => n.includes("not running")), "reports no server running");
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
421
|
+
}
|
|
330
422
|
});
|
|
331
423
|
test("/dashboard-stop reports no server when pid file missing", async () => {
|
|
332
424
|
const h = harness();
|
|
@@ -335,20 +427,23 @@ test("/dashboard-stop reports no server when pid file missing", async () => {
|
|
|
335
427
|
assert.ok(h.notifies.some((n) => n.includes("no dashboard server running")), "reports no server");
|
|
336
428
|
});
|
|
337
429
|
test("/dashboard skips server spawn when already running", async () => {
|
|
430
|
+
// Use a private dashboard port base for THIS test's harness + fake server so
|
|
431
|
+
// it never races the (parallel, hard-coded-9320) dashboard-server.test.js or
|
|
432
|
+
// a leftover production server. Set BEFORE harness() so registerDashboardCommands
|
|
433
|
+
// reads our base for findLivePort().
|
|
434
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = "29320";
|
|
338
435
|
const h = harness();
|
|
339
436
|
const confirms = [];
|
|
340
|
-
|
|
341
|
-
// (9320–9329) — isServerRunning() probes those ports, not the port.pid value.
|
|
437
|
+
const livPort = 29320; // inside the harness's private scan range (29320–29329)
|
|
342
438
|
const { createServer } = await import("node:http");
|
|
343
439
|
const server = createServer((_req, res) => {
|
|
344
440
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
345
441
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test", version: 1, config: {}, session: {}, context: {}, trigger: {}, store: {} }));
|
|
346
442
|
});
|
|
347
|
-
await new Promise((r) => server.listen(
|
|
348
|
-
const addr = server.address();
|
|
443
|
+
await new Promise((r) => server.listen(livPort, "127.0.0.1", r));
|
|
349
444
|
const { join: j } = await import("node:path");
|
|
350
445
|
const { writeFileSync: wf } = await import("node:fs");
|
|
351
|
-
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port:
|
|
446
|
+
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: livPort, pid: process.pid }));
|
|
352
447
|
const ctx = h.ctx({
|
|
353
448
|
ui: {
|
|
354
449
|
setStatus: () => { },
|
|
@@ -362,11 +457,14 @@ test("/dashboard skips server spawn when already running", async () => {
|
|
|
362
457
|
assert.ok(h.notifies.some((n) => n.includes("already running")), "reports already running");
|
|
363
458
|
assert.ok(confirms.length > 0, "confirm dialog was shown");
|
|
364
459
|
await new Promise((r) => server.close(() => r()));
|
|
460
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
365
461
|
});
|
|
366
462
|
test("/dashboard-status reports running after dashboard start", async () => {
|
|
463
|
+
// Private dashboard port base for this harness — never collides with the
|
|
464
|
+
// parallel dashboard-server.test.js (9320 family) or a leftover server.
|
|
465
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = "39320";
|
|
367
466
|
const h = harness();
|
|
368
|
-
|
|
369
|
-
// (9320–9329) or isServerRunning() won't detect it.
|
|
467
|
+
const livPort = 39320;
|
|
370
468
|
const { createServer } = await import("node:http");
|
|
371
469
|
const { join: j } = await import("node:path");
|
|
372
470
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -374,13 +472,13 @@ test("/dashboard-status reports running after dashboard start", async () => {
|
|
|
374
472
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
375
473
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
|
|
376
474
|
});
|
|
377
|
-
await new Promise((r) => server.listen(
|
|
378
|
-
|
|
379
|
-
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: addr.port, pid: process.pid }));
|
|
475
|
+
await new Promise((r) => server.listen(livPort, "127.0.0.1", r));
|
|
476
|
+
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: livPort, pid: process.pid }));
|
|
380
477
|
const ctx = h.ctx();
|
|
381
478
|
await h.commands["mega-dashboard-status"].handler("", ctx);
|
|
382
|
-
assert.ok(h.notifies.some((n) => n.includes("running") && n.includes(String(
|
|
479
|
+
assert.ok(h.notifies.some((n) => n.includes("running") && n.includes(String(livPort))), "reports running with port");
|
|
383
480
|
await new Promise((r) => server.close(() => r()));
|
|
481
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
384
482
|
});
|
|
385
483
|
test("state snapshot writes dashboard.json after compaction", async () => {
|
|
386
484
|
const h = harness();
|
|
@@ -422,6 +520,10 @@ test("events.log receives compaction events", async () => {
|
|
|
422
520
|
assert.ok(ex(j(h.stateDir, "dashboard.json")), "dashboard.json proves post-compact ran");
|
|
423
521
|
}
|
|
424
522
|
});
|
|
425
|
-
test("cleanup", () => {
|
|
523
|
+
test("cleanup", async () => {
|
|
524
|
+
// Terminate the global PGlite cross-repo index (WASM worker thread) so the
|
|
525
|
+
// test process can exit. Without this, node --test never returns even though
|
|
526
|
+
// every test passed — the leaked worker keeps the event loop alive.
|
|
527
|
+
await closeVectorIndex();
|
|
426
528
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
427
529
|
});
|
|
@@ -70,6 +70,11 @@ export function loadConfig() {
|
|
|
70
70
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
71
71
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
72
72
|
raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
73
|
+
legacyDurableTrim: envBool("MEGACOMPACT_LEGACY_DURABLE_TRIM", false),
|
|
74
|
+
crossRepoEnabled: envBool("MEGACOMPACT_CROSSREPO_ENABLED", true),
|
|
75
|
+
crossRepoCosine: Number(process.env.MEGACOMPACT_CROSSREPO_COSINE ?? "0.90"),
|
|
76
|
+
memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
|
|
77
|
+
memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
|
|
73
78
|
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
74
79
|
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
75
80
|
debug: envBool("MEGACOMPACT_DEBUG", false),
|
|
@@ -118,4 +118,83 @@ export function registerConflictCommands(pi, runtime) {
|
|
|
118
118
|
ctx.ui.notify(memoryLine(m));
|
|
119
119
|
},
|
|
120
120
|
});
|
|
121
|
+
// Shortform aliases — `m save "..."`, `m status`, `m list`, `m search <q>`,
|
|
122
|
+
// `m recall <id>`. Delegates to the same SQLite store so there's one source
|
|
123
|
+
// of truth. The /mega-memory command remains the canonical form.
|
|
124
|
+
pi.registerCommand("m", {
|
|
125
|
+
description: "Shortform alias for /mega-memory. Usage: /m save <text> | list | search <q> | recall <id> | status",
|
|
126
|
+
handler: async (args, ctx) => {
|
|
127
|
+
const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
|
|
128
|
+
const parts = args.trim().split(/\s+/);
|
|
129
|
+
const sub = parts[0]?.toLowerCase() ?? "list";
|
|
130
|
+
if (sub === "save") {
|
|
131
|
+
// Strip leading "save" so /m save "#foo bar" works the same as the
|
|
132
|
+
// canonical form. Then strip a balanced outer quote pair if the user
|
|
133
|
+
// wrote /m save "..." — common when the text contains spaces.
|
|
134
|
+
let text = args.trim().slice(4).trim();
|
|
135
|
+
const mq = text.match(/^["“](.*)["”]$/s);
|
|
136
|
+
if (mq)
|
|
137
|
+
text = mq[1].trim();
|
|
138
|
+
if (!text) {
|
|
139
|
+
ctx.ui.notify('[/m] usage: /m save "<text>" or /m save <text>');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const tagMatches = [...text.matchAll(/#([\w-]+)/g)].map((m) => m[1]);
|
|
143
|
+
const content = text.replace(/#[\w-]+/g, "").trim();
|
|
144
|
+
const id = addMemory({ content, tags: tagMatches }, repo, runtime.currentStateDir);
|
|
145
|
+
ctx.ui.notify(`[/m] saved #${id} to ${repo.split(/[\\/]/).pop()}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (sub === "search") {
|
|
149
|
+
const q = parts.slice(1).join(" ").trim();
|
|
150
|
+
if (!q) {
|
|
151
|
+
ctx.ui.notify("[/m] usage: /m search <query>");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const hits = searchMemories(q, repo, 50, runtime.currentStateDir);
|
|
155
|
+
if (!hits.length) {
|
|
156
|
+
ctx.ui.notify("[/m] no memories match.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
for (const mem of hits)
|
|
160
|
+
ctx.ui.notify(memoryLine(mem));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (sub === "recall") {
|
|
164
|
+
const id = Number(parts[1]);
|
|
165
|
+
if (!Number.isFinite(id) || parts[1] === undefined) {
|
|
166
|
+
ctx.ui.notify("[/m] usage: /m recall <id>");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (recallMemory(id, runtime.currentStateDir)) {
|
|
170
|
+
const found = listMemories(repo, 1000, runtime.currentStateDir).find((mem) => mem.id === id);
|
|
171
|
+
ctx.ui.notify(found ? `[/m] ${memoryLine(found)}` : `[/m] recalled #${id}`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
ctx.ui.notify(`[/m] #${id} not found.`);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (sub === "status") {
|
|
179
|
+
const all = listMemories(repo, 1000, runtime.currentStateDir);
|
|
180
|
+
const byKind = all.reduce((acc, m) => {
|
|
181
|
+
acc[m.kind] = (acc[m.kind] ?? 0) + 1;
|
|
182
|
+
return acc;
|
|
183
|
+
}, {});
|
|
184
|
+
const kinds = Object.entries(byKind).map(([k, n]) => `${k}=${n}`).join(", ");
|
|
185
|
+
const head = `[/m] ${all.length} memory record(s) in ${repo.split(/[\\/]/).pop() ?? repo}`;
|
|
186
|
+
ctx.ui.notify(kinds ? `${head} (${kinds})` : head);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// default: list
|
|
190
|
+
const all = listMemories(repo, 50, runtime.currentStateDir);
|
|
191
|
+
if (!all.length) {
|
|
192
|
+
ctx.ui.notify("[/m] no saved memories yet. Use /m save <text>.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
ctx.ui.notify(`[/m] ${all.length} memory record(s):`);
|
|
196
|
+
for (const mem of all)
|
|
197
|
+
ctx.ui.notify(memoryLine(mem));
|
|
198
|
+
},
|
|
199
|
+
});
|
|
121
200
|
}
|
|
@@ -18,11 +18,13 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
18
18
|
// when we fall back to the .ts source outside node_modules; false when using
|
|
19
19
|
// the shipped compiled dist/extensions/dashboard-server.js).
|
|
20
20
|
let dashboardNeedsStrip = false;
|
|
21
|
-
// The dashboard server binds
|
|
22
|
-
// in dashboard-server.js
|
|
23
|
-
// readiness even when port.pid landed in a different
|
|
21
|
+
// The dashboard server binds a 10-port range starting at MEGACOMPACT_DASHBOARD_PORT
|
|
22
|
+
// (default 9320) — see TARGET_PORT/PORT_RANGE in dashboard-server.js. Probe each for
|
|
23
|
+
// a live /api/snapshot so we detect readiness even when port.pid landed in a different
|
|
24
|
+
// state dir than we poll. Configurable so tests can use a private, non-colliding range.
|
|
25
|
+
const DASH_BASE = Number(process.env.MEGACOMPACT_DASHBOARD_PORT ?? "9320");
|
|
24
26
|
async function findLivePort() {
|
|
25
|
-
for (let port =
|
|
27
|
+
for (let port = DASH_BASE; port <= DASH_BASE + 9; port++) {
|
|
26
28
|
try {
|
|
27
29
|
const res = await fetch(`http://localhost:${port}/api/snapshot`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: localhost liveness probe of the dashboard server this extension spawned
|
|
28
30
|
if (res.ok)
|
|
@@ -10,8 +10,10 @@ import { normalizeSessionId } from "../src/store.js";
|
|
|
10
10
|
import { autoCompactCheck } from "../src/compact.js";
|
|
11
11
|
import { estimateSessionTokens } from "../src/tokens.js";
|
|
12
12
|
import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
|
|
13
|
-
import { runCompact, doRecall, piCompactWouldNoop } from "./mega-pipeline.js";
|
|
13
|
+
import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-pipeline.js";
|
|
14
|
+
import { recallMemoriesAndInline } from "../src/recall.js";
|
|
14
15
|
import { driveNativeCompaction } from "./mega-compact-driver.js";
|
|
16
|
+
import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
|
|
15
17
|
import { pressureFromPct } from "./mega-config.js";
|
|
16
18
|
/** Register all pi lifecycle event handlers. */
|
|
17
19
|
export function registerEventHandlers(pi, runtime, config) {
|
|
@@ -25,6 +27,8 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
25
27
|
runtime.resetRuntime(ctx.sessionManager.getSessionId());
|
|
26
28
|
runtime.captureModel(ctx); // best-effort: ctx.model may be set by session start
|
|
27
29
|
runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
|
|
30
|
+
// S21: clear any stale memory block from a prior session.
|
|
31
|
+
runtime.pendingMemoryRecallBlock = undefined;
|
|
28
32
|
// Auto-inline on resume/fork/continue: stage the most relevant checkpoints
|
|
29
33
|
// so the next before_agent_start prepends them to the system prompt.
|
|
30
34
|
// Triggered whenever this session already has persisted checkpoints AND a
|
|
@@ -36,13 +40,29 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
36
40
|
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
37
41
|
const query = recentUserQuery(ctx);
|
|
38
42
|
if (query && runtime.store.stats(sid).checkpointCount > 0) {
|
|
39
|
-
|
|
43
|
+
// S17: use the async variant on resume so cross-repo HNSW recall can
|
|
44
|
+
// augment when this repo's store is thin. session_start is an async-safe
|
|
45
|
+
// point (unlike the mid-turn context handler, which stays sync).
|
|
46
|
+
const r = await doRecallAsync(runtime, config, ctx, query, "resume", { crossRepo: config.crossRepoEnabled });
|
|
40
47
|
if (!r.empty) {
|
|
41
48
|
runtime.pendingRecallBlock = r.block;
|
|
42
|
-
|
|
43
|
-
runtime.
|
|
49
|
+
const crossLabel = r.toInject.some((h) => h.repoId) ? " (cross-repo)" : "";
|
|
50
|
+
runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt${crossLabel}`);
|
|
51
|
+
runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId), crossRepo: r.toInject.some((h) => h.repoId) });
|
|
44
52
|
}
|
|
45
53
|
}
|
|
54
|
+
// S21: parallel memory recall. Same async context so we can await without
|
|
55
|
+
// breaking the handler contract. Best-effort — never throws.
|
|
56
|
+
try {
|
|
57
|
+
const mr = await recallMemoriesAndInline({
|
|
58
|
+
query, stateDir: runtime.getStateDir(), limit: 5,
|
|
59
|
+
});
|
|
60
|
+
if (!mr.empty)
|
|
61
|
+
runtime.pendingMemoryRecallBlock = mr.block;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
runtime.logger.warn("memory-recall skipped", { err: String(err) });
|
|
65
|
+
}
|
|
46
66
|
}
|
|
47
67
|
runtime.dashboard.event("session_start", { reason: event.reason, sessionId: runtime.rt.sessionId });
|
|
48
68
|
runtime.snapshot(ctx);
|
|
@@ -60,6 +80,15 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
60
80
|
runtime.pendingRecallBlock = r.block;
|
|
61
81
|
runtime.logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
|
|
62
82
|
}
|
|
83
|
+
// S21: parallel memory recall. Trigram embedder is sub-ms; await is fine.
|
|
84
|
+
try {
|
|
85
|
+
const mr = await recallMemoriesAndInline({ query, stateDir: runtime.getStateDir(), limit: 5 });
|
|
86
|
+
if (!mr.empty)
|
|
87
|
+
runtime.pendingMemoryRecallBlock = mr.block;
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
runtime.logger.warn("memory-recall skipped", { err: String(err) });
|
|
91
|
+
}
|
|
63
92
|
}
|
|
64
93
|
}
|
|
65
94
|
runtime.dashboard.event("session_tree", { sessionId: runtime.rt.sessionId });
|
|
@@ -68,11 +97,14 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
68
97
|
// ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
|
|
69
98
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
70
99
|
runtime.captureModel(ctx); // most reliable point ctx.model is populated
|
|
71
|
-
|
|
100
|
+
const cpBlock = runtime.pendingRecallBlock;
|
|
101
|
+
const memBlock = runtime.pendingMemoryRecallBlock;
|
|
102
|
+
if (!cpBlock && !memBlock)
|
|
72
103
|
return;
|
|
73
|
-
|
|
74
|
-
runtime.
|
|
75
|
-
|
|
104
|
+
runtime.pendingRecallBlock = undefined;
|
|
105
|
+
runtime.pendingMemoryRecallBlock = undefined;
|
|
106
|
+
const composed = [cpBlock, memBlock].filter(Boolean).join("\n\n");
|
|
107
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${composed}` };
|
|
76
108
|
});
|
|
77
109
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
78
110
|
runtime.setStatus(ctx, undefined);
|
|
@@ -98,6 +130,24 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
98
130
|
else {
|
|
99
131
|
runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
|
|
100
132
|
}
|
|
133
|
+
// S16 continuation fallback: if the turn settled idle right after a live-trim
|
|
134
|
+
// compaction AND there is queued work AND we haven't nudged recently, nudge
|
|
135
|
+
// once so the agent continues (the live trim should make this rare). Guarded
|
|
136
|
+
// to never busy-loop: one nudge per 30s, only when truly idle + queued.
|
|
137
|
+
if (config.auto && runtime.activeAgents === 0) {
|
|
138
|
+
try {
|
|
139
|
+
const idle = ctx.isIdle?.() ?? true;
|
|
140
|
+
const queued = ctx.hasPendingMessages?.() ?? false;
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
if (idle && queued && now >= runtime.resumeNudgeUntil) {
|
|
143
|
+
runtime.resumeNudgeUntil = now + 30_000;
|
|
144
|
+
pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* non-fatal: a failed nudge never blocks */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
101
151
|
runtime.snapshot(ctx);
|
|
102
152
|
});
|
|
103
153
|
pi.on("turn_start", async (event, ctx) => {
|
|
@@ -108,16 +158,43 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
108
158
|
pi.on("turn_end", async (event, ctx) => {
|
|
109
159
|
runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
|
|
110
160
|
runtime.snapshot(ctx);
|
|
161
|
+
// S20: auto-review the conversation every N turns and persist durable
|
|
162
|
+
// memories. Best-effort + non-fatal: a review failure must never break the
|
|
163
|
+
// agent loop. Debounced by memoryReviewInterval turns.
|
|
164
|
+
if (config.memoryAutoReview && runtime.currentTurn > 0 && runtime.currentTurn % config.memoryReviewInterval === 0) {
|
|
165
|
+
try {
|
|
166
|
+
const { reviewConversation } = await import("../src/memory.js");
|
|
167
|
+
const { applyMemoryOps } = await import("../src/memoryOps.js");
|
|
168
|
+
const entries = ctx.sessionManager.getEntries();
|
|
169
|
+
const view = runtime.engineView(entries.flatMap((e) => (e.message ? [e.message] : [])));
|
|
170
|
+
const ops = reviewConversation(view, []);
|
|
171
|
+
if (ops.length) {
|
|
172
|
+
await applyMemoryOps(ops, runtime.currentStateDir);
|
|
173
|
+
// S21.2: a memory op landed in this turn window. The pipeline reads
|
|
174
|
+
// this counter after a successful compaction and fires
|
|
175
|
+
// `consolidateMemories` only when it's > 0.
|
|
176
|
+
runtime.memoriesTouchedThisCompaction += ops.length;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* non-fatal — auto-review must not break the turn loop */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
111
183
|
});
|
|
112
|
-
// ---- Auto-trigger:
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
// We
|
|
120
|
-
//
|
|
184
|
+
// ---- Auto-trigger: live trim (compact and continue) + native durable ----
|
|
185
|
+
// S16 redesign: we NO LONGER call ctx.compact() from the auto-trigger by
|
|
186
|
+
// default. That mapped to pi's MANUAL compaction path, which abort()s the
|
|
187
|
+
// in-flight turn (agent-session.js:1345) and stops the agent. Instead:
|
|
188
|
+
// - LIVE: return { messages: trimmedView } from the context event. This
|
|
189
|
+
// feeds pi's transformContext (sdk.js:226 → agent-loop.js:180) so the
|
|
190
|
+
// model sees a compacted window EVERY LLM call, with no abort. The turn
|
|
191
|
+
// continues. We persist our recall checkpoint (the durable value) first.
|
|
192
|
+
// - DURABLE: pi's NATIVE auto-compaction fires at agent-end
|
|
193
|
+
// (agent-session.js:1565), continues (return hasQueuedMessages()), and
|
|
194
|
+
// emits session_before_compact — where OUR driveNativeCompaction supplies
|
|
195
|
+
// the summary and pi truncates the transcript on disk. No ctx.compact().
|
|
196
|
+
// Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
|
|
197
|
+
// path (kept one release as rollback).
|
|
121
198
|
pi.on("context", async (event, ctx) => {
|
|
122
199
|
if (!config.auto)
|
|
123
200
|
return;
|
|
@@ -151,17 +228,57 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
151
228
|
const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
|
|
152
229
|
if (ran.skipped)
|
|
153
230
|
return;
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
if (
|
|
231
|
+
// LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
|
|
232
|
+
// manual compact path aborts the in-flight turn — only used behind the flag.
|
|
233
|
+
// Read live from env (in addition to the load-time config) so the flag can be
|
|
234
|
+
// toggled per-test without reloading the module; config.legacyDurableTrim is
|
|
235
|
+
// the cached default. (Mirrors how piCompactWouldNoop re-reads its floor.)
|
|
236
|
+
const legacy = config.legacyDurableTrim || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
|
|
237
|
+
if (legacy) {
|
|
238
|
+
if (piCompactWouldNoop(ctx))
|
|
239
|
+
return;
|
|
240
|
+
ctx.compact({ customInstructions: undefined });
|
|
161
241
|
return;
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
242
|
+
}
|
|
243
|
+
// S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
|
|
244
|
+
// Non-destructive: pi keeps the real transcript; only this LLM call sees the
|
|
245
|
+
// trimmed window. We compute the cut on the engine view (pure, tested) then
|
|
246
|
+
// slice the ORIGINAL pi AgentMessage[] from that index (lossless alignment,
|
|
247
|
+
// mirroring dropCompactedRange) and prepend a user-role summary message.
|
|
248
|
+
// A build failure or unsafe cut returns nothing (no trim this call — the
|
|
249
|
+
// next context event retries). The anchor floor is read live from env (the
|
|
250
|
+
// config value is the cached default) so it can be tuned per-test / per-run
|
|
251
|
+
// without reloading the module.
|
|
252
|
+
try {
|
|
253
|
+
const anchorEnv = process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
254
|
+
const anchorUserMessages = (anchorEnv != null && anchorEnv !== "" && Number.isFinite(Number(anchorEnv)))
|
|
255
|
+
? Number(anchorEnv)
|
|
256
|
+
: config.anchorUserMessages;
|
|
257
|
+
const cut = computeLiveTrimCut(view, {
|
|
258
|
+
compactedFrom: ran.result.compactedFrom,
|
|
259
|
+
summary: ran.result.summary,
|
|
260
|
+
anchorUserMessages,
|
|
261
|
+
});
|
|
262
|
+
if (cut === null)
|
|
263
|
+
return; // unsafe / below anchor floor — no trim this call
|
|
264
|
+
const summaryMsg = liveTrimSummaryMessage({
|
|
265
|
+
compactedFrom: ran.result.compactedFrom,
|
|
266
|
+
summary: ran.result.summary,
|
|
267
|
+
anchorUserMessages: config.anchorUserMessages,
|
|
268
|
+
});
|
|
269
|
+
// Synthesize a user-role AgentMessage carrying the compacted summary.
|
|
270
|
+
const summaryAgentMsg = {
|
|
271
|
+
role: "user",
|
|
272
|
+
content: summaryMsg.text,
|
|
273
|
+
timestamp: Date.now(),
|
|
274
|
+
};
|
|
275
|
+
const recent = messages.slice(cut); // guardrails-allow PREVENT-PI-002: `cut` is the pre-sanitized `compactedFrom` produced by src/boundary.ts computeDropRange, so the preserved run begins on a toolPair-safe index.
|
|
276
|
+
runtime.snapshot(ctx);
|
|
277
|
+
return { messages: [summaryAgentMsg, ...recent] };
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return; // non-fatal: no trim this call; the next context event retries
|
|
281
|
+
}
|
|
165
282
|
});
|
|
166
283
|
// ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
|
|
167
284
|
// We run the Trident pipeline to produce a compressed summary, then return
|