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.
Files changed (56) hide show
  1. package/README.md +47 -2
  2. package/dist/extensions/dashboard-server.js +66 -3
  3. package/dist/extensions/dashboard-server.test.js +95 -3
  4. package/dist/extensions/mega-commands.js +25 -9
  5. package/dist/extensions/mega-compact.test.js +133 -31
  6. package/dist/extensions/mega-config.js +5 -0
  7. package/dist/extensions/mega-conflict-cmds.js +79 -0
  8. package/dist/extensions/mega-dashboard-cmds.js +6 -4
  9. package/dist/extensions/mega-events.js +144 -27
  10. package/dist/extensions/mega-pipeline.js +84 -1
  11. package/dist/extensions/mega-runtime.js +35 -2
  12. package/dist/extensions/mega-trim.js +48 -0
  13. package/dist/extensions/mega-trim.test.js +58 -0
  14. package/dist/src/config/dedup.js +1 -0
  15. package/dist/src/driftDetection.js +103 -0
  16. package/dist/src/driftDetection.test.js +87 -0
  17. package/dist/src/memory.js +147 -0
  18. package/dist/src/memory.test.js +41 -0
  19. package/dist/src/memoryConsolidate.test.js +38 -0
  20. package/dist/src/memoryOps.js +58 -0
  21. package/dist/src/memoryOps.test.js +41 -0
  22. package/dist/src/memoryRecall.js +60 -0
  23. package/dist/src/memoryRecall.test.js +92 -0
  24. package/dist/src/recall.js +70 -1
  25. package/dist/src/recall.test.js +69 -1
  26. package/dist/src/store/sqlite.js +127 -11
  27. package/dist/src/vectorStore.js +6 -1
  28. package/extensions/dashboard-server.test.ts +115 -3
  29. package/extensions/dashboard-server.ts +69 -4
  30. package/extensions/mega-commands.ts +24 -9
  31. package/extensions/mega-compact.test.ts +134 -31
  32. package/extensions/mega-config.ts +22 -0
  33. package/extensions/mega-conflict-cmds.ts +81 -0
  34. package/extensions/mega-dashboard-cmds.ts +6 -4
  35. package/extensions/mega-events.ts +139 -28
  36. package/extensions/mega-pipeline.ts +94 -1
  37. package/extensions/mega-runtime.ts +35 -2
  38. package/extensions/mega-trim.test.ts +64 -0
  39. package/extensions/mega-trim.ts +75 -0
  40. package/extensions/openclaw-mega-compact.ts +24 -9
  41. package/package.json +2 -2
  42. package/src/config/dedup.ts +2 -0
  43. package/src/driftDetection.test.ts +100 -0
  44. package/src/driftDetection.ts +136 -0
  45. package/src/memory.test.ts +46 -0
  46. package/src/memory.ts +164 -0
  47. package/src/memoryConsolidate.test.ts +47 -0
  48. package/src/memoryOps.test.ts +53 -0
  49. package/src/memoryOps.ts +75 -0
  50. package/src/memoryRecall.test.ts +100 -0
  51. package/src/memoryRecall.ts +83 -0
  52. package/src/recall.test.ts +77 -1
  53. package/src/recall.ts +94 -1
  54. package/src/store/sqlite.ts +188 -11
  55. package/src/store.ts +3 -0
  56. 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 no longer drops messages itself (that was ephemeral —
182
- // the read-path token-growth bug). It triggers pi's compaction flow, which
183
- // calls our session_before_compact handler to supply the DURABLE trim.
184
- assert.equal(res, undefined, "context handler returns nothing (no local drop)");
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
- assert.equal(res, undefined, "context handler returns nothing (no local drop)");
203
- assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called pi would no-op");
204
- // Our recall checkpoint still persisted (Path A) — the durable trim is the
205
- // only thing skipped; recall is independent of it.
206
- const { listCheckpoints } = await import("../src/store/sqlite.js");
207
- assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "recall checkpoint still persisted");
208
- assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel still appended");
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
- const h = harness();
327
- const ctx = h.ctx();
328
- await h.commands["mega-dashboard-status"].handler("", ctx);
329
- assert.ok(h.notifies.some((n) => n.includes("not running")), "reports no server running");
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
- // Set up a fake HTTP server on a port inside the dashboard's scan range
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(9320, "127.0.0.1", r));
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: addr.port, pid: process.pid }));
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
- // Write a fake port.pid; the server must listen inside the scan range
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(9321, "127.0.0.1", r));
378
- const addr = server.address();
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(addr.port))), "reports running with port");
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 9320–9329 (TARGET_PORT..TARGET_PORT+PORT_RANGE-1
22
- // in dashboard-server.js). Probe each for a live /api/snapshot so we can detect
23
- // readiness even when port.pid landed in a different state dir than we poll.
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 = 9320; port <= 9329; 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
- const r = doRecall(runtime, config, ctx, query, "resume");
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
- runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
43
- runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
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
- if (!runtime.pendingRecallBlock)
100
+ const cpBlock = runtime.pendingRecallBlock;
101
+ const memBlock = runtime.pendingMemoryRecallBlock;
102
+ if (!cpBlock && !memBlock)
72
103
  return;
73
- const block = runtime.pendingRecallBlock;
74
- runtime.pendingRecallBlock = undefined; // one-shot: consume so we never double-inject
75
- return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
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: own the decision, pi owns the durable write ----------
113
- // OUR auto-trigger (over threshold + debounce): persist our Trident checkpoint,
114
- // then start pi's compaction flow via ctx.compact(). That fires
115
- // `session_before_compact`, where OUR handler returns our summary +
116
- // firstKeptEntryId, and pi durably writes the trim to disk (appendCompaction).
117
- // Result: auto-compact AND a durable trim — resume reloads the trimmed window,
118
- // no full-reload + additive recall inflation (Fix B kills the token-growth bug).
119
- // We do NOT drop messages here (that would be ephemeral; the read-only session
120
- // manager can't trim disk, so the trim has to come through pi).
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
- // Skip pi's durable-trim flow when pi would no-op ("Nothing to compact /
155
- // Already compacted"). pi throws that error *before* session_before_compact
156
- // fires and renders it via a compaction_end event we can't mute (onError is
157
- // too late), so the only fix is to not call ctx.compact() on a no-op. Our
158
- // recall checkpoint above is already persisted; the durable trim is
159
- // unnecessary for a transcript pi would keep in full anyway.
160
- if (piCompactWouldNoop(ctx))
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
- // Start pi's compaction flow so our session_before_compact handler can
163
- // supply the durable trim (pi writes it to disk). We never use pi's summary.
164
- ctx.compact({ customInstructions: undefined });
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