pi-mega-compact 0.4.28 → 0.5.0

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 (59) hide show
  1. package/README.md +47 -2
  2. package/dist/extensions/dashboard-server.js +58 -2
  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 +14 -0
  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 +63 -2
  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 +15 -0
  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
  57. package/dist/extensions/openclaw-mega-compact.js +0 -291
  58. package/dist/src/minilm.js +0 -92
  59. package/dist/src/wordpiece.js +0 -129
@@ -8,12 +8,15 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext, ContextEvent, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
11
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
11
12
  import { normalizeSessionId } from "../src/store.js";
12
13
  import { autoCompactCheck } from "../src/compact.js";
13
14
  import { estimateSessionTokens } from "../src/tokens.js";
14
15
  import { MegaRuntime, recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
15
- import { runCompact, doRecall, piCompactWouldNoop } from "./mega-pipeline.js";
16
+ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-pipeline.js";
17
+ import { recallMemoriesAndInline } from "../src/recall.js";
16
18
  import { driveNativeCompaction } from "./mega-compact-driver.js";
19
+ import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
17
20
  import { pressureFromPct, type MegaConfig } from "./mega-config.js";
18
21
 
19
22
  /** Register all pi lifecycle event handlers. */
@@ -29,6 +32,8 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
29
32
  runtime.resetRuntime(ctx.sessionManager.getSessionId());
30
33
  runtime.captureModel(ctx); // best-effort: ctx.model may be set by session start
31
34
  runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
35
+ // S21: clear any stale memory block from a prior session.
36
+ runtime.pendingMemoryRecallBlock = undefined;
32
37
  // Auto-inline on resume/fork/continue: stage the most relevant checkpoints
33
38
  // so the next before_agent_start prepends them to the system prompt.
34
39
  // Triggered whenever this session already has persisted checkpoints AND a
@@ -40,13 +45,27 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
40
45
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
41
46
  const query = recentUserQuery(ctx);
42
47
  if (query && runtime.store.stats(sid).checkpointCount > 0) {
43
- const r = doRecall(runtime, config, ctx, query, "resume");
48
+ // S17: use the async variant on resume so cross-repo HNSW recall can
49
+ // augment when this repo's store is thin. session_start is an async-safe
50
+ // point (unlike the mid-turn context handler, which stays sync).
51
+ const r = await doRecallAsync(runtime, config, ctx, query, "resume", { crossRepo: config.crossRepoEnabled });
44
52
  if (!r.empty) {
45
53
  runtime.pendingRecallBlock = r.block;
46
- runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
47
- runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
54
+ const crossLabel = r.toInject.some((h) => h.repoId) ? " (cross-repo)" : "";
55
+ runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt${crossLabel}`);
56
+ runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId), crossRepo: r.toInject.some((h) => h.repoId) });
48
57
  }
49
58
  }
59
+ // S21: parallel memory recall. Same async context so we can await without
60
+ // breaking the handler contract. Best-effort — never throws.
61
+ try {
62
+ const mr = await recallMemoriesAndInline({
63
+ query, stateDir: runtime.getStateDir(), limit: 5,
64
+ });
65
+ if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
66
+ } catch (err) {
67
+ runtime.logger.warn("memory-recall skipped", { err: String(err) });
68
+ }
50
69
  }
51
70
  runtime.dashboard.event("session_start", { reason: event.reason, sessionId: runtime.rt.sessionId });
52
71
  runtime.snapshot(ctx);
@@ -65,6 +84,13 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
65
84
  runtime.pendingRecallBlock = r.block;
66
85
  runtime.logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
67
86
  }
87
+ // S21: parallel memory recall. Trigram embedder is sub-ms; await is fine.
88
+ try {
89
+ const mr = await recallMemoriesAndInline({ query, stateDir: runtime.getStateDir(), limit: 5 });
90
+ if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
91
+ } catch (err) {
92
+ runtime.logger.warn("memory-recall skipped", { err: String(err) });
93
+ }
68
94
  }
69
95
  }
70
96
  runtime.dashboard.event("session_tree", { sessionId: runtime.rt.sessionId });
@@ -74,10 +100,13 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
74
100
  // ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
75
101
  pi.on("before_agent_start", async (event, ctx) => {
76
102
  runtime.captureModel(ctx); // most reliable point ctx.model is populated
77
- if (!runtime.pendingRecallBlock) return;
78
- const block = runtime.pendingRecallBlock;
79
- runtime.pendingRecallBlock = undefined; // one-shot: consume so we never double-inject
80
- return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
103
+ const cpBlock = runtime.pendingRecallBlock;
104
+ const memBlock = runtime.pendingMemoryRecallBlock;
105
+ if (!cpBlock && !memBlock) return;
106
+ runtime.pendingRecallBlock = undefined;
107
+ runtime.pendingMemoryRecallBlock = undefined;
108
+ const composed = [cpBlock, memBlock].filter(Boolean).join("\n\n");
109
+ return { systemPrompt: `${event.systemPrompt}\n\n${composed}` };
81
110
  });
82
111
 
83
112
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -105,6 +134,23 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
105
134
  } else {
106
135
  runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
107
136
  }
137
+ // S16 continuation fallback: if the turn settled idle right after a live-trim
138
+ // compaction AND there is queued work AND we haven't nudged recently, nudge
139
+ // once so the agent continues (the live trim should make this rare). Guarded
140
+ // to never busy-loop: one nudge per 30s, only when truly idle + queued.
141
+ if (config.auto && runtime.activeAgents === 0) {
142
+ try {
143
+ const idle = ctx.isIdle?.() ?? true;
144
+ const queued = ctx.hasPendingMessages?.() ?? false;
145
+ const now = Date.now();
146
+ if (idle && queued && now >= runtime.resumeNudgeUntil) {
147
+ runtime.resumeNudgeUntil = now + 30_000;
148
+ pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
149
+ }
150
+ } catch {
151
+ /* non-fatal: a failed nudge never blocks */
152
+ }
153
+ }
108
154
  runtime.snapshot(ctx);
109
155
  });
110
156
 
@@ -117,17 +163,44 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
117
163
  pi.on("turn_end", async (event, ctx) => {
118
164
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
119
165
  runtime.snapshot(ctx);
166
+
167
+ // S20: auto-review the conversation every N turns and persist durable
168
+ // memories. Best-effort + non-fatal: a review failure must never break the
169
+ // agent loop. Debounced by memoryReviewInterval turns.
170
+ if (config.memoryAutoReview && runtime.currentTurn > 0 && runtime.currentTurn % config.memoryReviewInterval === 0) {
171
+ try {
172
+ const { reviewConversation } = await import("../src/memory.js");
173
+ const { applyMemoryOps } = await import("../src/memoryOps.js");
174
+ const entries = ctx.sessionManager.getEntries();
175
+ const view = runtime.engineView(entries.flatMap((e: any) => (e.message ? [e.message] : [])));
176
+ const ops = reviewConversation(view, []);
177
+ if (ops.length) {
178
+ await applyMemoryOps(ops, runtime.currentStateDir);
179
+ // S21.2: a memory op landed in this turn window. The pipeline reads
180
+ // this counter after a successful compaction and fires
181
+ // `consolidateMemories` only when it's > 0.
182
+ runtime.memoriesTouchedThisCompaction += ops.length;
183
+ }
184
+ } catch {
185
+ /* non-fatal — auto-review must not break the turn loop */
186
+ }
187
+ }
120
188
  });
121
189
 
122
- // ---- Auto-trigger: own the decision, pi owns the durable write ----------
123
- // OUR auto-trigger (over threshold + debounce): persist our Trident checkpoint,
124
- // then start pi's compaction flow via ctx.compact(). That fires
125
- // `session_before_compact`, where OUR handler returns our summary +
126
- // firstKeptEntryId, and pi durably writes the trim to disk (appendCompaction).
127
- // Result: auto-compact AND a durable trim — resume reloads the trimmed window,
128
- // no full-reload + additive recall inflation (Fix B kills the token-growth bug).
129
- // We do NOT drop messages here (that would be ephemeral; the read-only session
130
- // manager can't trim disk, so the trim has to come through pi).
190
+ // ---- Auto-trigger: live trim (compact and continue) + native durable ----
191
+ // S16 redesign: we NO LONGER call ctx.compact() from the auto-trigger by
192
+ // default. That mapped to pi's MANUAL compaction path, which abort()s the
193
+ // in-flight turn (agent-session.js:1345) and stops the agent. Instead:
194
+ // - LIVE: return { messages: trimmedView } from the context event. This
195
+ // feeds pi's transformContext (sdk.js:226 agent-loop.js:180) so the
196
+ // model sees a compacted window EVERY LLM call, with no abort. The turn
197
+ // continues. We persist our recall checkpoint (the durable value) first.
198
+ // - DURABLE: pi's NATIVE auto-compaction fires at agent-end
199
+ // (agent-session.js:1565), continues (return hasQueuedMessages()), and
200
+ // emits session_before_compact — where OUR driveNativeCompaction supplies
201
+ // the summary and pi truncates the transcript on disk. No ctx.compact().
202
+ // Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
203
+ // path (kept one release as rollback).
131
204
  pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
132
205
  if (!config.auto) return;
133
206
  const usage = ctx.getContextUsage();
@@ -162,17 +235,55 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
162
235
  const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
163
236
  if (ran.skipped) return;
164
237
 
165
- // Skip pi's durable-trim flow when pi would no-op ("Nothing to compact /
166
- // Already compacted"). pi throws that error *before* session_before_compact
167
- // fires and renders it via a compaction_end event we can't mute (onError is
168
- // too late), so the only fix is to not call ctx.compact() on a no-op. Our
169
- // recall checkpoint above is already persisted; the durable trim is
170
- // unnecessary for a transcript pi would keep in full anyway.
171
- if (piCompactWouldNoop(ctx)) return;
172
-
173
- // Start pi's compaction flow so our session_before_compact handler can
174
- // supply the durable trim (pi writes it to disk). We never use pi's summary.
175
- ctx.compact({ customInstructions: undefined });
238
+ // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
239
+ // manual compact path aborts the in-flight turn — only used behind the flag.
240
+ // Read live from env (in addition to the load-time config) so the flag can be
241
+ // toggled per-test without reloading the module; config.legacyDurableTrim is
242
+ // the cached default. (Mirrors how piCompactWouldNoop re-reads its floor.)
243
+ const legacy = config.legacyDurableTrim || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
244
+ if (legacy) {
245
+ if (piCompactWouldNoop(ctx)) return;
246
+ ctx.compact({ customInstructions: undefined });
247
+ return;
248
+ }
249
+
250
+ // S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
251
+ // Non-destructive: pi keeps the real transcript; only this LLM call sees the
252
+ // trimmed window. We compute the cut on the engine view (pure, tested) then
253
+ // slice the ORIGINAL pi AgentMessage[] from that index (lossless alignment,
254
+ // mirroring dropCompactedRange) and prepend a user-role summary message.
255
+ // A build failure or unsafe cut returns nothing (no trim this call — the
256
+ // next context event retries). The anchor floor is read live from env (the
257
+ // config value is the cached default) so it can be tuned per-test / per-run
258
+ // without reloading the module.
259
+ try {
260
+ const anchorEnv = process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
261
+ const anchorUserMessages = (anchorEnv != null && anchorEnv !== "" && Number.isFinite(Number(anchorEnv)))
262
+ ? Number(anchorEnv)
263
+ : config.anchorUserMessages;
264
+ const cut = computeLiveTrimCut(view, {
265
+ compactedFrom: ran.result.compactedFrom,
266
+ summary: ran.result.summary,
267
+ anchorUserMessages,
268
+ });
269
+ if (cut === null) return; // unsafe / below anchor floor — no trim this call
270
+ const summaryMsg = liveTrimSummaryMessage({
271
+ compactedFrom: ran.result.compactedFrom,
272
+ summary: ran.result.summary,
273
+ anchorUserMessages: config.anchorUserMessages,
274
+ });
275
+ // Synthesize a user-role AgentMessage carrying the compacted summary.
276
+ const summaryAgentMsg = {
277
+ role: "user" as const,
278
+ content: summaryMsg.text,
279
+ timestamp: Date.now(),
280
+ } as unknown as AgentMessage;
281
+ 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.
282
+ runtime.snapshot(ctx);
283
+ return { messages: [summaryAgentMsg, ...recent] };
284
+ } catch {
285
+ return; // non-fatal: no trim this call; the next context event retries
286
+ }
176
287
  });
177
288
 
178
289
  // ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
@@ -12,10 +12,11 @@ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
13
13
  import { compactSession } from "../src/engine.js";
14
14
  import type { EngineMessage } from "../src/types.js";
15
- import { recallAndInline } from "../src/recall.js";
15
+ import { recallAndInline, recallAndInlineAsync, formatRecallBlock, type RecallInjectResult } from "../src/recall.js";
16
16
  import { normalizeSessionId } from "../src/store.js";
17
17
  import { estimateBlockTokens } from "../src/tokens.js";
18
18
  import { touchSession, logDaily } from "../src/store/sqlite.js";
19
+ import { consolidateMemories } from "../src/memory.js";
19
20
  import {
20
21
  MegaRuntime,
21
22
  C,
@@ -77,6 +78,10 @@ function doCompact(
77
78
  runtime: MegaRuntime,
78
79
  ): RunCompactResult {
79
80
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
81
+ // S21.2: reset the per-compaction memory-op counter so the post-compact
82
+ // consolidate pass only fires when memory rows actually changed during the
83
+ // compaction window (turn_end → auto-review may have written some).
84
+ runtime.memoriesTouchedThisCompaction = 0;
80
85
  const result = compactSession(
81
86
  {
82
87
  sessionId: sid,
@@ -148,6 +153,28 @@ function doCompact(
148
153
  /* non-fatal: stats bookkeeping only */
149
154
  }
150
155
 
156
+ // S21.2: best-effort consolidation of near-duplicate memories for this repo.
157
+ // Runs after the per-repo stats touch so `consolidateMemories` can use the
158
+ // same stateDir. Non-fatal — a failed consolidate never blocks a compaction.
159
+ // Only runs when new memory ops landed in this pass (otherwise the prior
160
+ // compaction's consolidate already had its shot — re-running would just
161
+ // touch every row again with no merges).
162
+ if (!result.deduped && runtime.memoriesTouchedThisCompaction > 0) {
163
+ try {
164
+ const root = resolveRepoRoot(ctx.cwd);
165
+ void consolidateMemories(runtime.currentStateDir, root).then(
166
+ (n) => {
167
+ if (n > 0) runtime.pushTicker(`${C.green}∫${C.reset} consolidated ${n} memory dup${n === 1 ? "" : "s"}`);
168
+ },
169
+ () => {
170
+ /* swallow: consolidate failures must never surface to the user */
171
+ },
172
+ );
173
+ } catch {
174
+ /* non-fatal */
175
+ }
176
+ }
177
+
151
178
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
152
179
  // skip re-vectorizing an already-compacted region (zero token cost).
153
180
  pi.appendEntry(MARKER_TYPE, {
@@ -363,6 +390,72 @@ export function doRecall(
363
390
  return result;
364
391
  }
365
392
 
393
+ /**
394
+ * S17: async recall with optional cross-repo augmentation. Used on resume
395
+ * (session_start) and /mega-recall --cross-repo — NEVER from the mid-turn
396
+ * context handler (that stays sync). Runs the sync same-repo scan first; if it
397
+ * returns < config.autoInlineK hits AND crossRepo is enabled, awaits the PGlite
398
+ * HNSW cross-repo path and merges (source-labeled, deduped by checkpointId). The
399
+ * recallMaxTokens cap + windowDedupe apply to the merged set so cross-repo can
400
+ * never net-inflate the window. Cross-repo uses a stricter cosine floor
401
+ * (config.crossRepoCosine) than same-repo. Non-fatal: any async failure returns
402
+ * the same-repo result unchanged.
403
+ */
404
+ export async function doRecallAsync(
405
+ runtime: MegaRuntime,
406
+ config: MegaConfig,
407
+ ctx: ExtensionContext,
408
+ query: string,
409
+ source: "resume" | "command",
410
+ opts: { crossRepo?: boolean } = {},
411
+ ): Promise<RecallInjectResult> {
412
+ runtime.bindRepo(ctx.cwd);
413
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
414
+ const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
415
+ // Sync same-repo first (fast, never blocks).
416
+ const sameRepo = recallAndInline(
417
+ {
418
+ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
419
+ recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
420
+ liveWindow, dedupSim: config.dedupSim,
421
+ },
422
+ runtime.store,
423
+ );
424
+ if (!config.crossRepoEnabled || !opts.crossRepo) return sameRepo;
425
+ if (sameRepo.toInject.length >= config.autoInlineK) return sameRepo; // same-repo satisfied
426
+ // Augment: cross-repo HNSW (async) with the stricter floor. Non-fatal.
427
+ try {
428
+ const x = await recallAndInlineAsync(
429
+ {
430
+ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
431
+ recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
432
+ liveWindow, dedupSim: config.crossRepoCosine, crossRepo: true,
433
+ globalIndexDir: process.env.MEGACOMPACT_INDEX_DIR,
434
+ },
435
+ runtime.store,
436
+ );
437
+ runtime.dashboard.event("recall-crossrepo", {
438
+ source, query: query.slice(0, 120), injected: x.toInject.length,
439
+ sourceRepos: x.toInject.map((h) => h.repoId).filter(Boolean),
440
+ });
441
+ // Merge, dedup by checkpointId, respect the same token cap by reformatting.
442
+ const seen = new Set(sameRepo.toInject.map((h) => h.checkpoint.checkpointId));
443
+ const merged = [...sameRepo.toInject];
444
+ for (const h of x.toInject) {
445
+ if (!seen.has(h.checkpoint.checkpointId)) { merged.push(h); seen.add(h.checkpoint.checkpointId); }
446
+ }
447
+ const block = merged.length ? formatRecallBlock(merged) : "";
448
+ return {
449
+ toInject: merged,
450
+ report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
451
+ block,
452
+ empty: merged.length === 0,
453
+ };
454
+ } catch {
455
+ return sameRepo; // cross-repo failure → same-repo only (non-fatal)
456
+ }
457
+ }
458
+
366
459
  /**
367
460
  * Extract the live-window message texts from the session manager (Fix C),
368
461
  * for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
@@ -79,12 +79,17 @@ export class MegaRuntime {
79
79
  tokensSaved: 0,
80
80
  };
81
81
  debounceUntil = 0;
82
+ // S16: debounce for the agent_end resume nudge (avoid busy-loops).
83
+ resumeNudgeUntil = 0;
82
84
  // Agent tracking for real-time widget updates
83
85
  activeAgents = 0;
84
86
  currentTurn = 0;
85
87
  // Recall block produced by auto-inline (resume/branch) that the next
86
88
  // before_agent_start should prepend to the system prompt. Unset after use.
87
89
  pendingRecallBlock: string | undefined;
90
+ // S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
91
+ // semantics; composed with the checkpoint block in before_agent_start.
92
+ pendingMemoryRecallBlock: string | undefined;
88
93
  statusKey: string | undefined; // current status text for dashboard
89
94
  // Active model/provider (for real cost estimation). Captured from ctx.model
90
95
  // on model_select + session_start; persisted to SQL so cost + the dashboard
@@ -103,6 +108,11 @@ export class MegaRuntime {
103
108
  readonly TICKER_MAX = 5;
104
109
  // Pulsing status: set true while a compaction is in flight, cleared on result.
105
110
  pulsing = false;
111
+ // S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
112
+ // the current compaction. The pipeline reads this after a successful compact
113
+ // to decide whether to fire `consolidateMemories` (skip the work entirely
114
+ // when no memory rows changed).
115
+ memoriesTouchedThisCompaction = 0;
106
116
  // Rolling "saved" goal for the progress bar — grows as we save more, so the
107
117
  // bar always has a meaningful denominator (never sits at 100% forever).
108
118
  savedGoal = 50_000;
@@ -364,6 +374,11 @@ export class MegaRuntime {
364
374
  } catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
365
375
  }
366
376
 
377
+ /** S21: state dir of the currently bound repo (where memories live). */
378
+ getStateDir(): string {
379
+ return this.currentStateDir;
380
+ }
381
+
367
382
  /** Build the sync onTier callback that paints the live per-tier trace. */
368
383
  makeTierCallback(ctx: ExtensionContext): (ev: { tier: "L0" | "L1" | "L2" | "new"; status: "scanning" | "deduped" | "passed" | "stored"; detail?: string }) => void {
369
384
  const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * mega-trim.test.ts — tests for the live compaction view builder (S16).
3
+ */
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { buildLiveTrimmedView } from "./mega-trim.js";
7
+ import type { EngineMessage } from "../src/types.js";
8
+
9
+ function m(role: EngineMessage["role"], text: string, extra: Partial<EngineMessage> = {}): EngineMessage {
10
+ return { role, text, toolName: undefined, input: undefined, output: undefined, ...extra };
11
+ }
12
+
13
+ test("buildLiveTrimmedView: prepends a compacted summary and keeps the recent anchor", () => {
14
+ const view: EngineMessage[] = [
15
+ m("user", "old request one"), m("assistant", "old answer one"),
16
+ m("user", "old request two"), m("assistant", "old answer two"),
17
+ m("user", "recent keep me"), m("assistant", "recent keep me too"),
18
+ ];
19
+ // Compacted region = first 4; recent anchor = last 2.
20
+ const result = buildLiveTrimmedView(view, {
21
+ compactedFrom: 4, // index where the compacted region ends
22
+ summary: "<summary>earlier work on old requests</summary>",
23
+ anchorUserMessages: 1,
24
+ });
25
+ // First element is the injected compacted summary as a user-role message.
26
+ assert.equal(result[0].role, "user");
27
+ assert.ok(String(result[0].text).includes("earlier work on old requests"));
28
+ // Recent anchor preserved in order, no older messages leak through.
29
+ assert.equal(result.length, 1 + 2, "summary + 2 recent");
30
+ assert.ok(result.slice(1).some((x) => String(x.text).includes("recent keep me")));
31
+ });
32
+
33
+ test("buildLiveTrimmedView: empty summary returns the original view unchanged", () => {
34
+ const view = [m("user", "x"), m("assistant", "y")];
35
+ const result = buildLiveTrimmedView(view, { compactedFrom: 0, summary: "", anchorUserMessages: 1 });
36
+ assert.deepEqual(result, view);
37
+ });
38
+
39
+ test("buildLiveTrimmedView: never splits a toolCall/toolResult pair (PREVENT-PI-002)", () => {
40
+ const view: EngineMessage[] = [
41
+ m("user", "q"), m("assistant", "calls tool", { toolName: "read" }), m("tool", "result"),
42
+ m("user", "keep"), m("assistant", "ok"),
43
+ ];
44
+ // cut=3 would start the preserved run on the orphaned tool result at index 2 —
45
+ // the builder must snap back so the toolCall/toolResult pair is not split.
46
+ const result = buildLiveTrimmedView(view, { compactedFrom: 3, summary: "<summary>s</summary>", anchorUserMessages: 1 });
47
+ // The tool result must never appear preserved WITHOUT its preceding toolCall.
48
+ const preserved = result.slice(1);
49
+ const hasToolResult = preserved.some((x) => x.role === "tool");
50
+ const hasToolCall = preserved.some((x) => x.role === "assistant" && x.toolName);
51
+ // Either the tool pair is kept together, or the tool result is dropped into
52
+ // the compacted region — it is never left orphaned.
53
+ assert.ok(!(hasToolResult && !hasToolCall), "no orphaned tool result in the preserved run");
54
+ });
55
+
56
+ test("buildLiveTrimmedView: honors the anchor floor (PREVENT-PI-001)", () => {
57
+ // cut would leave zero user messages in the anchor — must skip the trim.
58
+ const view: EngineMessage[] = [
59
+ m("user", "old q"), m("assistant", "old a"),
60
+ m("assistant", "only assistant kept"),
61
+ ];
62
+ const result = buildLiveTrimmedView(view, { compactedFrom: 2, summary: "<summary>s</summary>", anchorUserMessages: 1 });
63
+ assert.deepEqual(result, view, "below anchor floor → no trim this call");
64
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * mega-trim.ts — the LIVE compaction view builder (S16).
3
+ *
4
+ * Produces the message list returned from the `context` event so the model sees
5
+ * a compacted window every LLM call WITHOUT aborting the turn (ctx.compact()
6
+ * would abort; the context-event return feeds pi's transformContext per call).
7
+ *
8
+ * Shape: [compactSummaryMessage, ...recentAnchor]. The compacted region
9
+ * [0, compactedFrom) is collapsed to a single user-role summary; the recent
10
+ * anchor [compactedFrom, end) is kept verbatim. Honors PREVENT-PI-002 (never
11
+ * splits a toolCall/toolResult pair) by snapping compactedFrom back to a
12
+ * boundary-safe index, and PREVENT-PI-001 (anchor floor) via the anchor knob.
13
+ *
14
+ * Pure + pi-agnostic: takes EngineMessage[], returns EngineMessage[]. No pi
15
+ * imports. Non-destructive: the caller still owns the real messages.
16
+ */
17
+ import type { EngineMessage } from "../src/types.js";
18
+ import { isBoundarySafe } from "../src/boundary.js";
19
+ import { formatCompactSummary } from "../src/compact.js";
20
+
21
+ export interface BuildLiveTrimViewOpts {
22
+ /** Index where the compacted region ends (the recent anchor starts here). */
23
+ compactedFrom: number;
24
+ /** The compacted-region summary text (already generated by runCompact). */
25
+ summary: string;
26
+ /** Min recent user messages to keep as the anchor (PREVENT-PI-001). */
27
+ anchorUserMessages: number;
28
+ }
29
+
30
+ /**
31
+ * Compute the safe cut index for the live trim. Snaps `compactedFrom` back to a
32
+ * boundary-safe index (PREVENT-PI-002: never start the preserved run on an
33
+ * orphaned tool result), and enforces the anchor floor (PREVENT-PI-001: keep at
34
+ * least `anchorUserMessages` user-role messages). Returns `null` when no trim is
35
+ * safe this call (empty summary, unsafe boundary, or below the anchor floor) so
36
+ * the caller keeps the original view and retries on the next context event.
37
+ *
38
+ * Exposed separately from `buildLiveTrimmedView` so the context handler can map
39
+ * the cut back onto the original pi `AgentMessage[]` (lossless index alignment,
40
+ * mirroring `dropCompactedRange` in src/adapt.ts).
41
+ */
42
+ export function computeLiveTrimCut(view: EngineMessage[], opts: BuildLiveTrimViewOpts): number | null {
43
+ if (!opts.summary || !opts.summary.trim()) return null;
44
+ let cut = opts.compactedFrom;
45
+ while (cut > 0 && !isBoundarySafe(view, cut)) cut--;
46
+ if (cut <= 0) return null; // nothing safe to cut — keep everything this call
47
+ const recent = view.slice(cut);
48
+ const userCount = recent.filter((m) => m.role === "user").length;
49
+ if (userCount < opts.anchorUserMessages) return null;
50
+ return cut;
51
+ }
52
+
53
+ /** The formatted compacted-region summary as a user-role engine message. */
54
+ export function liveTrimSummaryMessage(opts: BuildLiveTrimViewOpts): EngineMessage {
55
+ return {
56
+ role: "user",
57
+ text: formatCompactSummary(opts.summary),
58
+ toolName: undefined,
59
+ input: undefined,
60
+ output: undefined,
61
+ };
62
+ }
63
+
64
+ /** Build the live trimmed view. Returns the original view if summary is empty
65
+ * or the boundary is unsafe (no trim this call — try next). Pure + tested. */
66
+ export function buildLiveTrimmedView(
67
+ view: EngineMessage[],
68
+ opts: BuildLiveTrimViewOpts,
69
+ ): EngineMessage[] {
70
+ const cut = computeLiveTrimCut(view, opts);
71
+ if (cut === null) return view;
72
+ const recent = view.slice(cut);
73
+ const summaryMsg = liveTrimSummaryMessage(opts);
74
+ return [summaryMsg, ...recent];
75
+ }
@@ -21,7 +21,7 @@ import {
21
21
  type CompactInput,
22
22
  type CompactResult,
23
23
  } from "../src/engine.js";
24
- import { recallAndInline, type RecallInjectResult } from "../src/recall.js";
24
+ import { recallAndInline, recallMemoriesAndInline, type RecallInjectResult } from "../src/recall.js";
25
25
  import { VectorStore } from "../src/vectorStore.js";
26
26
  import type { EngineMessage } from "../src/types.js";
27
27
 
@@ -333,19 +333,34 @@ export default definePluginEntry({
333
333
  store,
334
334
  );
335
335
 
336
- if (result.toInject.length === 0) {
336
+ // S21: parallel memory recall for the slash command. Same query so the
337
+ // output combines checkpoint + memory context the user actually needs.
338
+ let memBlock = "";
339
+ let memReport: string[] = [];
340
+ try {
341
+ const mr = await recallMemoriesAndInline({ query, stateDir, limit: 5 });
342
+ if (!mr.empty) {
343
+ memBlock = mr.block;
344
+ memReport = mr.report;
345
+ }
346
+ } catch {
347
+ // best-effort — never break the command over memory recall
348
+ }
349
+
350
+ if (result.toInject.length === 0 && !memBlock) {
337
351
  return {
338
352
  content: [{ type: "text", text: "No relevant context found in the mega-compact store." }],
339
353
  };
340
354
  }
341
355
 
342
- const parts: string[] = [
343
- `**Recalled ${result.toInject.length} checkpoint(s):**`,
344
- ...result.report,
345
- "",
346
- "---",
347
- result.block,
348
- ];
356
+ const parts: string[] = [];
357
+ if (result.toInject.length) {
358
+ parts.push(`**Recalled ${result.toInject.length} checkpoint(s):**`, ...result.report, "");
359
+ }
360
+ if (memBlock) {
361
+ parts.push(`**Recalled ${memReport.length} memory record(s):**`, ...memReport, "");
362
+ }
363
+ parts.push("---", result.block, memBlock);
349
364
 
350
365
  return { content: [{ type: "text", text: parts.join("\n") }] };
351
366
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.28",
3
+ "version": "0.5.0",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
@@ -42,7 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.json",
44
44
  "lint": "tsc --noEmit && node scripts/guardrails-scan.mjs",
45
- "test": "npm run build && node --test --test-timeout=180000 \"dist/src/**/*.test.js\" \"dist/extensions/**/*.test.js\"",
45
+ "test": "npm run build && node scripts/run-tests.mjs",
46
46
  "guardrails": "python3 scripts/regression_check.py --all || node scripts/guardrails-scan.mjs",
47
47
  "precommit": "bash .claude/hooks/pre-commit.sh",
48
48
  "prepublishOnly": "npm run build",
@@ -46,6 +46,7 @@ export interface DedupConfigShape {
46
46
  DEDUP_SIM: number; // legacy content-similarity fallback
47
47
  MMR_LAMBDA: number; // retrieval diversity
48
48
  SEMDEDUP_COSINE: number; // offline SemDeDup pair threshold
49
+ CONSOLIDATE_COSINE: number; // memory consolidation merge threshold (Sprint 21)
49
50
  // Caps / budgets.
50
51
  SIMILARITY_BUDGET_MS: number;
51
52
  L1_VERIFY_BUDGET_MS: number;
@@ -79,6 +80,7 @@ export function loadDedupConfig(): DedupConfigShape {
79
80
  DEDUP_SIM: envNum("MEGACOMPACT_DEDUP_SIM", 0.9),
80
81
  MMR_LAMBDA: envNum("MEGACOMPACT_MMR_LAMBDA", 0.5),
81
82
  SEMDEDUP_COSINE: envNum("MEGACOMPACT_SEMDEDUP_COSINE", 0.95),
83
+ CONSOLIDATE_COSINE: envNum("MEGACOMPACT_CONSOLIDATE_COSINE", 0.7),
82
84
  SIMILARITY_BUDGET_MS: envNum("MEGACOMPACT_SIMILARITY_BUDGET_MS", 50),
83
85
  L1_VERIFY_BUDGET_MS: envNum("MEGACOMPACT_L1_VERIFY_BUDGET_MS", 20),
84
86
  L1_CANDIDATE_CAP: envNum("MEGACOMPACT_L1_CANDIDATE_CAP", 100),