pi-mega-compact 0.8.4 → 0.8.6

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.
@@ -42,4 +42,13 @@ export default function (pi) {
42
42
  registerConflictCommands(pi, runtime);
43
43
  registerDbCommands(pi, runtime);
44
44
  registerGameCommands(pi, runtime);
45
+ // v0.8.5 (audit P3): release the fs.watch game-state watcher handle on
46
+ // session teardown so it doesn't linger across reloads. pi exposes no
47
+ // extension-unload event (the factory return value is ignored and there is no
48
+ // "shutdown" event on the ExtensionAPI), so dispose() is wired to the
49
+ // session_shutdown lifecycle event — the closest valid teardown signal.
50
+ // dispose() is idempotent, and the next snapshot() re-opens the watcher
51
+ // lazily via bindRepo() → ensureGameStateWatcher(), so there is no permanent
52
+ // leak and no per-session fd accumulation.
53
+ pi.on("session_shutdown", () => runtime.dispose());
45
54
  }
@@ -128,6 +128,7 @@ export function registerCompactHandlers(pi, runtime, config) {
128
128
  pi.on("session_compact", async (_event, _ctx) => {
129
129
  runtime.rt.lastNativeCompactAt = Date.now();
130
130
  runtime.rt.lastCompactAt = Date.now();
131
+ runtime.trimCache = null; // v0.8.6: durable truncation changes the transcript — never replay the stale cached cut (PREVENT-PI-001/002)
131
132
  runtime.logger.info("session-compacted", {
132
133
  sessionId: runtime.rt.sessionId,
133
134
  at: runtime.rt.lastCompactAt,
@@ -122,6 +122,34 @@ export function registerContextHandler(pi, runtime, config) {
122
122
  return;
123
123
  }
124
124
  runtime.debounceUntil = now + 2000;
125
+ // v0.8.6 cache-stability: replay the cached trim view when still in the
126
+ // same compaction epoch AND context hasn't grown enough to warrant a
127
+ // re-compact. This stabilizes the provider KV-cache prefix (the summary +
128
+ // cut are reused verbatim) instead of regenerating a fresh summary +
129
+ // sentinel every fire, which invalidated the prefix on every other turn
130
+ // (the alternating cache-miss regression). Re-compact only when context
131
+ // grew >=10% of the window (percent basis) or >=50% of the effective
132
+ // threshold (token basis, when percent is unavailable). The cached `cut`
133
+ // is only valid while the transcript grows within the epoch — it is
134
+ // cleared on session_compact (durable truncation) + resetRuntime, so we
135
+ // never replay a stale cut into a truncated transcript (PREVENT-PI-001/002).
136
+ const RECOMPACT_PCT_DELTA = 10;
137
+ if (runtime.trimCache &&
138
+ runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
139
+ runtime.trimCache.cut <= messages.length) {
140
+ const grewEnough = pct != null && runtime.trimCache.ctxPct != null
141
+ ? pct - runtime.trimCache.ctxPct >= RECOMPACT_PCT_DELTA
142
+ : currentTokens - (runtime.trimCache.ctxTokens ?? 0) >=
143
+ runtime.effectiveThreshold * 0.5;
144
+ if (!grewEnough) {
145
+ const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized once by computeLiveTrimCut (src/boundary.ts) and replayed verbatim; the transcript only grows within an epoch (cache is cleared on durable truncation), so the preserved run still starts on a toolPair-safe index.
146
+ runtime.diagLiveTrimFires++; // trim view returned this call (replay counts as a fire)
147
+ runtime.diagLiveTrimReplays++;
148
+ runtime.snapshot(ctx);
149
+ return { messages: [runtime.trimCache.summaryAgentMsg, ...recent] };
150
+ }
151
+ // else: context grew enough → fall through to re-compact (cache is stale)
152
+ }
125
153
  // Adaptive compression (Fix E): scale compression strength + keepFrom depth
126
154
  // with how close we are to the model context limit. Null-safe: when the
127
155
  // token-fallback path ran (pct unavailable) use the token-basis pressure
@@ -224,9 +252,22 @@ export function registerContextHandler(pi, runtime, config) {
224
252
  const summaryAgentMsg = {
225
253
  role: "user",
226
254
  content: summaryMsg.text,
227
- timestamp: Date.now(),
255
+ // v0.8.6: stable timestamp across the epoch (NOT Date.now()) so the
256
+ // summary message bytes — and thus the KV-cache prefix — don't drift
257
+ // on every replay within the same compaction epoch.
258
+ timestamp: runtime.rt.lastCompactAt ?? Date.now(),
228
259
  };
229
260
  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.
261
+ // v0.8.6: cache the trim view so subsequent gated calls in this epoch
262
+ // replay it verbatim (stabilizing the KV-cache prefix) instead of
263
+ // regenerating a fresh summary + sentinel every fire.
264
+ runtime.trimCache = {
265
+ checkpointId: ran.result.checkpointId ?? `epoch-${runtime.rt.lastCompactAt ?? Date.now()}`,
266
+ cut,
267
+ summaryAgentMsg,
268
+ ctxPct: pct ?? null,
269
+ ctxTokens: currentTokens,
270
+ };
230
271
  runtime.snapshot(ctx);
231
272
  // DIAG (team-run relief): confirm the live trim actually fires + how big
232
273
  // the window still is. The return is non-durable (per-LLM-call only), so
@@ -152,12 +152,19 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
152
152
  }
153
153
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
154
154
  // skip re-vectorizing an already-compacted region (zero token cost).
155
- pi.appendEntry(MARKER_TYPE, {
156
- checkpointId: result.checkpointId,
157
- regionHash: result.regionHash,
158
- tokenEstimate: result.tokenEstimate,
159
- deduped: result.deduped,
160
- });
155
+ // v0.8.6: gate on !result.deduped so the marker ONLY lands when a genuinely
156
+ // new checkpoint was created. Without this, every dedup re-fire appended a
157
+ // fresh sentinel to the real transcript, bloating it and perturbing the
158
+ // provider KV-cache prefix (the alternating cache-miss regression). Matches
159
+ // the RAPTOR + vector-index blocks above, which are already !deduped-gated.
160
+ if (!result.deduped) {
161
+ pi.appendEntry(MARKER_TYPE, {
162
+ checkpointId: result.checkpointId,
163
+ regionHash: result.regionHash,
164
+ tokenEstimate: result.tokenEstimate,
165
+ deduped: result.deduped,
166
+ });
167
+ }
161
168
  // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
162
169
  // serve high-level summaries. Best-effort + non-fatal: never block compaction.
163
170
  // Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
@@ -50,6 +50,14 @@ export class MegaRuntime {
50
50
  cacheHitTokens: 0,
51
51
  lengthStopPending: false,
52
52
  };
53
+ // v0.8.6 cache-stability: the cached live-trim view for the current
54
+ // compaction epoch. Set after a fresh runCompact + computeLiveTrimCut, and
55
+ // replayed verbatim on subsequent gated context events in the SAME epoch
56
+ // (same checkpointId) so the provider KV-cache prefix stays stable instead
57
+ // of being invalidated by a freshly regenerated summary + sentinel every
58
+ // fire. Invalidated on session restart (resetRuntime) and on any native
59
+ // durable compaction (session_compact) that truncates the transcript.
60
+ trimCache = null;
53
61
  debounceUntil = 0;
54
62
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
55
63
  resumeNudgeUntil = 0;
@@ -119,6 +127,17 @@ export class MegaRuntime {
119
127
  lastCtxWindow = 0;
120
128
  // Latest computed widget payload (recomputed per snapshot, rendered per frame).
121
129
  widgetData = null;
130
+ // v0.8.5: material-change signature from the last full snapshot() body. When
131
+ // the next snapshot()'s signature matches, the expensive recompute (6 sync
132
+ // SQLite opens) + writeFileSync(dashboard.json) are skipped — only the
133
+ // (already-registered) widget factory is refreshed. Kills the per-event
134
+ // main-thread block during typing/idle streaming with no material change.
135
+ lastSnapshotSig = null;
136
+ // v0.8.5: bumped whenever the cached game-state memo is evicted (bumpGameState
137
+ // for in-process /mega-game writes, the fs.watch callback for cross-process
138
+ // dashboard-server writes, and bindRepo on repo switch) so the snapshot gate
139
+ // invalidates and the widget re-reads theme/mode after the change.
140
+ gameStateBump = 0;
122
141
  // Cached cross-repo drift status (recomputed at most every 30s — it opens the
123
142
  // machine-wide registry DB, so we don't want to do it on every render frame).
124
143
  driftCache = null;
@@ -148,6 +167,7 @@ export class MegaRuntime {
148
167
  * updated and cost nothing).
149
168
  */
150
169
  diagLiveTrimFires = 0; // context handler returned a trimmed view
170
+ diagLiveTrimReplays = 0; // v0.8.6: trim view returned via cached replay (skipped re-compact)
151
171
  diagBeforeCompactFires = 0; // session_before_compact handler entered
152
172
  diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
153
173
  diagAgentEndIdle = 0; // agent_end with activeAgents===0
@@ -253,6 +273,7 @@ export class MegaRuntime {
253
273
  // stateDir), so evict the memo on every repo switch; the next widget render
254
274
  // re-queries lazily via getCachedGameState().
255
275
  this.cachedGameState = undefined;
276
+ this.gameStateBump++;
256
277
  // S32: re-target the fs.watch cache-eviction watcher at the NEW stateDir's
257
278
  // sqlite.db so cross-process writes (dashboard server) still evict the memo.
258
279
  this.ensureGameStateWatcher();
@@ -293,6 +314,21 @@ export class MegaRuntime {
293
314
  snapshot(ctx) {
294
315
  if (ctx)
295
316
  this.bindRepo(ctx.cwd);
317
+ // v0.8.5: gate the expensive body (6 sync SQLite opens +
318
+ // writeFileSync(dashboard.json)) behind a cheap material-change signature.
319
+ // During typing / idle / no-compaction streaming, the 'context' event
320
+ // fires repeatedly with NO material change — skip the recompute + write and
321
+ // just re-register the (live) widget factory, which reads the cached
322
+ // widgetData every frame. This removes the per-event main-thread block
323
+ // WITHOUT changing write timing, so tests that read dashboard.json
324
+ // synchronously after a compaction still see it written (compaction changes
325
+ // compactCount/tokensSaved → the signature changes → the full recompute +
326
+ // write runs).
327
+ const sig = this.materialSig();
328
+ if (ctx && this.widgetData && this.lastSnapshotSig === sig) {
329
+ this.renderWidget(ctx);
330
+ return;
331
+ }
296
332
  const st = this.store.stats(this.rt.sessionId);
297
333
  const repo = this.store.repoStats();
298
334
  const di = this.store.dataInvariant();
@@ -596,6 +632,9 @@ export class MegaRuntime {
596
632
  // process.stdout.columns. buildWidgetLines reads this.widgetData live.
597
633
  this.renderWidget(ctx);
598
634
  }
635
+ // v0.8.5: record the material-change signature computed at the top so the
636
+ // next snapshot() can skip this whole body when nothing material changed.
637
+ this.lastSnapshotSig = sig;
599
638
  }
600
639
  /** Register the above-editor widget as a width-aware factory so pi re-renders
601
640
  * it at the REAL terminal width every frame (auto-fit wide/narrow). The
@@ -607,6 +646,34 @@ export class MegaRuntime {
607
646
  invalidate: () => { },
608
647
  }), { placement: "aboveEditor" });
609
648
  }
649
+ /** v0.8.5: cheap material-change signature over live runtime fields (no
650
+ * SQLite). Two snapshots with the same signature produce identical
651
+ * dashboard.json + widgetData, so the 6 synchronous SQLite opens + the
652
+ * writeFileSync(dashboard.json) can be skipped. Built from in-memory state
653
+ * only; gameStateBump covers cross-process game_state edits (fs.watch) +
654
+ * in-process /mega-game writes (bumpGameState) + repo switches (bindRepo).
655
+ * The transient flare flags are included so a one-shot flare forces the
656
+ * recompute that renders (then clears) it for exactly one cycle. */
657
+ materialSig() {
658
+ const rt = this.rt;
659
+ const ae = this.activeEffect;
660
+ return JSON.stringify([
661
+ this.lastCtxTokens, this.lastCtxPercent, this.lastCtxWindow,
662
+ this.activeAgents, this.currentTurn,
663
+ rt.compactCount, rt.tokensSaved, rt.dedupSkips, rt.dedupAttempts,
664
+ rt.recallInjections, rt.cacheHitTokens, rt.persistedThisSession,
665
+ rt.lastCheckpointId ?? null, rt.lastCompactedFrom, rt.lastCompactedTokens,
666
+ this.statusKey ?? null,
667
+ this.currentModel?.modelId ?? null, this.currentModel?.provider ?? null,
668
+ ae ? `${ae.type}:${ae.role}:${ae.startedAt}` : null,
669
+ this.gameStateBump,
670
+ this.megaCacheFlare, this.megaCacheFlarePct,
671
+ this.levelUpFlare, this.achievementFlare,
672
+ this.achievementFlareTitles.join("|"),
673
+ this.tierTrace ?? null, this.lastWhy ?? null, this.pulsing,
674
+ this.ticker.length,
675
+ ]);
676
+ }
610
677
  /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
611
678
  embedderName() {
612
679
  // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
@@ -656,6 +723,7 @@ export class MegaRuntime {
656
723
  cacheHitTokens: 0,
657
724
  lengthStopPending: false,
658
725
  };
726
+ this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
659
727
  this.statusKey = undefined;
660
728
  this.activeAgents = 0;
661
729
  this.currentTurn = 0;
@@ -803,6 +871,7 @@ export class MegaRuntime {
803
871
  this.gameStateWatcher = watch(this.currentStateDir, (_eventType, filename) => {
804
872
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
805
873
  this.cachedGameState = undefined;
874
+ this.gameStateBump++;
806
875
  }
807
876
  });
808
877
  this.gameStateWatchDir = this.currentStateDir;
@@ -849,6 +918,7 @@ export class MegaRuntime {
849
918
  * the panel picks up theme/mode/toggle changes live. */
850
919
  bumpGameState() {
851
920
  this.cachedGameState = undefined;
921
+ this.gameStateBump++;
852
922
  }
853
923
  /** S33: player level for game mode — floor(log2(turns+1))+1 (gentle).
854
924
  * Defensive: non-finite/negative collapses to 1 (never NaN). */
@@ -45,4 +45,13 @@ export default function (pi: ExtensionAPI) {
45
45
  registerConflictCommands(pi, runtime);
46
46
  registerDbCommands(pi, runtime);
47
47
  registerGameCommands(pi, runtime);
48
+ // v0.8.5 (audit P3): release the fs.watch game-state watcher handle on
49
+ // session teardown so it doesn't linger across reloads. pi exposes no
50
+ // extension-unload event (the factory return value is ignored and there is no
51
+ // "shutdown" event on the ExtensionAPI), so dispose() is wired to the
52
+ // session_shutdown lifecycle event — the closest valid teardown signal.
53
+ // dispose() is idempotent, and the next snapshot() re-opens the watcher
54
+ // lazily via bindRepo() → ensureGameStateWatcher(), so there is no permanent
55
+ // leak and no per-session fd accumulation.
56
+ pi.on("session_shutdown", () => runtime.dispose());
48
57
  }
@@ -159,6 +159,7 @@ export function registerCompactHandlers(
159
159
  pi.on("session_compact", async (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
160
160
  runtime.rt.lastNativeCompactAt = Date.now();
161
161
  runtime.rt.lastCompactAt = Date.now();
162
+ runtime.trimCache = null; // v0.8.6: durable truncation changes the transcript — never replay the stale cached cut (PREVENT-PI-001/002)
162
163
  runtime.logger.info("session-compacted", {
163
164
  sessionId: runtime.rt.sessionId,
164
165
  at: runtime.rt.lastCompactAt,
@@ -157,6 +157,38 @@ export function registerContextHandler(
157
157
  }
158
158
  runtime.debounceUntil = now + 2000;
159
159
 
160
+ // v0.8.6 cache-stability: replay the cached trim view when still in the
161
+ // same compaction epoch AND context hasn't grown enough to warrant a
162
+ // re-compact. This stabilizes the provider KV-cache prefix (the summary +
163
+ // cut are reused verbatim) instead of regenerating a fresh summary +
164
+ // sentinel every fire, which invalidated the prefix on every other turn
165
+ // (the alternating cache-miss regression). Re-compact only when context
166
+ // grew >=10% of the window (percent basis) or >=50% of the effective
167
+ // threshold (token basis, when percent is unavailable). The cached `cut`
168
+ // is only valid while the transcript grows within the epoch — it is
169
+ // cleared on session_compact (durable truncation) + resetRuntime, so we
170
+ // never replay a stale cut into a truncated transcript (PREVENT-PI-001/002).
171
+ const RECOMPACT_PCT_DELTA = 10;
172
+ if (
173
+ runtime.trimCache &&
174
+ runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
175
+ runtime.trimCache.cut <= messages.length
176
+ ) {
177
+ const grewEnough =
178
+ pct != null && runtime.trimCache.ctxPct != null
179
+ ? pct - runtime.trimCache.ctxPct >= RECOMPACT_PCT_DELTA
180
+ : currentTokens - (runtime.trimCache.ctxTokens ?? 0) >=
181
+ runtime.effectiveThreshold * 0.5;
182
+ if (!grewEnough) {
183
+ const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized once by computeLiveTrimCut (src/boundary.ts) and replayed verbatim; the transcript only grows within an epoch (cache is cleared on durable truncation), so the preserved run still starts on a toolPair-safe index.
184
+ runtime.diagLiveTrimFires++; // trim view returned this call (replay counts as a fire)
185
+ runtime.diagLiveTrimReplays++;
186
+ runtime.snapshot(ctx);
187
+ return { messages: [runtime.trimCache.summaryAgentMsg, ...recent] };
188
+ }
189
+ // else: context grew enough → fall through to re-compact (cache is stale)
190
+ }
191
+
160
192
  // Adaptive compression (Fix E): scale compression strength + keepFrom depth
161
193
  // with how close we are to the model context limit. Null-safe: when the
162
194
  // token-fallback path ran (pct unavailable) use the token-basis pressure
@@ -266,9 +298,22 @@ export function registerContextHandler(
266
298
  const summaryAgentMsg = {
267
299
  role: "user" as const,
268
300
  content: summaryMsg.text,
269
- timestamp: Date.now(),
301
+ // v0.8.6: stable timestamp across the epoch (NOT Date.now()) so the
302
+ // summary message bytes — and thus the KV-cache prefix — don't drift
303
+ // on every replay within the same compaction epoch.
304
+ timestamp: runtime.rt.lastCompactAt ?? Date.now(),
270
305
  } as unknown as AgentMessage;
271
306
  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.
307
+ // v0.8.6: cache the trim view so subsequent gated calls in this epoch
308
+ // replay it verbatim (stabilizing the KV-cache prefix) instead of
309
+ // regenerating a fresh summary + sentinel every fire.
310
+ runtime.trimCache = {
311
+ checkpointId: ran.result.checkpointId ?? `epoch-${runtime.rt.lastCompactAt ?? Date.now()}`,
312
+ cut,
313
+ summaryAgentMsg,
314
+ ctxPct: pct ?? null,
315
+ ctxTokens: currentTokens,
316
+ };
272
317
  runtime.snapshot(ctx);
273
318
  // DIAG (team-run relief): confirm the live trim actually fires + how big
274
319
  // the window still is. The return is non-durable (per-LLM-call only), so
@@ -192,12 +192,19 @@ function doCompact(
192
192
 
193
193
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
194
194
  // skip re-vectorizing an already-compacted region (zero token cost).
195
- pi.appendEntry(MARKER_TYPE, {
196
- checkpointId: result.checkpointId,
197
- regionHash: result.regionHash,
198
- tokenEstimate: result.tokenEstimate,
199
- deduped: result.deduped,
200
- });
195
+ // v0.8.6: gate on !result.deduped so the marker ONLY lands when a genuinely
196
+ // new checkpoint was created. Without this, every dedup re-fire appended a
197
+ // fresh sentinel to the real transcript, bloating it and perturbing the
198
+ // provider KV-cache prefix (the alternating cache-miss regression). Matches
199
+ // the RAPTOR + vector-index blocks above, which are already !deduped-gated.
200
+ if (!result.deduped) {
201
+ pi.appendEntry(MARKER_TYPE, {
202
+ checkpointId: result.checkpointId,
203
+ regionHash: result.regionHash,
204
+ tokenEstimate: result.tokenEstimate,
205
+ deduped: result.deduped,
206
+ });
207
+ }
201
208
 
202
209
  // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
203
210
  // serve high-level summaries. Best-effort + non-fatal: never block compaction.
@@ -87,6 +87,20 @@ export class MegaRuntime {
87
87
  cacheHitTokens: 0,
88
88
  lengthStopPending: false,
89
89
  };
90
+ // v0.8.6 cache-stability: the cached live-trim view for the current
91
+ // compaction epoch. Set after a fresh runCompact + computeLiveTrimCut, and
92
+ // replayed verbatim on subsequent gated context events in the SAME epoch
93
+ // (same checkpointId) so the provider KV-cache prefix stays stable instead
94
+ // of being invalidated by a freshly regenerated summary + sentinel every
95
+ // fire. Invalidated on session restart (resetRuntime) and on any native
96
+ // durable compaction (session_compact) that truncates the transcript.
97
+ trimCache: {
98
+ checkpointId: string;
99
+ cut: number;
100
+ summaryAgentMsg: AgentMessage;
101
+ ctxPct: number | null;
102
+ ctxTokens: number | null;
103
+ } | null = null;
90
104
  debounceUntil = 0;
91
105
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
92
106
  resumeNudgeUntil = 0;
@@ -158,6 +172,17 @@ export class MegaRuntime {
158
172
 
159
173
  // Latest computed widget payload (recomputed per snapshot, rendered per frame).
160
174
  widgetData: WidgetData | null = null;
175
+ // v0.8.5: material-change signature from the last full snapshot() body. When
176
+ // the next snapshot()'s signature matches, the expensive recompute (6 sync
177
+ // SQLite opens) + writeFileSync(dashboard.json) are skipped — only the
178
+ // (already-registered) widget factory is refreshed. Kills the per-event
179
+ // main-thread block during typing/idle streaming with no material change.
180
+ private lastSnapshotSig: string | null = null;
181
+ // v0.8.5: bumped whenever the cached game-state memo is evicted (bumpGameState
182
+ // for in-process /mega-game writes, the fs.watch callback for cross-process
183
+ // dashboard-server writes, and bindRepo on repo switch) so the snapshot gate
184
+ // invalidates and the widget re-reads theme/mode after the change.
185
+ private gameStateBump = 0;
161
186
  // Cached cross-repo drift status (recomputed at most every 30s — it opens the
162
187
  // machine-wide registry DB, so we don't want to do it on every render frame).
163
188
  private driftCache: { at: number; status: "ok" | "warn" } | null = null;
@@ -188,6 +213,7 @@ export class MegaRuntime {
188
213
  * updated and cost nothing).
189
214
  */
190
215
  diagLiveTrimFires = 0; // context handler returned a trimmed view
216
+ diagLiveTrimReplays = 0; // v0.8.6: trim view returned via cached replay (skipped re-compact)
191
217
  diagBeforeCompactFires = 0; // session_before_compact handler entered
192
218
  diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
193
219
  diagAgentEndIdle = 0; // agent_end with activeAgents===0
@@ -303,6 +329,7 @@ export class MegaRuntime {
303
329
  // stateDir), so evict the memo on every repo switch; the next widget render
304
330
  // re-queries lazily via getCachedGameState().
305
331
  this.cachedGameState = undefined;
332
+ this.gameStateBump++;
306
333
  // S32: re-target the fs.watch cache-eviction watcher at the NEW stateDir's
307
334
  // sqlite.db so cross-process writes (dashboard server) still evict the memo.
308
335
  this.ensureGameStateWatcher();
@@ -343,6 +370,21 @@ export class MegaRuntime {
343
370
  /** Collect live state and write it to disk (+ paint the above-editor widget). */
344
371
  snapshot(ctx?: ExtensionContext): void {
345
372
  if (ctx) this.bindRepo(ctx.cwd);
373
+ // v0.8.5: gate the expensive body (6 sync SQLite opens +
374
+ // writeFileSync(dashboard.json)) behind a cheap material-change signature.
375
+ // During typing / idle / no-compaction streaming, the 'context' event
376
+ // fires repeatedly with NO material change — skip the recompute + write and
377
+ // just re-register the (live) widget factory, which reads the cached
378
+ // widgetData every frame. This removes the per-event main-thread block
379
+ // WITHOUT changing write timing, so tests that read dashboard.json
380
+ // synchronously after a compaction still see it written (compaction changes
381
+ // compactCount/tokensSaved → the signature changes → the full recompute +
382
+ // write runs).
383
+ const sig = this.materialSig();
384
+ if (ctx && this.widgetData && this.lastSnapshotSig === sig) {
385
+ this.renderWidget(ctx);
386
+ return;
387
+ }
346
388
  const st = this.store.stats(this.rt.sessionId);
347
389
  const repo = this.store.repoStats();
348
390
  const di = this.store.dataInvariant();
@@ -664,6 +706,9 @@ export class MegaRuntime {
664
706
  // process.stdout.columns. buildWidgetLines reads this.widgetData live.
665
707
  this.renderWidget(ctx);
666
708
  }
709
+ // v0.8.5: record the material-change signature computed at the top so the
710
+ // next snapshot() can skip this whole body when nothing material changed.
711
+ this.lastSnapshotSig = sig;
667
712
  }
668
713
 
669
714
  /** Register the above-editor widget as a width-aware factory so pi re-renders
@@ -686,6 +731,35 @@ export class MegaRuntime {
686
731
  );
687
732
  }
688
733
 
734
+ /** v0.8.5: cheap material-change signature over live runtime fields (no
735
+ * SQLite). Two snapshots with the same signature produce identical
736
+ * dashboard.json + widgetData, so the 6 synchronous SQLite opens + the
737
+ * writeFileSync(dashboard.json) can be skipped. Built from in-memory state
738
+ * only; gameStateBump covers cross-process game_state edits (fs.watch) +
739
+ * in-process /mega-game writes (bumpGameState) + repo switches (bindRepo).
740
+ * The transient flare flags are included so a one-shot flare forces the
741
+ * recompute that renders (then clears) it for exactly one cycle. */
742
+ private materialSig(): string {
743
+ const rt = this.rt;
744
+ const ae = this.activeEffect;
745
+ return JSON.stringify([
746
+ this.lastCtxTokens, this.lastCtxPercent, this.lastCtxWindow,
747
+ this.activeAgents, this.currentTurn,
748
+ rt.compactCount, rt.tokensSaved, rt.dedupSkips, rt.dedupAttempts,
749
+ rt.recallInjections, rt.cacheHitTokens, rt.persistedThisSession,
750
+ rt.lastCheckpointId ?? null, rt.lastCompactedFrom, rt.lastCompactedTokens,
751
+ this.statusKey ?? null,
752
+ this.currentModel?.modelId ?? null, this.currentModel?.provider ?? null,
753
+ ae ? `${ae.type}:${ae.role}:${ae.startedAt}` : null,
754
+ this.gameStateBump,
755
+ this.megaCacheFlare, this.megaCacheFlarePct,
756
+ this.levelUpFlare, this.achievementFlare,
757
+ this.achievementFlareTitles.join("|"),
758
+ this.tierTrace ?? null, this.lastWhy ?? null, this.pulsing,
759
+ this.ticker.length,
760
+ ]);
761
+ }
762
+
689
763
  /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
690
764
  private embedderName(): string {
691
765
  // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
@@ -736,6 +810,7 @@ export class MegaRuntime {
736
810
  cacheHitTokens: 0,
737
811
  lengthStopPending: false,
738
812
  };
813
+ this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
739
814
  this.statusKey = undefined;
740
815
  this.activeAgents = 0;
741
816
  this.currentTurn = 0;
@@ -887,6 +962,7 @@ export class MegaRuntime {
887
962
  (_eventType, filename) => {
888
963
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
889
964
  this.cachedGameState = undefined;
965
+ this.gameStateBump++;
890
966
  }
891
967
  },
892
968
  );
@@ -932,6 +1008,7 @@ export class MegaRuntime {
932
1008
  * the panel picks up theme/mode/toggle changes live. */
933
1009
  bumpGameState(): void {
934
1010
  this.cachedGameState = undefined;
1011
+ this.gameStateBump++;
935
1012
  }
936
1013
 
937
1014
  /** S33: player level for game mode — floor(log2(turns+1))+1 (gentle).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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",