pi-mega-compact 0.8.23 → 0.8.25

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 (157) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +8 -0
  3. package/dist/extensions/dashboard-server/api-contracts/game-types.js +7 -0
  4. package/dist/extensions/mega-compact-s38.test.js +263 -14
  5. package/dist/extensions/mega-compact.js +15 -0
  6. package/dist/extensions/mega-config.js +3 -0
  7. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  8. package/dist/extensions/mega-events/context-handler.js +45 -7
  9. package/dist/extensions/mega-events/error-classifier.js +125 -18
  10. package/dist/extensions/mega-pipeline/compact.js +24 -13
  11. package/dist/extensions/mega-pipeline/recall.js +31 -2
  12. package/dist/extensions/mega-runtime/append-event.js +24 -0
  13. package/dist/extensions/mega-runtime/bind-repo.js +65 -0
  14. package/dist/extensions/mega-runtime/capture-model.js +87 -0
  15. package/dist/extensions/mega-runtime/dashboard-snapshot.js +122 -0
  16. package/dist/extensions/mega-runtime/effects.js +86 -0
  17. package/dist/extensions/mega-runtime/engine-view.js +11 -0
  18. package/dist/extensions/mega-runtime/game-state.js +116 -0
  19. package/dist/extensions/mega-runtime/get-state-dir.js +10 -0
  20. package/dist/extensions/mega-runtime/perf.js +49 -0
  21. package/dist/extensions/mega-runtime/pressure-getters.js +64 -0
  22. package/dist/extensions/mega-runtime/render-widget.js +17 -0
  23. package/dist/extensions/mega-runtime/reset-runtime.js +50 -0
  24. package/dist/extensions/mega-runtime/runtime-helpers.js +73 -0
  25. package/dist/extensions/mega-runtime/runtime-snapshot.js +208 -0
  26. package/dist/extensions/mega-runtime/runtime.js +405 -0
  27. package/dist/extensions/mega-runtime/snapshot.js +142 -0
  28. package/dist/extensions/mega-runtime/state.js +5 -1151
  29. package/dist/extensions/mega-runtime/status.js +11 -0
  30. package/dist/extensions/mega-runtime/widget-ansi.js +207 -0
  31. package/dist/extensions/mega-runtime/widget-types.js +8 -0
  32. package/dist/extensions/mega-runtime/widget.js +15 -204
  33. package/dist/extensions/openclaw-mega-compact.js +291 -0
  34. package/dist/src/boundary.js +79 -43
  35. package/dist/src/boundary.test.js +119 -2
  36. package/dist/src/canary.js +10 -0
  37. package/dist/src/config/dedup.js +14 -0
  38. package/dist/src/config.js +3 -1
  39. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  40. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  41. package/dist/src/dedup/raptor/index.js +38 -0
  42. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  43. package/dist/src/dedup/raptor/multilevel.js +17 -5
  44. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  45. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  46. package/dist/src/dedup/raptor/retrieval.js +14 -2
  47. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  48. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  49. package/dist/src/dedup/raptor/summarizer.js +1 -0
  50. package/dist/src/dedup/raptor/tree.js +16 -2
  51. package/dist/src/engine.js +18 -2
  52. package/dist/src/httpEmbedder.js +96 -6
  53. package/dist/src/httpEmbedder.test.js +277 -0
  54. package/dist/src/mechanical-fix.test.js +65 -0
  55. package/dist/src/minilm.js +92 -0
  56. package/dist/src/raptor-inject-summaries.test.js +155 -0
  57. package/dist/src/recall.js +135 -21
  58. package/dist/src/recall.test.js +179 -4
  59. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  60. package/dist/src/store/sqlite/maintenance.js +2 -2
  61. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  62. package/dist/src/store/sqlite/memories.js +5 -5
  63. package/dist/src/store/sqlite/meta.js +1 -1
  64. package/dist/src/store/sqlite/raptor.js +56 -17
  65. package/dist/src/store/sqlite/raptor.test.js +106 -0
  66. package/dist/src/store/sqlite/schema.js +90 -1
  67. package/dist/src/store/sqlite/session-state.js +9 -3
  68. package/dist/src/store/sqlite/stats.js +9 -5
  69. package/dist/src/store/sqlite/turns.js +181 -0
  70. package/dist/src/store/sqlite/turns.test.js +183 -0
  71. package/dist/src/store/sqlite/utils.js +15 -4
  72. package/dist/src/store/sqlite.js +1 -0
  73. package/dist/src/store.js +2 -2
  74. package/dist/src/vector-search-cache.test.js +157 -0
  75. package/dist/src/vector-search.js +107 -15
  76. package/dist/src/vectorStore.js +36 -8
  77. package/dist/src/wordpiece.js +129 -0
  78. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +1 -1
  79. package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -155
  80. package/extensions/dashboard-server/api-contracts/game-types.ts +172 -0
  81. package/extensions/mega-compact-s38.test.ts +259 -14
  82. package/extensions/mega-compact.ts +15 -0
  83. package/extensions/mega-config.ts +18 -0
  84. package/extensions/mega-dashboard.ts +10 -1
  85. package/extensions/mega-events/agent-handlers.ts +211 -26
  86. package/extensions/mega-events/context-handler.ts +43 -7
  87. package/extensions/mega-events/error-classifier.ts +125 -17
  88. package/extensions/mega-pipeline/compact.ts +28 -16
  89. package/extensions/mega-pipeline/recall.ts +34 -2
  90. package/extensions/mega-runtime/DECOMPOSITION.md +180 -0
  91. package/extensions/mega-runtime/README.md +38 -0
  92. package/extensions/mega-runtime/append-event.ts +40 -0
  93. package/extensions/mega-runtime/bind-repo.ts +81 -0
  94. package/extensions/mega-runtime/capture-model.ts +101 -0
  95. package/extensions/mega-runtime/dashboard-snapshot.ts +181 -0
  96. package/extensions/mega-runtime/effects.ts +129 -0
  97. package/extensions/mega-runtime/engine-view.ts +17 -0
  98. package/extensions/mega-runtime/game-state.ts +149 -0
  99. package/extensions/mega-runtime/get-state-dir.ts +19 -0
  100. package/extensions/mega-runtime/helpers.ts +25 -1
  101. package/extensions/mega-runtime/perf.ts +60 -0
  102. package/extensions/mega-runtime/pressure-getters.ts +96 -0
  103. package/extensions/mega-runtime/render-widget.ts +41 -0
  104. package/extensions/mega-runtime/runtime-helpers.ts +119 -0
  105. package/extensions/mega-runtime/runtime-snapshot.ts +293 -0
  106. package/extensions/mega-runtime/runtime.ts +483 -0
  107. package/extensions/mega-runtime/snapshot.ts +230 -0
  108. package/extensions/mega-runtime/state.ts +5 -1268
  109. package/extensions/mega-runtime/status.ts +26 -0
  110. package/extensions/mega-runtime/widget-ansi.ts +217 -0
  111. package/extensions/mega-runtime/widget-types.ts +80 -0
  112. package/extensions/mega-runtime/widget.ts +34 -285
  113. package/package.json +1 -1
  114. package/src/boundary.test.ts +128 -2
  115. package/src/boundary.ts +75 -39
  116. package/src/canary.ts +10 -0
  117. package/src/config/dedup.ts +25 -0
  118. package/src/config.ts +3 -1
  119. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  120. package/src/dedup/raptor/buildHistory.ts +259 -0
  121. package/src/dedup/raptor/index.ts +38 -0
  122. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  123. package/src/dedup/raptor/multilevel.test.ts +47 -0
  124. package/src/dedup/raptor/multilevel.ts +18 -8
  125. package/src/dedup/raptor/raptor.test.ts +59 -0
  126. package/src/dedup/raptor/retrieval.test.ts +118 -0
  127. package/src/dedup/raptor/retrieval.ts +14 -2
  128. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  129. package/src/dedup/raptor/summarizer.ts +1 -0
  130. package/src/dedup/raptor/tree.ts +17 -2
  131. package/src/engine.ts +32 -3
  132. package/src/httpEmbedder.test.ts +286 -0
  133. package/src/httpEmbedder.ts +98 -8
  134. package/src/mechanical-fix.test.ts +70 -0
  135. package/src/raptor-inject-summaries.test.ts +212 -0
  136. package/src/recall.test.ts +220 -4
  137. package/src/recall.ts +151 -22
  138. package/src/store/sqlite/dedup-mirror.ts +35 -18
  139. package/src/store/sqlite/maintenance.ts +2 -2
  140. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  141. package/src/store/sqlite/memories.ts +5 -5
  142. package/src/store/sqlite/meta.ts +1 -1
  143. package/src/store/sqlite/raptor.test.ts +139 -0
  144. package/src/store/sqlite/raptor.ts +135 -81
  145. package/src/store/sqlite/schema.ts +90 -1
  146. package/src/store/sqlite/session-state.ts +9 -3
  147. package/src/store/sqlite/stats.ts +10 -8
  148. package/src/store/sqlite/turns.test.ts +218 -0
  149. package/src/store/sqlite/turns.ts +292 -0
  150. package/src/store/sqlite/utils.ts +14 -4
  151. package/src/store/sqlite.ts +1 -0
  152. package/src/store.ts +9 -2
  153. package/src/vector-search-cache.test.ts +190 -0
  154. package/src/vector-search.ts +273 -156
  155. package/src/vectorStore.ts +443 -382
  156. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  157. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
