pi-mega-compact 0.8.4 → 0.8.5

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
  }
@@ -119,6 +119,17 @@ export class MegaRuntime {
119
119
  lastCtxWindow = 0;
120
120
  // Latest computed widget payload (recomputed per snapshot, rendered per frame).
121
121
  widgetData = null;
122
+ // v0.8.5: material-change signature from the last full snapshot() body. When
123
+ // the next snapshot()'s signature matches, the expensive recompute (6 sync
124
+ // SQLite opens) + writeFileSync(dashboard.json) are skipped — only the
125
+ // (already-registered) widget factory is refreshed. Kills the per-event
126
+ // main-thread block during typing/idle streaming with no material change.
127
+ lastSnapshotSig = null;
128
+ // v0.8.5: bumped whenever the cached game-state memo is evicted (bumpGameState
129
+ // for in-process /mega-game writes, the fs.watch callback for cross-process
130
+ // dashboard-server writes, and bindRepo on repo switch) so the snapshot gate
131
+ // invalidates and the widget re-reads theme/mode after the change.
132
+ gameStateBump = 0;
122
133
  // Cached cross-repo drift status (recomputed at most every 30s — it opens the
123
134
  // machine-wide registry DB, so we don't want to do it on every render frame).
124
135
  driftCache = null;
@@ -253,6 +264,7 @@ export class MegaRuntime {
253
264
  // stateDir), so evict the memo on every repo switch; the next widget render
254
265
  // re-queries lazily via getCachedGameState().
255
266
  this.cachedGameState = undefined;
267
+ this.gameStateBump++;
256
268
  // S32: re-target the fs.watch cache-eviction watcher at the NEW stateDir's
257
269
  // sqlite.db so cross-process writes (dashboard server) still evict the memo.
258
270
  this.ensureGameStateWatcher();
@@ -293,6 +305,21 @@ export class MegaRuntime {
293
305
  snapshot(ctx) {
294
306
  if (ctx)
295
307
  this.bindRepo(ctx.cwd);
308
+ // v0.8.5: gate the expensive body (6 sync SQLite opens +
309
+ // writeFileSync(dashboard.json)) behind a cheap material-change signature.
310
+ // During typing / idle / no-compaction streaming, the 'context' event
311
+ // fires repeatedly with NO material change — skip the recompute + write and
312
+ // just re-register the (live) widget factory, which reads the cached
313
+ // widgetData every frame. This removes the per-event main-thread block
314
+ // WITHOUT changing write timing, so tests that read dashboard.json
315
+ // synchronously after a compaction still see it written (compaction changes
316
+ // compactCount/tokensSaved → the signature changes → the full recompute +
317
+ // write runs).
318
+ const sig = this.materialSig();
319
+ if (ctx && this.widgetData && this.lastSnapshotSig === sig) {
320
+ this.renderWidget(ctx);
321
+ return;
322
+ }
296
323
  const st = this.store.stats(this.rt.sessionId);
297
324
  const repo = this.store.repoStats();
298
325
  const di = this.store.dataInvariant();
@@ -596,6 +623,9 @@ export class MegaRuntime {
596
623
  // process.stdout.columns. buildWidgetLines reads this.widgetData live.
597
624
  this.renderWidget(ctx);
598
625
  }
626
+ // v0.8.5: record the material-change signature computed at the top so the
627
+ // next snapshot() can skip this whole body when nothing material changed.
628
+ this.lastSnapshotSig = sig;
599
629
  }
600
630
  /** Register the above-editor widget as a width-aware factory so pi re-renders
601
631
  * it at the REAL terminal width every frame (auto-fit wide/narrow). The
@@ -607,6 +637,34 @@ export class MegaRuntime {
607
637
  invalidate: () => { },
608
638
  }), { placement: "aboveEditor" });
609
639
  }
640
+ /** v0.8.5: cheap material-change signature over live runtime fields (no
641
+ * SQLite). Two snapshots with the same signature produce identical
642
+ * dashboard.json + widgetData, so the 6 synchronous SQLite opens + the
643
+ * writeFileSync(dashboard.json) can be skipped. Built from in-memory state
644
+ * only; gameStateBump covers cross-process game_state edits (fs.watch) +
645
+ * in-process /mega-game writes (bumpGameState) + repo switches (bindRepo).
646
+ * The transient flare flags are included so a one-shot flare forces the
647
+ * recompute that renders (then clears) it for exactly one cycle. */
648
+ materialSig() {
649
+ const rt = this.rt;
650
+ const ae = this.activeEffect;
651
+ return JSON.stringify([
652
+ this.lastCtxTokens, this.lastCtxPercent, this.lastCtxWindow,
653
+ this.activeAgents, this.currentTurn,
654
+ rt.compactCount, rt.tokensSaved, rt.dedupSkips, rt.dedupAttempts,
655
+ rt.recallInjections, rt.cacheHitTokens, rt.persistedThisSession,
656
+ rt.lastCheckpointId ?? null, rt.lastCompactedFrom, rt.lastCompactedTokens,
657
+ this.statusKey ?? null,
658
+ this.currentModel?.modelId ?? null, this.currentModel?.provider ?? null,
659
+ ae ? `${ae.type}:${ae.role}:${ae.startedAt}` : null,
660
+ this.gameStateBump,
661
+ this.megaCacheFlare, this.megaCacheFlarePct,
662
+ this.levelUpFlare, this.achievementFlare,
663
+ this.achievementFlareTitles.join("|"),
664
+ this.tierTrace ?? null, this.lastWhy ?? null, this.pulsing,
665
+ this.ticker.length,
666
+ ]);
667
+ }
610
668
  /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
611
669
  embedderName() {
612
670
  // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
@@ -803,6 +861,7 @@ export class MegaRuntime {
803
861
  this.gameStateWatcher = watch(this.currentStateDir, (_eventType, filename) => {
804
862
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
805
863
  this.cachedGameState = undefined;
864
+ this.gameStateBump++;
806
865
  }
807
866
  });
808
867
  this.gameStateWatchDir = this.currentStateDir;
@@ -849,6 +908,7 @@ export class MegaRuntime {
849
908
  * the panel picks up theme/mode/toggle changes live. */
850
909
  bumpGameState() {
851
910
  this.cachedGameState = undefined;
911
+ this.gameStateBump++;
852
912
  }
853
913
  /** S33: player level for game mode — floor(log2(turns+1))+1 (gentle).
854
914
  * 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
  }
@@ -158,6 +158,17 @@ export class MegaRuntime {
158
158
 
159
159
  // Latest computed widget payload (recomputed per snapshot, rendered per frame).
160
160
  widgetData: WidgetData | null = null;
161
+ // v0.8.5: material-change signature from the last full snapshot() body. When
162
+ // the next snapshot()'s signature matches, the expensive recompute (6 sync
163
+ // SQLite opens) + writeFileSync(dashboard.json) are skipped — only the
164
+ // (already-registered) widget factory is refreshed. Kills the per-event
165
+ // main-thread block during typing/idle streaming with no material change.
166
+ private lastSnapshotSig: string | null = null;
167
+ // v0.8.5: bumped whenever the cached game-state memo is evicted (bumpGameState
168
+ // for in-process /mega-game writes, the fs.watch callback for cross-process
169
+ // dashboard-server writes, and bindRepo on repo switch) so the snapshot gate
170
+ // invalidates and the widget re-reads theme/mode after the change.
171
+ private gameStateBump = 0;
161
172
  // Cached cross-repo drift status (recomputed at most every 30s — it opens the
162
173
  // machine-wide registry DB, so we don't want to do it on every render frame).
163
174
  private driftCache: { at: number; status: "ok" | "warn" } | null = null;
@@ -303,6 +314,7 @@ export class MegaRuntime {
303
314
  // stateDir), so evict the memo on every repo switch; the next widget render
304
315
  // re-queries lazily via getCachedGameState().
305
316
  this.cachedGameState = undefined;
317
+ this.gameStateBump++;
306
318
  // S32: re-target the fs.watch cache-eviction watcher at the NEW stateDir's
307
319
  // sqlite.db so cross-process writes (dashboard server) still evict the memo.
308
320
  this.ensureGameStateWatcher();
@@ -343,6 +355,21 @@ export class MegaRuntime {
343
355
  /** Collect live state and write it to disk (+ paint the above-editor widget). */
344
356
  snapshot(ctx?: ExtensionContext): void {
345
357
  if (ctx) this.bindRepo(ctx.cwd);
358
+ // v0.8.5: gate the expensive body (6 sync SQLite opens +
359
+ // writeFileSync(dashboard.json)) behind a cheap material-change signature.
360
+ // During typing / idle / no-compaction streaming, the 'context' event
361
+ // fires repeatedly with NO material change — skip the recompute + write and
362
+ // just re-register the (live) widget factory, which reads the cached
363
+ // widgetData every frame. This removes the per-event main-thread block
364
+ // WITHOUT changing write timing, so tests that read dashboard.json
365
+ // synchronously after a compaction still see it written (compaction changes
366
+ // compactCount/tokensSaved → the signature changes → the full recompute +
367
+ // write runs).
368
+ const sig = this.materialSig();
369
+ if (ctx && this.widgetData && this.lastSnapshotSig === sig) {
370
+ this.renderWidget(ctx);
371
+ return;
372
+ }
346
373
  const st = this.store.stats(this.rt.sessionId);
347
374
  const repo = this.store.repoStats();
348
375
  const di = this.store.dataInvariant();
@@ -664,6 +691,9 @@ export class MegaRuntime {
664
691
  // process.stdout.columns. buildWidgetLines reads this.widgetData live.
665
692
  this.renderWidget(ctx);
666
693
  }
694
+ // v0.8.5: record the material-change signature computed at the top so the
695
+ // next snapshot() can skip this whole body when nothing material changed.
696
+ this.lastSnapshotSig = sig;
667
697
  }
668
698
 
669
699
  /** Register the above-editor widget as a width-aware factory so pi re-renders
@@ -686,6 +716,35 @@ export class MegaRuntime {
686
716
  );
687
717
  }
688
718
 
719
+ /** v0.8.5: cheap material-change signature over live runtime fields (no
720
+ * SQLite). Two snapshots with the same signature produce identical
721
+ * dashboard.json + widgetData, so the 6 synchronous SQLite opens + the
722
+ * writeFileSync(dashboard.json) can be skipped. Built from in-memory state
723
+ * only; gameStateBump covers cross-process game_state edits (fs.watch) +
724
+ * in-process /mega-game writes (bumpGameState) + repo switches (bindRepo).
725
+ * The transient flare flags are included so a one-shot flare forces the
726
+ * recompute that renders (then clears) it for exactly one cycle. */
727
+ private materialSig(): string {
728
+ const rt = this.rt;
729
+ const ae = this.activeEffect;
730
+ return JSON.stringify([
731
+ this.lastCtxTokens, this.lastCtxPercent, this.lastCtxWindow,
732
+ this.activeAgents, this.currentTurn,
733
+ rt.compactCount, rt.tokensSaved, rt.dedupSkips, rt.dedupAttempts,
734
+ rt.recallInjections, rt.cacheHitTokens, rt.persistedThisSession,
735
+ rt.lastCheckpointId ?? null, rt.lastCompactedFrom, rt.lastCompactedTokens,
736
+ this.statusKey ?? null,
737
+ this.currentModel?.modelId ?? null, this.currentModel?.provider ?? null,
738
+ ae ? `${ae.type}:${ae.role}:${ae.startedAt}` : null,
739
+ this.gameStateBump,
740
+ this.megaCacheFlare, this.megaCacheFlarePct,
741
+ this.levelUpFlare, this.achievementFlare,
742
+ this.achievementFlareTitles.join("|"),
743
+ this.tierTrace ?? null, this.lastWhy ?? null, this.pulsing,
744
+ this.ticker.length,
745
+ ]);
746
+ }
747
+
689
748
  /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
690
749
  private embedderName(): string {
691
750
  // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
@@ -887,6 +946,7 @@ export class MegaRuntime {
887
946
  (_eventType, filename) => {
888
947
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
889
948
  this.cachedGameState = undefined;
949
+ this.gameStateBump++;
890
950
  }
891
951
  },
892
952
  );
@@ -932,6 +992,7 @@ export class MegaRuntime {
932
992
  * the panel picks up theme/mode/toggle changes live. */
933
993
  bumpGameState(): void {
934
994
  this.cachedGameState = undefined;
995
+ this.gameStateBump++;
935
996
  }
936
997
 
937
998
  /** 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.5",
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",