@@ -1,1154 +1,8 @@
1
1
  /**
2
- * state.ts — the `MegaRuntime` class: shared live state of the mega-compact
3
- * extension.
2
+ * state.ts — backwards-compatible re-export of MegaRuntime.
4
3
  *
5
- * The original mega-compact.ts was a single large closure over ~20 mutable
6
- * variables. This module lifts that state into a `MegaRuntime` class so the
7
- * event/command/pipeline modules can share it without re-declaring it. All
8
- * behavior (store/dashboard rebinding, dashboard snapshot shape, the
9
- * above-editor widget math, model capture) is preserved byte-for-byte from the
10
- * original closure.
4
+ * The class implementation lives in runtime.ts. This file exists so that
5
+ * every existing `import { MegaRuntime } from "./state.js"` continues to
6
+ * resolve without changes.
11
7
  */
12
- import { join } from "node:path";
13
- import { appendFileSync, mkdirSync } from "node:fs";
14
- import { VectorStore, vectorStats, vectorRepoStats, vectorDataInvariant } from "../../src/vectorStore.js";
15
- import { toEngineMessages } from "../../src/adapt.js";
16
- import { normalizeSessionId } from "../../src/store.js";
17
- import { Logger } from "../../src/log.js";
18
- import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, getDedupStats, getCompactCount, getRecallInjected, getCacheHitTokensSaved, getGameState, recordPerfSample, recordSessionHeartbeat, appendTokenSample, } from "../../src/store/sqlite.js";
19
- import { detectCrossRepoDrift } from "../../src/driftDetection.js";
20
- import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens, } from "../mega-config.js";
21
- import { Dashboard } from "../mega-dashboard.js";
22
- import { STATUS_KEY, WIDGET_KEY, TOKENS_PER_SEC_ESTIMATE, ownVersion, } from "./helpers.js";
23
- import { C, buildWidgetLines, } from "./widget.js";
24
- import { getTheme } from "../../src/config/themes.js";
25
- import { watch } from "node:fs";
26
- import { turnLevel } from "../../src/game/scoring.js";
27
- export class MegaRuntime {
28
- config;
29
- // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
30
- // gets its own isolated state dir. They start bound to the global default.
31
- store;
32
- logger;
33
- dashboard;
34
- activeRepoRoot = null;
35
- currentStateDir;
36
- // The only mutable per-session state. Reset on session_start / session_tree.
37
- rt = {
38
- sessionId: normalizeSessionId(undefined),
39
- persistedThisSession: false,
40
- lastCheckpointId: undefined,
41
- lastCompactedFrom: 0,
42
- lastCompactedTokens: 0,
43
- dedupSkips: 0,
44
- dedupAttempts: 0,
45
- tokensSaved: 0,
46
- lastCompactAt: null,
47
- lastNativeCompactAt: null,
48
- compactCount: 0,
49
- recallInjections: 0,
50
- cacheHitTokens: 0,
51
- lengthStopPending: false,
52
- errorRetryCount: 0,
53
- errorRetryUntil: 0,
54
- consecutiveErrors: 0,
55
- };
56
- // v0.8.6 cache-stability: the cached live-trim view for the current
57
- // compaction epoch. Set after a fresh runCompact + computeLiveTrimCut, and
58
- // replayed verbatim on subsequent gated context events in the SAME epoch
59
- // (same checkpointId) so the provider KV-cache prefix stays stable instead
60
- // of being invalidated by a freshly regenerated summary + sentinel every
61
- // fire. Invalidated on session restart (resetRuntime) and on any native
62
- // durable compaction (session_compact) that truncates the transcript.
63
- trimCache = null;
64
- debounceUntil = 0;
65
- // S16: debounce for the agent_end resume nudge (avoid busy-loops).
66
- resumeNudgeUntil = 0;
67
- // Agent tracking for real-time widget updates
68
- activeAgents = 0;
69
- currentTurn = 0;
70
- // S33: transient MEGA CACHE flare flag (armed by the turn_end scoring hook
71
- // when cachePct > 100). Copied into widgetData.megaCacheFlare on the next
72
- // snapshot() so the widget renders the oopsie gag, then reset (one cycle).
73
- megaCacheFlare = false;
74
- /** v0.8.3: ambient effect state for animated panel borders keyed off
75
- * status transitions (level-up, mega-cache overshoot, achievement unlock,
76
- * compaction start). Threaded into widgetData as `activeEffect`; the widget
77
- * computes the per-frame phase from startedAt vs Date.now() (non-expired).
78
- * Null when idle/expired. */
79
- activeEffect = null;
80
- megaCacheFlarePct = 0;
81
- levelUpFlare = false;
82
- lastLevel = 0;
83
- // S35: transient achievement-unlock flare (armed by the scoring hooks after
84
- // evaluateAndUnlockAchievements returns newly-unlocked titles). Copied into
85
- // widgetData.achievementFlare on the next snapshot() so the widget renders the
86
- // unlock toast, then reset (one cycle — mirrors megaCacheFlare/levelUpFlare).
87
- achievementFlare = false;
88
- achievementFlareTitles = [];
89
- // S33: last cumulative dedup-collapsed count seen by the session_compact
90
- // hook, so we only record the DELTA as the dedupe score (leaderboard sums).
91
- lastDedupCollapsed = 0;
92
- // Recall block produced by auto-inline (resume/branch) that the next
93
- // before_agent_start should prepend to the system prompt. Unset after use.
94
- pendingRecallBlock;
95
- // S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
96
- // semantics; composed with the checkpoint block in before_agent_start.
97
- pendingMemoryRecallBlock;
98
- statusKey; // current status text for dashboard
99
- // Active model/provider (for real cost estimation). Captured from ctx.model
100
- // on model_select + session_start; persisted to SQL so cost + the dashboard
101
- // can read it without a live ctx.
102
- currentModel;
103
- // Live "what it's doing right now" timestamp, used for the fresh-window.
104
- lastActivityAt = 0;
105
- // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
106
- // Built from the store's sync onTier callback during a compaction so the user
107
- // watches each tier evaluate in real time. Cleared once the outcome settles.
108
- tierTrace;
109
- // Phase 3 — standout toolbar state.
110
- // Recall/activity ticker: a small ring buffer (≤5) of recent compact/recall
111
- // events so the widget shows a live history instead of a single last action.
112
- ticker = [];
113
- TICKER_MAX = 5;
114
- // Pulsing status: set true while a compaction is in flight, cleared on result.
115
- pulsing = false;
116
- // S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
117
- // the current compaction. The pipeline reads this after a successful compact
118
- // to decide whether to fire `consolidateMemories` (skip the work entirely
119
- // when no memory rows changed).
120
- memoriesTouchedThisCompaction = 0;
121
- // Rolling "saved" goal for the progress bar — grows as we save more, so the
122
- // bar always has a meaningful denominator (never sits at 100% forever).
123
- savedGoal = 50_000;
124
- // Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
125
- // while fresh.
126
- lastWhy = undefined;
127
- // v0.8.8 Perf dashboard instrumentation: turn/provider start timestamps +
128
- // the 5s cpu/mem interval handle (one per MegaRuntime, cleared in dispose()).
129
- perfTurnStart = 0;
130
- perfProviderStart = 0;
131
- perfCpuInterval;
132
- perfCpuBaseline;
133
- // Context tracking for the dashboard (updated in the context handler).
134
- lastCtxTokens = null;
135
- lastCtxPercent = null;
136
- lastCtxWindow = 0;
137
- // Latest computed widget payload (recomputed per snapshot, rendered per frame).
138
- widgetData = null;
139
- // v0.8.5: material-change signature from the last full snapshot() body. When
140
- // the next snapshot()'s signature matches, the expensive recompute (6 sync
141
- // SQLite opens) + writeFileSync(dashboard.json) are skipped — only the
142
- // (already-registered) widget factory is refreshed. Kills the per-event
143
- // main-thread block during typing/idle streaming with no material change.
144
- lastSnapshotSig = null;
145
- // v0.8.5: bumped whenever the cached game-state memo is evicted (bumpGameState
146
- // for in-process /mega-game writes, the fs.watch callback for cross-process
147
- // dashboard-server writes, and bindRepo on repo switch) so the snapshot gate
148
- // invalidates and the widget re-reads theme/mode after the change.
149
- gameStateBump = 0;
150
- // Cached cross-repo drift status (recomputed at most every 30s — it opens the
151
- // machine-wide registry DB, so we don't want to do it on every render frame).
152
- driftCache = null;
153
- // S31: cached game-mode state (game_mode_on/theme/tui_display_mode). Lazily
154
- // read from the game_state SQLite row on the first widget render, then
155
- // memoized until bumpGameState() evicts it (called by /mega-game after a
156
- // write) so the widget picks up theme/mode/level changes live without
157
- // re-querying the DB on every render frame.
158
- cachedGameState;
159
- // S32: fs.watch on the current repo's sqlite.db so cross-process writes
160
- // (e.g. the dashboard server's PUT /api/game-state, which runs as a detached
161
- // child with no MegaRuntime ref) evict the cached game-state memo. Without
162
- // this, /mega-game's in-process bumpGameState() is the only eviction trigger
163
- // and the widget would keep showing stale theme/mode/toggle after a dashboard
164
- // edit until a restart. The watcher tracks currentStateDir — closed + re-opened
165
- // by ensureGameStateWatcher() on every bindRepo repo switch. Non-fatal: any
166
- // fs.watch failure (missing file / platform issue) is swallowed; the next
167
- // getCachedGameState() snapshot re-queries the DB anyway.
168
- gameStateWatcher;
169
- gameStateWatchDir;
170
- // P2: the last ExtensionContext handed to snapshot()/renderWidget(), stashed
171
- // so the fs.watch game-state callback can force a widget re-render without
172
- // a context event (cross-process dashboard edits while pi is idle). Cleared
173
- // implicitly on construction (undefined → watcher skips until first snap).
174
- lastWidgetCtx;
175
- /**
176
- * DIAG counters for the "team run doesn't relieve context" investigation.
177
- * Plain integers, incremented at the three compaction decision points. They
178
- * let a headless test drive the real event handlers and assert the firing
179
- * cadence without scraping log files. Inert in production (the live-trim and
180
- * before-compact probes also emit logger.info, but these counters are always
181
- * updated and cost nothing).
182
- */
183
- diagLiveTrimFires = 0; // context handler returned a trimmed view
184
- diagLiveTrimReplays = 0; // v0.8.6: trim view returned via cached replay (skipped re-compact)
185
- diagBeforeCompactFires = 0; // session_before_compact handler entered
186
- diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
187
- diagAgentEndIdle = 0; // agent_end with activeAgents===0
188
- diagAgentEndDurable = 0; // agent_end fired ctx.compact() (mid-run durable trim)
189
- diagAgentEndDurableSkipRecent = 0; // agent_end skipped ctx.compact() — compaction in last 10s (race guard)
190
- // Per-skip-path counters for the team-run diagnosis.
191
- diagCtxFastGate = 0; // returned at token fast-gate (below threshold)
192
- diagCtxNoCompact = 0; // autoCompactCheck().shouldCompact === false
193
- diagCtxDebounce = 0; // debounceUntil not yet elapsed
194
- diagCtxRunSkipped = 0; // runCompact() returned skipped
195
- diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
196
- diagCtxThrown = 0; // live-trim try threw (caught)
197
- /**
198
- * S26 capture instrumentation: the "model_snapshots empty → $0.00 cost card"
199
- * bug was invisible because captureModel swallowed the DB write in a silent
200
- * `catch {}`. These always-updated counters (zero cost) let a headless test or
201
- * a live capture tell whether captureModel ran and whether the snapshot landed.
202
- */
203
- diagCaptureModelCalls = 0; // captureModel entered with a populated ctx.model
204
- diagCaptureModelFails = 0; // recordModelSnapshot threw → model_snapshots stays empty
205
- /**
206
- * Live 0–1 pressure — how full the context window is relative to the
207
- * compaction threshold.
208
- *
209
- * RECONCILE (BACKLOG dual-basis flicker): when the model context window is
210
- * known we base pressure consistently on the *percentage* basis
211
- * (`lastCtxPercent / (tierPct*100)`). This keeps the band stable whether the
212
- * latest context event carried a token count or only a percentage, so the
213
- * threshold comparison doesn't jump when a token-count event arrives vs a
214
- * percent-only event. We only fall back to the token-count basis
215
- * (`config.thresholdTokens`) when the window is unknown (e.g. before the first
216
- * context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
217
- */
218
- get pressure() {
219
- if (this.lastCtxWindow > 0 &&
220
- this.config.tierPct != null &&
221
- this.lastCtxPercent != null) {
222
- // pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
223
- // exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
224
- // fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
225
- // token-based pressureRatio(currentTokens, effectiveThreshold) reading so
226
- // the band doesn't jump when a token-count vs percent-only event arrives.
227
- return pressureFromPct(this.lastCtxPercent / this.config.tierPct);
228
- }
229
- if (this.lastCtxTokens != null &&
230
- this.lastCtxTokens > 0 &&
231
- this.config.thresholdTokens > 0) {
232
- return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
233
- }
234
- return pressureFromPct(this.lastCtxPercent);
235
- }
236
- /**
237
- * The live compaction FIRE POINT in tokens: the effective threshold scaled by
238
- * the current model context window (`tierPct * window`) when known, else the
239
- * boot fallback `config.thresholdTokens`. This is what the FAST GATE /
240
- * `autoCompactCheck` / agent_end durable-trigger compare against, so
241
- * compaction fires at tier% of the window for ANY model size (200k or 1M),
242
- * always below pi's native auto-compaction (~80% of window).
243
- */
244
- get effectiveThreshold() {
245
- return effectiveThresholdTokens({
246
- tierPct: this.config.tierPct,
247
- fallbackThreshold: this.config.thresholdTokens,
248
- window: this.lastCtxWindow,
249
- });
250
- }
251
- /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
252
- get pressureBand() {
253
- return pressureBand(this.pressure);
254
- }
255
- constructor(config) {
256
- this.config = config;
257
- this.store = new VectorStore({
258
- dedupSim: config.dedupSim,
259
- stateDir: config.stateDir,
260
- });
261
- this.logger = new Logger({
262
- enabled: config.debug,
263
- path: join(config.stateDir, "mega-compact.log"),
264
- });
265
- this.dashboard = new Dashboard(config.stateDir);
266
- this.currentStateDir = config.stateDir;
267
- this.ensureGameStateWatcher();
268
- }
269
- // ---- per-repo binding -----------------------------------------------------
270
- /**
271
- * Point store/dashboard/logger at the current repo's state dir. Rebuilds the
272
- * instances only when the repo root changes, so cross-repo dedup stats, db,
273
- * and events are fully isolated. Falls back to the global default outside git.
274
- */
275
- bindRepo(cwd) {
276
- const dir = cwd
277
- ? repoStateDir(cwd, this.config.stateDir)
278
- : this.config.stateDir;
279
- const key = cwd ? (resolveRepoRoot(cwd) ?? dir) : dir;
280
- if (key === this.activeRepoRoot)
281
- return dir;
282
- this.activeRepoRoot = key;
283
- this.currentStateDir = dir;
284
- // S31 audit P2: bindRepo switched currentStateDir but left cachedGameState
285
- // memoized -> the widget kept showing the previous repo's theme/mode/toggle
286
- // until /mega-game or a restart. The game_state row is per-repo (per
287
- // stateDir), so evict the memo on every repo switch; the next widget render
288
- // re-queries lazily via getCachedGameState().
289
- this.cachedGameState = undefined;
290
- this.gameStateBump++;
291
- // S32: re-target the fs.watch cache-eviction watcher at the NEW stateDir's
292
- // sqlite.db so cross-process writes (dashboard server) still evict the memo.
293
- this.ensureGameStateWatcher();
294
- this.store = new VectorStore({
295
- dedupSim: this.config.dedupSim,
296
- stateDir: dir,
297
- });
298
- this.logger = new Logger({
299
- enabled: this.config.debug,
300
- path: join(dir, "mega-compact.log"),
301
- });
302
- this.dashboard = new Dashboard(dir);
303
- // Aggregate this repo into the machine-wide index so the multi-repo
304
- // dashboard (Summary / All-repos tabs) can show it alongside every other
305
- // repo. Best-effort + non-fatal: a read-only index dir or contention must
306
- // never break the per-repo compaction path. Runs only on repo-switch
307
- // (this branch), so it's infrequent — not per-context-event.
308
- try {
309
- const repo = vectorRepoStats(this.store);
310
- const di = vectorDataInvariant(this.store);
311
- const root = key !== dir ? key : (resolveRepoRoot(cwd ?? dir) ?? dir);
312
- upsertRepoRegistry({
313
- repoRoot: root,
314
- displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
315
- stateDir: dir,
316
- checkpointCount: repo.checkpointCount,
317
- tokensSaved: repo.tokensSaved,
318
- compressedOriginalBytes: di.compressedOriginalBytes,
319
- });
320
- }
321
- catch {
322
- /* non-fatal: index aggregation must not block compaction */
323
- }
324
- return dir;
325
- }
326
- // ---- dashboard snapshot + widget ------------------------------------------
327
- /** Collect live state and write it to disk (+ paint the above-editor widget). */
328
- snapshot(ctx) {
329
- if (ctx)
330
- this.lastWidgetCtx = ctx;
331
- if (ctx)
332
- this.bindRepo(ctx.cwd);
333
- // v0.8.5: gate the expensive body (6 sync SQLite opens +
334
- // writeFileSync(dashboard.json)) behind a cheap material-change signature.
335
- // During typing / idle / no-compaction streaming, the 'context' event
336
- // fires repeatedly with NO material change — skip the recompute + write and
337
- // just re-register the (live) widget factory, which reads the cached
338
- // widgetData every frame. This removes the per-event main-thread block
339
- // WITHOUT changing write timing, so tests that read dashboard.json
340
- // synchronously after a compaction still see it written (compaction changes
341
- // compactCount/tokensSaved → the signature changes → the full recompute +
342
- // write runs).
343
- const sig = this.materialSig();
344
- if (ctx && this.widgetData && this.lastSnapshotSig === sig) {
345
- this.renderWidget(ctx);
346
- return;
347
- }
348
- const perfT0 = performance.now();
349
- const st = vectorStats(this.store, this.rt.sessionId);
350
- const repo = vectorRepoStats(this.store);
351
- const di = vectorDataInvariant(this.store);
352
- // Live + store-wide cache-hit / compaction counters for the dashboard.
353
- const ds = getDedupStats(this.currentStateDir);
354
- const cacheHitsTotal = ds.deduped + getRecallInjected(this.currentStateDir);
355
- const cacheHitsTotalTokens = getCacheHitTokensSaved(this.currentStateDir);
356
- const cacheHitsSession = this.rt.dedupSkips + this.rt.recallInjections;
357
- const sec = (tok) => (tok || 0) / TOKENS_PER_SEC_ESTIMATE;
358
- // Active model/provider for the current-repo card + the multi-repo table.
359
- const modelSnap = latestModelSnapshot(this.currentStateDir);
360
- const model = modelSnap
361
- ? {
362
- name: modelSnap.modelName ?? modelSnap.modelId,
363
- provider: modelSnap.provider,
364
- providerName: modelSnap.providerName ?? "",
365
- inputRate: modelSnap.inputRate,
366
- outputRate: modelSnap.outputRate,
367
- }
368
- : undefined;
369
- // effectiveThresholdPct: the live fire point as a % of the window (null for
370
- // `custom`, which has no tierPct). S29: honors MEGACOMPACT_AUTO_PCT_TRIGGER
371
- // override so the dashboard's armed/ready match the context-handler gate
372
- // (which fires on this same %). Used by armed/ready + the dashboard.
373
- const effectiveThresholdPct = this.config.tierPct != null
374
- ? (this.config.autoPctTrigger ?? this.config.tierPct) * 100
375
- : null;
376
- // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
377
- // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
378
- // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
379
- const armed = this.lastCtxPercent != null &&
380
- this.lastCtxPercent >=
381
- Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
382
- // S29: ready mirrors the context-handler gate's basis — percent for tiered
383
- // (the gate fires on pct), tokens for custom (the gate fires on tokens).
384
- // Previously this always required tokens, so the dashboard could show
385
- // "armed" (percent high) but never "ready" when tokens were under-reported
386
- // — the same inconsistency the S29 gate fix removes.
387
- const ready = this.config.tierPct != null
388
- ? armed && (this.lastCtxPercent ?? 0) >= (effectiveThresholdPct ?? 0)
389
- : armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
390
- this.dashboard.snapshot({
391
- version: 1,
392
- updatedAt: new Date().toISOString(),
393
- // S24: the headline tier is the LIVE pressure band; the env preset is kept
394
- // alongside as presetTier so the dashboard can show both.
395
- tier: this.pressureBand,
396
- presetTier: this.config.tier,
397
- pressure: this.pressure,
398
- config: {
399
- fastGatePct: this.config.fastGatePct,
400
- thresholdTokens: this.effectiveThreshold,
401
- tierPct: this.config.tierPct,
402
- effectiveThresholdPct,
403
- anchorUserMessages: this.config.anchorUserMessages,
404
- preserveRecent: this.config.preserveRecent,
405
- auto: this.config.auto,
406
- autoInline: this.config.autoInline,
407
- },
408
- session: {
409
- id: this.rt.sessionId,
410
- state: this.statusKey ?? "idle",
411
- persistedThisSession: this.rt.persistedThisSession,
412
- lastCheckpointId: this.rt.lastCheckpointId ?? null,
413
- lastCompactedFrom: this.rt.lastCompactedFrom,
414
- lastCompactedTokens: this.rt.lastCompactedTokens,
415
- dedupSkips: this.rt.dedupSkips,
416
- dedupAttempts: this.rt.dedupAttempts,
417
- },
418
- context: {
419
- tokens: this.lastCtxTokens,
420
- percent: this.lastCtxPercent,
421
- contextWindow: this.lastCtxWindow,
422
- },
423
- trigger: {
424
- armed,
425
- ready,
426
- currentTokens: this.lastCtxTokens,
427
- thresholdTokens: this.effectiveThreshold,
428
- fastGatePct: this.config.fastGatePct,
429
- tierPct: this.config.tierPct,
430
- effectiveThresholdPct,
431
- },
432
- crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
433
- store: {
434
- checkpointCount: st.checkpointCount,
435
- totalTokenEstimate: st.totalTokenEstimate,
436
- originalTokens: st.originalTokens,
437
- tokensSaved: this.rt.tokensSaved,
438
- injectedCount: st.injectedCount,
439
- dedupHitRate: st.dedupHitRate,
440
- storageDedupRate: st.storageDedupRate,
441
- dedupAttempts: st.dedupAttempts,
442
- dedupCollapsed: st.dedupCollapsed,
443
- },
444
- // Reconciled token accounting (single canonical formula, session + repo).
445
- // Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
446
- // deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
447
- compression: {
448
- session: {
449
- tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
450
- tokensOut: st.totalTokenEstimate,
451
- tokensFreed: this.rt.tokensSaved,
452
- compressionPct: this.rt.tokensSaved + st.totalTokenEstimate > 0
453
- ? this.rt.tokensSaved /
454
- (this.rt.tokensSaved + st.totalTokenEstimate)
455
- : 0,
456
- dedupPct: st.storageDedupRate,
457
- },
458
- repo: {
459
- tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
460
- tokensOut: repo.totalTokenEstimate,
461
- tokensFreed: repo.tokensSaved,
462
- compressionPct: repo.tokensSaved + repo.totalTokenEstimate > 0
463
- ? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate)
464
- : 0,
465
- dedupPct: repo.storageDedupRate,
466
- },
467
- },
468
- repo: {
469
- checkpointCount: repo.checkpointCount,
470
- totalTokenEstimate: repo.totalTokenEstimate,
471
- originalTokens: repo.originalTokens,
472
- tokensSaved: repo.tokensSaved,
473
- sessionCount: repo.sessionCount,
474
- dedupAttempts: repo.dedupAttempts,
475
- dedupCollapsed: repo.dedupCollapsed,
476
- storageDedupRate: repo.storageDedupRate,
477
- },
478
- integrity: {
479
- regionsRetained: di.regionsRetained,
480
- compressedOriginalBytes: di.compressedOriginalBytes,
481
- duplicatesCollapsed: di.duplicatesCollapsed,
482
- bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
483
- },
484
- cacheHits: {
485
- session: cacheHitsSession,
486
- total: cacheHitsTotal,
487
- sessionTokensSaved: this.rt.cacheHitTokens,
488
- totalTokensSaved: cacheHitsTotalTokens,
489
- },
490
- compacts: {
491
- session: this.rt.compactCount,
492
- total: getCompactCount(this.currentStateDir),
493
- },
494
- timeSaved: {
495
- compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(vectorRepoStats(this.store).tokensSaved) },
496
- cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
497
- },
498
- model,
499
- // S38.8: error-retry state for the dashboard "retries" tile. The field is
500
- // declared on DashboardSnapshot (mega-dashboard.ts) and surfaced here so the
501
- // dashboard can render live retry/circuit-breaker status alongside the event
502
- // stream (which already carries per-retry events).
503
- retries: {
504
- errorRetryCount: this.rt.errorRetryCount,
505
- consecutiveErrors: this.rt.consecutiveErrors,
506
- maxConsecutiveErrors: this.config.maxConsecutiveErrors,
507
- errorRetryHardStop: this.config.errorRetryHardStop,
508
- },
509
- diag: {
510
- ctxFastGate: this.diagCtxFastGate,
511
- liveTrimFires: this.diagLiveTrimFires,
512
- liveTrimReplays: this.diagLiveTrimReplays,
513
- },
514
- });
515
- const perfDiskMs = this.dashboard.lastWriteMs;
516
- // S39: record a session heartbeat + token sample into the shared
517
- // machine-wide index.sqlite so the dashboard can show a real-time
518
- // stacked-memory graph across all active pi processes. Behind the
519
- // material-change gate (this code only runs when sig changed). Non-fatal
520
- // try/catch mirrors the recordPerfSample pattern below. Skip the token
521
- // sample when lastCtxTokens is null (no context data yet).
522
- try {
523
- const repo = resolveRepoRoot(ctx?.cwd ?? this.currentStateDir) ?? this.currentStateDir;
524
- recordSessionHeartbeat(process.pid, this.rt.sessionId, repo, this.currentStateDir, this.lastCtxWindow || 0);
525
- if (this.lastCtxTokens != null) {
526
- appendTokenSample(this.rt.sessionId, repo, this.lastCtxTokens, this.lastCtxPercent ?? 0, this.lastCtxWindow || 0, join(this.currentStateDir, "events.log"));
527
- }
528
- }
529
- catch {
530
- /* non-fatal: S39 monitoring must never block the snapshot path */
531
- }
532
- // Live stats widget above the editor
533
- if (ctx) {
534
- // ── gather widget data (computed per snapshot, rendered per frame) ────
535
- const tokStr = this.lastCtxTokens != null
536
- ? `${Math.round(this.lastCtxTokens / 1000)}k`
537
- : "?";
538
- const maxStr = this.lastCtxWindow > 0
539
- ? `${Math.round(this.lastCtxWindow / 1000)}k`
540
- : "?";
541
- const pctStr = this.lastCtxPercent != null
542
- ? this.lastCtxPercent > 100
543
- ? `>100%` // S29: overshoot warning, not a raw "250%" — the percent trigger now compacts before 100%, so this is the residual case where it can't keep up.
544
- : `${Math.round(this.lastCtxPercent * 10) / 10}%`
545
- : "?%";
546
- // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
547
- // mega), not the static env preset. It climbs as context fills.
548
- const liveBand = this.pressureBand;
549
- const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
550
- const triggerLabel = ready
551
- ? `${C.green}● ready${C.reset}`
552
- : armed
553
- ? `${C.amber}◐ armed${C.reset}`
554
- : `${C.gray}○ idle${C.reset}`;
555
- // Storage dedup rate is cumulative (store-wide, per-repo) and survives
556
- // session resets. Always show a number (decimal for sub-10%).
557
- const storageRate = st.storageDedupRate; // 0..1
558
- const dedupStr = storageRate * 100 >= 10
559
- ? `${Math.round(storageRate * 100)}%`
560
- : `${(storageRate * 100).toFixed(1)}%`;
561
- // Agents view: count + status (S27 per-agent tokens are gated on P0).
562
- const agentLabel = this.activeAgents > 0
563
- ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
564
- : `${C.dim}🤖 idle${C.reset}`;
565
- const agentStr = ` │ ${agentLabel}`;
566
- const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
567
- // Reconciled in/out view (session + repo) — ONE canonical formula.
568
- const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
569
- const sessKept = st.totalTokenEstimate;
570
- const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
571
- const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
572
- const repoKept = repo.totalTokenEstimate;
573
- const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
574
- const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
575
- const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
576
- const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
577
- // Model + provider (S26 capture) for the header.
578
- const modelName = modelSnap?.modelName ?? modelSnap?.modelId ?? "?";
579
- const modelStr = modelSnap?.provider
580
- ? `${modelName}·${modelSnap.provider}`
581
- : modelName;
582
- // Since-last-compact (ms; null until first compaction this session).
583
- const sinceCompact = this.rt.lastCompactAt != null
584
- ? Date.now() - this.rt.lastCompactAt
585
- : null;
586
- // Memory store: embedder + compression ratio (original / stored).
587
- const embedderName = this.embedderName();
588
- const compRatio = st.originalTokens > 0 && st.totalTokenEstimate > 0
589
- ? st.originalTokens / st.totalTokenEstimate
590
- : st.originalTokens > 0
591
- ? 1
592
- : 0;
593
- const compStr = compRatio >= 1 ? `${compRatio.toFixed(1)}x` : "—";
594
- // Cross-repo drift status (cached, read-only).
595
- const driftStatus = this.driftStatus();
596
- const agentsActive = this.activeAgents > 0;
597
- // S31: game-mode state for the widget (theme/mode/level + MEGA CACHE).
598
- // Pulled from the cached game_state row; cachePct is the REAL dedup hit
599
- // rate (may exceed 100% — that's the MEGA CACHE trigger). megaCacheFlare
600
- // is false for now (S33.4 scoring hook arms it when cachePct > 100).
601
- const gs = this.getCachedGameState();
602
- // S34: derive the level-up flare from the turn count each snapshot.
603
- const curLevel = this.getTurnLevel();
604
- if (curLevel > this.lastLevel) {
605
- this.levelUpFlare = true;
606
- // v0.8.3: arm a pulse border effect to celebrate the level-up.
607
- this.setEffect("pulse", "accent", 1500);
608
- }
609
- const cachePct = st.dedupHitRate * 100;
610
- this.widgetData = {
611
- version: ownVersion(),
612
- tierLabel,
613
- triggerLabel,
614
- pctStr,
615
- tokStr,
616
- maxStr,
617
- ctxPct,
618
- chk: st.checkpointCount,
619
- agentStr,
620
- turnStr,
621
- dedupStr,
622
- sessIn,
623
- sessKept,
624
- sTxt,
625
- repoIn,
626
- repoKept,
627
- rTxt,
628
- repoChk: repo.checkpointCount,
629
- repoSess: repo.sessionCount,
630
- modelStr,
631
- sinceCompact,
632
- embedderName,
633
- compStr,
634
- driftStatus,
635
- agentsActive,
636
- fresh: Date.now() - this.lastActivityAt < 4000,
637
- ticker: this.ticker,
638
- lastWhy: this.lastWhy,
639
- tierTrace: this.tierTrace,
640
- pulsing: this.pulsing,
641
- // S31 game-mode fields:
642
- gameMode: gs.game_mode_on,
643
- theme: getTheme(gs.theme) ? gs.theme : "transparent",
644
- tuiMode: gs.tui_display_mode,
645
- level: this.getTurnLevel(),
646
- cachePct,
647
- megaCacheFlare: this.megaCacheFlare,
648
- megaCacheFlarePct: this.megaCacheFlarePct,
649
- levelUpFlare: this.levelUpFlare,
650
- achievementFlare: this.achievementFlare,
651
- achievementFlareTitles: this.achievementFlareTitles,
652
- // v0.8.3: ambient border effect — threaded live so the widget can
653
- // compute the per-frame phase and render animated borders.
654
- activeEffect: this.activeEffect,
655
- };
656
- // S33: consume the flare after copying it into widgetData so it fires
657
- // for exactly one render cycle (the gag flares once, then clears).
658
- this.megaCacheFlare = false;
659
- this.megaCacheFlarePct = 0;
660
- // S34: consume the level-up flare after one render cycle (mirrors the
661
- // megaCacheFlare one-shot semantics), and advance lastLevel.
662
- this.levelUpFlare = false;
663
- this.lastLevel = curLevel;
664
- // S35: consume the achievement-unlock flare after one render cycle
665
- // (mirrors the megaCacheFlare/levelUpFlare one-shot semantics).
666
- this.achievementFlare = false;
667
- this.achievementFlareTitles = [];
668
- // v0.8.3: expire the ambient border effect once its time window has
669
- // elapsed. SEPARATE from the one-shot flares above (those are per-cycle
670
- // consumes; activeEffect is time-windowed and cleared when Date.now()
671
- // crosses startedAt + durationMs). The widget also defends this per-frame
672
- // (effectBorderSgr returns '' once expired), so this is bookkeeping to
673
- // free the slot and prevent a stale effect lingering between snapshots.
674
- if (this.activeEffect &&
675
- Date.now() - this.activeEffect.startedAt >=
676
- this.activeEffect.durationMs) {
677
- this.activeEffect = null;
678
- }
679
- // Auto-fit: register a factory so pi re-renders the panel at the REAL
680
- // terminal width every frame (tui.columns), instead of guessing with
681
- // process.stdout.columns. buildWidgetLines reads this.widgetData live.
682
- this.renderWidget(ctx);
683
- }
684
- // v0.8.5: record the material-change signature computed at the top so the
685
- // next snapshot() can skip this whole body when nothing material changed.
686
- try {
687
- recordPerfSample(this.currentStateDir, "db_recompute_ms", performance.now() - perfT0);
688
- recordPerfSample(this.currentStateDir, "disk_write_ms", perfDiskMs);
689
- }
690
- catch {
691
- /* non-fatal: perf instrumentation never blocks the agent */
692
- }
693
- this.lastSnapshotSig = sig;
694
- }
695
- /** Register the above-editor widget as a width-aware factory so pi re-renders
696
- * it at the REAL terminal width every frame (auto-fit wide/narrow). The
697
- * factory returns a minimal Component whose render() reads this.widgetData.
698
- */
699
- renderWidget(ctx) {
700
- ctx.ui.setWidget(WIDGET_KEY, (_tui, _theme) => ({
701
- render: (width) => buildWidgetLines(this.widgetData, width > 0 ? width : 200, this.activeAgents),
702
- invalidate: () => { },
703
- }), { placement: "aboveEditor" });
704
- }
705
- /** v0.8.5: cheap material-change signature over live runtime fields (no
706
- * SQLite). Two snapshots with the same signature produce identical
707
- * dashboard.json + widgetData, so the 6 synchronous SQLite opens + the
708
- * writeFileSync(dashboard.json) can be skipped. Built from in-memory state
709
- * only; gameStateBump covers cross-process game_state edits (fs.watch) +
710
- * in-process /mega-game writes (bumpGameState) + repo switches (bindRepo).
711
- * The transient flare flags are included so a one-shot flare forces the
712
- * recompute that renders (then clears) it for exactly one cycle. */
713
- materialSig() {
714
- const rt = this.rt;
715
- const ae = this.activeEffect;
716
- return JSON.stringify([
717
- this.lastCtxTokens, this.lastCtxPercent, this.lastCtxWindow,
718
- this.activeAgents, this.currentTurn,
719
- rt.compactCount, rt.tokensSaved, rt.dedupSkips, rt.dedupAttempts,
720
- rt.recallInjections, rt.cacheHitTokens, rt.persistedThisSession,
721
- rt.lastCheckpointId ?? null, rt.lastCompactedFrom, rt.lastCompactedTokens,
722
- this.statusKey ?? null,
723
- this.currentModel?.modelId ?? null, this.currentModel?.provider ?? null,
724
- ae ? `${ae.type}:${ae.role}:${ae.startedAt}` : null,
725
- this.gameStateBump,
726
- this.megaCacheFlare, this.megaCacheFlarePct,
727
- this.levelUpFlare, this.achievementFlare,
728
- this.achievementFlareTitles.join("|"),
729
- this.tierTrace ?? null, this.lastWhy ?? null, this.pulsing,
730
- this.ticker.length,
731
- ]);
732
- }
733
- /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
734
- embedderName() {
735
- // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
736
- // the embedder factory uses so the label matches what's actually running.
737
- return process.env.MEGACOMPACT_MINILM === "true" ||
738
- process.env.MEGACOMPACT_MINILM === "1"
739
- ? "MiniLM"
740
- : "Trigram";
741
- }
742
- /** Cross-repo drift status (ok | warn), cached for 30s (opens the registry DB). */
743
- driftStatus() {
744
- const now = Date.now();
745
- if (this.driftCache && now - this.driftCache.at < 30_000)
746
- return this.driftCache.status;
747
- let status = "ok";
748
- try {
749
- const report = detectCrossRepoDrift();
750
- status = report.totals.warn > 0 ? "warn" : "ok";
751
- }
752
- catch {
753
- status = "ok";
754
- }
755
- this.driftCache = { at: now, status };
756
- return status;
757
- }
758
- setStatus(ctx, text) {
759
- this.statusKey = text;
760
- ctx.ui.setStatus(STATUS_KEY, text);
761
- }
762
- resetRuntime(sessionId) {
763
- const sid = normalizeSessionId(sessionId);
764
- if (this.rt.sessionId === sid && this.rt.persistedThisSession)
765
- return; // same session, keep checkpoint memory
766
- this.rt = {
767
- sessionId: sid,
768
- persistedThisSession: false,
769
- lastCheckpointId: undefined,
770
- lastCompactedFrom: 0,
771
- lastCompactedTokens: 0,
772
- dedupSkips: 0,
773
- dedupAttempts: 0,
774
- tokensSaved: 0,
775
- lastCompactAt: null,
776
- lastNativeCompactAt: null,
777
- compactCount: 0,
778
- recallInjections: 0,
779
- cacheHitTokens: 0,
780
- lengthStopPending: false,
781
- errorRetryCount: 0,
782
- errorRetryUntil: 0,
783
- consecutiveErrors: 0,
784
- };
785
- this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
786
- this.statusKey = undefined;
787
- this.activeAgents = 0;
788
- this.currentTurn = 0;
789
- this.lastActivityAt = 0;
790
- this.tierTrace = undefined;
791
- this.ticker.length = 0;
792
- this.pulsing = false;
793
- this.savedGoal = 50_000;
794
- this.lastWhy = undefined;
795
- // S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
796
- // that re-binds the repo, so drop the memo too. Cheap; the next
797
- // getCachedGameState() re-queries lazily.
798
- this.cachedGameState = undefined;
799
- }
800
- /**
801
- * Capture the active model/provider from ctx.model and persist it so cost
802
- * estimation + the dashboard can read real pricing. Cheap + idempotent-ish:
803
- * only writes a new row when the model id changes (models change rarely).
804
- */
805
- captureModel(ctx) {
806
- const m = ctx.model;
807
- if (!m) {
808
- this.appendEvent("captureModel:no-model", { cwd: ctx.cwd });
809
- return;
810
- }
811
- if (this.currentModel &&
812
- this.currentModel.modelId === m.id &&
813
- this.currentModel.provider === m.provider)
814
- return;
815
- let providerName = null;
816
- try {
817
- providerName =
818
- ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null;
819
- }
820
- catch {
821
- /* optional */
822
- }
823
- const snap = {
824
- provider: m.provider,
825
- providerName,
826
- modelId: m.id,
827
- modelName: m.name ?? null,
828
- inputRate: m.cost?.input ?? 0,
829
- outputRate: m.cost?.output ?? 0,
830
- contextWindow: m.contextWindow ?? 0,
831
- maxTokens: m.maxTokens ?? 0,
832
- reasoning: !!m.reasoning,
833
- };
834
- this.currentModel = { ...snap, capturedAt: Date.now() };
835
- this.diagCaptureModelCalls++;
836
- const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
837
- // S26: previously a single silent `catch {}` hid every capture failure, so
838
- // model_snapshots stayed empty and the cost card read $0.00 with zero signal.
839
- // Split per-write + append to events.log (always-on, dashboard live-streams
840
- // it) + bump a DIAG counter so a live capture surfaces the root cause.
841
- try {
842
- recordModelSnapshot(repo, snap, this.currentStateDir);
843
- this.appendEvent("captureModel:recorded", {
844
- repo,
845
- modelId: snap.modelId,
846
- provider: snap.provider,
847
- inputRate: snap.inputRate,
848
- outputRate: snap.outputRate,
849
- });
850
- }
851
- catch (e) {
852
- this.diagCaptureModelFails++;
853
- this.appendEvent("captureModel:record-failed", {
854
- repo,
855
- modelId: snap.modelId,
856
- error: e instanceof Error ? e.message : String(e),
857
- stack: e instanceof Error ? e.stack : undefined,
858
- });
859
- }
860
- try {
861
- // Denormalize the active model into the machine-wide index so the
862
- // All-repos dashboard table can show provider/model per repo without
863
- // opening every repo's DB. Best-effort + non-fatal.
864
- recordRepoModel(repo, {
865
- provider: snap.provider,
866
- providerName: snap.providerName,
867
- modelName: snap.modelName,
868
- inputRate: snap.inputRate,
869
- outputRate: snap.outputRate,
870
- stateDir: this.currentStateDir,
871
- displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
872
- });
873
- }
874
- catch (e) {
875
- this.appendEvent("captureModel:index-record-failed", {
876
- repo,
877
- modelId: snap.modelId,
878
- error: e instanceof Error ? e.message : String(e),
879
- });
880
- }
881
- }
882
- /**
883
- * Append a structured line to the repo's events.log — the always-on
884
- * diagnostics sink the dashboard live-streams. Unlike this.logger (gated by
885
- * config.debug), this fires in production, so capture failures surface during
886
- * a real capture even with debugging off. Best-effort + non-fatal.
887
- */
888
- appendEvent(event, fields) {
889
- try {
890
- mkdirSync(this.currentStateDir, { recursive: true });
891
- appendFileSync(join(this.currentStateDir, "events.log"), JSON.stringify({ ts: Date.now(), event, ...fields }) + "\n");
892
- }
893
- catch {
894
- /* non-fatal */
895
- }
896
- }
897
- /** S21: state dir of the currently bound repo (where memories live). */
898
- getStateDir() {
899
- return this.currentStateDir;
900
- }
901
- /** S32: (re)target the fs.watch cache-eviction watcher at the current
902
- * stateDir's sqlite.db. Called from the constructor + every bindRepo repo
903
- * switch so the watcher always tracks the NEW repo's db file. If a watcher
904
- * already exists for this dir, no-op; if the dir changed, close the old one
905
- * first. fs.watch can throw on a missing file / platform issues — wrapped
906
- * non-fatal; the next getCachedGameState() re-queries the DB anyway. */
907
- ensureGameStateWatcher() {
908
- if (this.gameStateWatcher && this.gameStateWatchDir === this.currentStateDir) {
909
- return;
910
- }
911
- if (this.gameStateWatcher) {
912
- try {
913
- this.gameStateWatcher.close();
914
- }
915
- catch { /* non-fatal */ }
916
- this.gameStateWatcher = undefined;
917
- this.gameStateWatchDir = undefined;
918
- }
919
- try {
920
- // Watch the state DIR (not just sqlite.db) and filter by filename.
921
- // Why: the store is WAL-mode (openStore sets PRAGMA journal_mode=WAL).
922
- // Cross-process writes (dashboard server child) append to sqlite.db-wal
923
- // and do NOT modify sqlite.db until a checkpoint — and a long-lived
924
- // parent connection (VectorStore + dashboard readers) keeps the WAL
925
- // uncheckpointed, so a watcher on sqlite.db alone never fires and
926
- // cachedGameState stays stale (theme stuck after a dashboard edit).
927
- // Watching the dir + matching sqlite.db* catches the main db, the -wal
928
- // sidecar, and -shm, so the memo evicts on any cross-process write. The
929
- // filter also excludes events.log / *.log noise in the same dir.
930
- this.gameStateWatcher = watch(this.currentStateDir, (_eventType, filename) => {
931
- if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
932
- this.cachedGameState = undefined;
933
- this.gameStateBump++;
934
- // P2: force a widget re-render so a dashboard-made theme/toggle/
935
- // tui-mode change reflects in the live TUI immediately, even when
936
- // pi is idle (no context event to drive snapshot()). Use the
937
- // LIGHTWEIGHT refreshWidgetGameState() — NOT the full snapshot():
938
- // snapshot() recomputes 6 sync SQLite opens + writes dashboard.json
939
- // + writes to the store, and those store writes RETRIGGER this
940
- // same fs.watch callback (it fires on every sqlite.db* write) →
941
- // re-entrant thrash → 190s test timeout under mega-compact.test.js
942
- // / mega-teamrun.test.js. The lightweight path re-reads ONLY the
943
- // game_state row and patches the three game-mode fields on the
944
- // existing widgetData, then re-registers the factory via
945
- // renderWidget() — it writes nothing to the store or
946
- // dashboard.json, so it cannot retrigger itself. Guard: skip until
947
- // the first snapshot stashed a ctx (no widget registered yet →
948
- // nothing to refresh). Non-fatal: next context event re-snapshots.
949
- const ctx = this.lastWidgetCtx;
950
- if (ctx) {
951
- try {
952
- this.refreshWidgetGameState(ctx);
953
- }
954
- catch {
955
- /* non-fatal */
956
- }
957
- }
958
- }
959
- });
960
- this.gameStateWatchDir = this.currentStateDir;
961
- }
962
- catch {
963
- /* non-fatal: missing dir / platform issue — next snapshot re-queries */
964
- }
965
- }
966
- /** S32: release the fs.watch game-state watcher. Called when the runtime is
967
- * torn down (no existing dispose path — the process exit reclaims the fd,
968
- * but explicit close is correct for any in-process reload / test reuse). */
969
- dispose() {
970
- if (this.gameStateWatcher) {
971
- try {
972
- this.gameStateWatcher.close();
973
- }
974
- catch { /* non-fatal */ }
975
- this.gameStateWatcher = undefined;
976
- this.gameStateWatchDir = undefined;
977
- }
978
- // v0.8.8: stop the cpu/mem sampling interval on teardown. Re-armed lazily
979
- // by ensurePerfInterval() on the next turn_start.
980
- if (this.perfCpuInterval) {
981
- clearInterval(this.perfCpuInterval);
982
- this.perfCpuInterval = undefined;
983
- this.perfCpuBaseline = undefined;
984
- }
985
- }
986
- /** v0.8.8: (re)start the 5s cpu/mem sampling interval (idempotent). One per
987
- * MegaRuntime; cleared in dispose(). Samples process.cpuUsage() (user/sys
988
- * delta vs the last tick → ms) + process.memoryUsage() (rss/heap → MB) and
989
- * records them as perf_samples. unref'd so it never keeps the process alive
990
- * on its own. Non-fatal: any failure is swallowed (instrumentation never
991
- * blocks the agent). PREVENT-PI-004: local process stats + SQLite only. */
992
- ensurePerfInterval() {
993
- if (this.perfCpuInterval)
994
- return;
995
- this.perfCpuBaseline = undefined; // first tick sets the baseline (no delta)
996
- this.perfCpuInterval = setInterval(() => {
997
- try {
998
- const dir = this.currentStateDir;
999
- const cpu = process.cpuUsage();
1000
- const mem = process.memoryUsage();
1001
- if (this.perfCpuBaseline) {
1002
- const du = (cpu.user - this.perfCpuBaseline.user) / 1000; // μs → ms
1003
- const ds = (cpu.system - this.perfCpuBaseline.sys) / 1000;
1004
- recordPerfSample(dir, "cpu_user_ms", Math.max(0, du));
1005
- recordPerfSample(dir, "cpu_sys_ms", Math.max(0, ds));
1006
- }
1007
- this.perfCpuBaseline = { user: cpu.user, sys: cpu.system };
1008
- recordPerfSample(dir, "rss_mb", mem.rss / 1_000_000);
1009
- recordPerfSample(dir, "heap_mb", mem.heapUsed / 1_000_000);
1010
- }
1011
- catch {
1012
- /* non-fatal */
1013
- }
1014
- }, 5000);
1015
- this.perfCpuInterval.unref?.();
1016
- }
1017
- /** S31: the cached game-mode state (game_mode_on/theme/tui_display_mode).
1018
- * Lazily read from the game_state SQLite row on the first call, then
1019
- * memoized until `bumpGameState()` evicts it. Reading is non-throwing
1020
- * (getGameState returns DEFAULT_GAME_STATE on any error), so the widget
1021
- * can call this on every render safely. */
1022
- getCachedGameState() {
1023
- if (!this.cachedGameState) {
1024
- try {
1025
- this.cachedGameState = getGameState(this.currentStateDir);
1026
- }
1027
- catch {
1028
- this.cachedGameState = {
1029
- game_mode_on: false,
1030
- theme: "transparent",
1031
- tui_display_mode: "full",
1032
- };
1033
- }
1034
- }
1035
- return this.cachedGameState;
1036
- }
1037
- /** P2 cross-process re-render: lightweight game-state refresh for the
1038
- * fs.watch callback. Eviction of cachedGameState + gameStateBump++ happens
1039
- * in the caller BEFORE this runs. Here we re-read ONLY the game_state row
1040
- * via getCachedGameState() (one SELECT; the cache is already evicted) and
1041
- * patch ONLY the three game-mode fields on the EXISTING widgetData, then
1042
- * re-register the widget factory via renderWidget() so pi redraws next
1043
- * frame.
1044
- *
1045
- * WHY a lightweight path: the full snapshot(ctx) recomputes 6 synchronous
1046
- * SQLite opens + writeFileSync(dashboard.json) + store writes, and those
1047
- * store writes RETRIGGER this same fs.watch callback → re-entrant thrash
1048
- * (the watcher fires on every sqlite.db* write, including context-event
1049
- * checkpoint writes) → 190s test timeout under mega-compact.test.js /
1050
- * mega-teamrun.test.js. This path writes NOTHING to the store or
1051
- * dashboard.json, so it cannot retrigger itself.
1052
- *
1053
- * Guard: no-op when widgetData is null (no snapshot has run yet → nothing
1054
- * to patch) or ctx is undefined. Field values mirror snapshot() exactly. */
1055
- refreshWidgetGameState(ctx) {
1056
- if (!this.widgetData || !ctx)
1057
- return;
1058
- const gs = this.getCachedGameState();
1059
- this.widgetData.gameMode = gs.game_mode_on;
1060
- this.widgetData.theme = getTheme(gs.theme) ? gs.theme : "transparent";
1061
- this.widgetData.tuiMode = gs.tui_display_mode;
1062
- this.renderWidget(ctx);
1063
- }
1064
- /** S31: evict the cached game-mode state so the next widget render re-reads
1065
- * the game_state row. Called by /mega-game after every setGameState() so
1066
- * the panel picks up theme/mode/toggle changes live. */
1067
- bumpGameState() {
1068
- this.cachedGameState = undefined;
1069
- this.gameStateBump++;
1070
- }
1071
- /** S33: player level for game mode — floor(log2(turns+1))+1 (gentle).
1072
- * Defensive: non-finite/negative collapses to 1 (never NaN). */
1073
- getTurnLevel() {
1074
- return turnLevel(this.currentTurn);
1075
- }
1076
- /** S33: arm the transient MEGA CACHE flare so the next snapshot() copies it
1077
- * into widgetData and the widget renders the oopsie gag for one cycle.
1078
- * v0.8.3: also arm a 'flash' ambient effect on the panel borders (mega
1079
- * color) for 1.2s. */
1080
- armMegaCacheFlare(peakPct) {
1081
- this.megaCacheFlare = true;
1082
- this.megaCacheFlarePct = peakPct;
1083
- this.setEffect("flash", "mega", 1200);
1084
- }
1085
- /** S35: arm the transient achievement-unlock flare with the newly-unlocked
1086
- * titles so the next snapshot() copies them into widgetData and the widget
1087
- * renders the one-time unlock toast for one render cycle.
1088
- * v0.8.3: also arm a 'pulse' ambient effect on the panel borders (accent
1089
- * color) for 2s to celebrate the unlock. */
1090
- armAchievementFlare(titles) {
1091
- this.achievementFlare = true;
1092
- this.achievementFlareTitles = titles;
1093
- this.setEffect("pulse", "accent", 2000);
1094
- }
1095
- /** v0.8.3: arm an ambient border effect (animated pulse/flash on the panel
1096
- * borders). Replaces any in-flight effect (last call wins — a later event
1097
- * like a level-up during an achievement pulse simply overrides). The widget
1098
- * reads activeEffect each frame and computes the per-frame phase from
1099
- * startedAt vs Date.now(); it renders '' once the window elapses. */
1100
- setEffect(type, role, durationMs) {
1101
- this.activeEffect = { type, role, startedAt: Date.now(), durationMs };
1102
- }
1103
- /** Build the sync onTier callback that paints the live per-tier trace. */
1104
- makeTierCallback(ctx) {
1105
- const order = ["L0", "L1", "L2", "new"];
1106
- const seen = new Map();
1107
- const glyph = (status) => status === "deduped"
1108
- ? `${C.green}✓${C.reset}`
1109
- : status === "passed"
1110
- ? `${C.dim}○${C.reset}`
1111
- : status === "scanning"
1112
- ? `${C.amber}…${C.reset}`
1113
- : `${C.cyan}●${C.reset}`;
1114
- return (ev) => {
1115
- const label = ev.tier === "new"
1116
- ? `${C.cyan}stored${C.reset}`
1117
- : `${ev.tier} ${glyph(ev.status)}` +
1118
- (ev.detail ? ` ${C.gray}(${ev.detail})${C.reset}` : "");
1119
- // Show the most recent outcome per tier (collapses re-fires).
1120
- seen.set(ev.tier, label);
1121
- const show = [];
1122
- for (const t of order)
1123
- if (seen.has(t))
1124
- show.push(seen.get(t));
1125
- this.tierTrace = `${C.teal}⚙${C.reset} ${show.join(` ${C.gray}→${C.reset} `)}`;
1126
- this.lastActivityAt = Date.now();
1127
- try {
1128
- this.snapshot(ctx);
1129
- }
1130
- catch {
1131
- /* non-fatal */
1132
- }
1133
- };
1134
- }
1135
- // Phase 3 — recall/activity ticker ring buffer.
1136
- pushTicker(text) {
1137
- // P1: dedupe consecutive identical entries — skip the append when the
1138
- // last entry's text matches, so a re-fired compact/recall/dedup event
1139
- // doesn't flood the ring (keeps it at TICKER_MAX for real variety).
1140
- // `at` is NOT refreshed on a skip (the original event time stands).
1141
- if (this.ticker[this.ticker.length - 1]?.text === text) {
1142
- this.lastActivityAt = Date.now();
1143
- return;
1144
- }
1145
- this.ticker.push({ text, at: Date.now() });
1146
- while (this.ticker.length > this.TICKER_MAX)
1147
- this.ticker.shift();
1148
- this.lastActivityAt = Date.now();
1149
- }
1150
- /** Convert the messages pi hands us in the `context` event into the engine view. */
1151
- engineView(messages) {
1152
- return toEngineMessages(messages);
1153
- }
1154
- }
8
+ export { MegaRuntime } from "./runtime.js";