pi-mega-compact 0.6.9 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,13 +11,14 @@
11
11
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
12
12
  import { join, dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
- import { readFileSync } from "node:fs";
14
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
15
15
  import { VectorStore } from "../src/vectorStore.js";
16
16
  import { toEngineMessages } from "../src/adapt.js";
17
17
  import { normalizeSessionId } from "../src/store.js";
18
18
  import { Logger } from "../src/log.js";
19
- import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel } from "../src/store/sqlite.js";
20
- import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand } from "./mega-config.js";
19
+ import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, } from "../src/store/sqlite.js";
20
+ import { detectCrossRepoDrift } from "../src/driftDetection.js";
21
+ import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens, } from "./mega-config.js";
21
22
  import { Dashboard } from "./mega-dashboard.js";
22
23
  export const STATUS_KEY = "mega-compact";
23
24
  export const WIDGET_KEY = "mega-compact-stats";
@@ -56,6 +57,93 @@ export const C = {
56
57
  red: "\x1b[38;5;203m", // pressure / overflow
57
58
  };
58
59
  const PULSE = ["◐", "◓", "◑", "◒"];
60
+ // ── Full-width widget panel helpers ────────────────────────────────────────
61
+ // pi's above-editor widget renderer (a Container of Text lines) does NOT pass
62
+ // a terminal width to setWidget(), so lines render left-aligned by default. To
63
+ // make the widget read as a full-width status panel we pad each line to the
64
+ // real terminal width with a background fill. NOTE: C.reset is a FULL SGR
65
+ // reset, so we re-apply the panel bg after every reset to keep the background
66
+ // continuous under colored text (and under pi's own trailing reset).
67
+ const PANEL_BG = "\x1b[48;5;236m"; // dark slate panel background
68
+ const PANEL_RST = "\x1b[0m" + PANEL_BG; // reset fg but retain panel bg
69
+ /** Visible cell width of a string, ignoring ANSI SGR/OSC escapes. */
70
+ function visibleWidth(s) {
71
+ const stripped = s
72
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")
73
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
74
+ let w = 0;
75
+ for (const ch of stripped) {
76
+ const cp = ch.codePointAt(0) ?? 0;
77
+ const wide = cp >= 0x1100 &&
78
+ (cp <= 0x115f ||
79
+ (cp >= 0x2e80 && cp <= 0x303e) ||
80
+ (cp >= 0x3041 && cp <= 0x33ff) ||
81
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
82
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
83
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
84
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
85
+ (cp >= 0xf900 && cp <= 0xfaff) ||
86
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
87
+ (cp >= 0xff00 && cp <= 0xff60) ||
88
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
89
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
90
+ (cp >= 0x20000 && cp <= 0x3fffd));
91
+ w += wide ? 2 : 1;
92
+ }
93
+ return w;
94
+ }
95
+ /** Pad a content string (with ANSI colors) to `width` cells using panel bg. */
96
+ function panelLine(content, width) {
97
+ const withBg = PANEL_BG + content.replace(/\x1b\[0m/g, PANEL_RST);
98
+ const pad = Math.max(0, width - visibleWidth(withBg));
99
+ return withBg + " ".repeat(pad) + "\x1b[0m";
100
+ }
101
+ /** A full-width hairline bar (top/bottom border of the panel). */
102
+ function panelBar(width, ch = "─") {
103
+ return PANEL_BG + ch.repeat(Math.max(0, width)) + "\x1b[0m";
104
+ }
105
+ /** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
106
+ * 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
107
+ function fmtTokens(x) {
108
+ return x >= 1_000_000
109
+ ? `${(x / 1_000_000).toFixed(1)}mil`
110
+ : x >= 1000
111
+ ? `${(x / 1000).toFixed(1)}k`
112
+ : `${Math.round(x)}`;
113
+ }
114
+ /** Retro gradient bar — `w` cells shaded by fill position (green→amber→red).
115
+ * Used for CONTEXT fill where low=green (room) and high=red (near the limit). */
116
+ function ramp(pct, w = 12) {
117
+ const cells = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
118
+ const scaled = Math.max(0, Math.min(w, pct * w));
119
+ const full = Math.floor(scaled);
120
+ const frac = scaled - full;
121
+ const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
122
+ let out = "";
123
+ for (let i = 0; i < full; i++)
124
+ out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
125
+ if (fracCell)
126
+ out +=
127
+ (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
128
+ out +=
129
+ C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
130
+ return out;
131
+ }
132
+ /** Human "time since" string from a millisecond delta (or null → "never"). */
133
+ function sinceCompactStr(ms) {
134
+ if (ms == null)
135
+ return "never";
136
+ const s = Math.floor(ms / 1000);
137
+ if (s < 60)
138
+ return `${s}s ago`;
139
+ const m = Math.floor(s / 60);
140
+ if (m < 60)
141
+ return `${m}m ago`;
142
+ const h = Math.floor(m / 60);
143
+ if (h < 24)
144
+ return `${h}h ago`;
145
+ return `${Math.floor(h / 24)}d ago`;
146
+ }
59
147
  export class MegaRuntime {
60
148
  config;
61
149
  // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
@@ -75,6 +163,7 @@ export class MegaRuntime {
75
163
  dedupSkips: 0,
76
164
  dedupAttempts: 0,
77
165
  tokensSaved: 0,
166
+ lastCompactAt: null,
78
167
  };
79
168
  debounceUntil = 0;
80
169
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -121,6 +210,11 @@ export class MegaRuntime {
121
210
  lastCtxTokens = null;
122
211
  lastCtxPercent = null;
123
212
  lastCtxWindow = 0;
213
+ // Latest computed widget payload (recomputed per snapshot, rendered per frame).
214
+ widgetData = null;
215
+ // Cached cross-repo drift status (recomputed at most every 30s — it opens the
216
+ // machine-wide registry DB, so we don't want to do it on every render frame).
217
+ driftCache = null;
124
218
  /**
125
219
  * DIAG counters for the "team run doesn't relieve context" investigation.
126
220
  * Plain integers, incremented at the three compaction decision points. They
@@ -142,27 +236,73 @@ export class MegaRuntime {
142
236
  diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
143
237
  diagCtxThrown = 0; // live-trim try threw (caught)
144
238
  /**
145
- * Live 0–1 pressure: how full the context window is relative to the compaction
146
- * threshold. Computed from the most recent context event the runtime already
147
- * tracks (token count when available the direct signal otherwise the usage
148
- * percentage). This is the single "how full" number every subsystem reads; the
149
- * toolbar/dashboard tier label is `pressureBand` over this, so it climbs
150
- * low→mega as context rises (S24). Always finite + in [0,1].
239
+ * S26 capture instrumentation: the "model_snapshots empty $0.00 cost card"
240
+ * bug was invisible because captureModel swallowed the DB write in a silent
241
+ * `catch {}`. These always-updated counters (zero cost) let a headless test or
242
+ * a live capture tell whether captureModel ran and whether the snapshot landed.
243
+ */
244
+ diagCaptureModelCalls = 0; // captureModel entered with a populated ctx.model
245
+ diagCaptureModelFails = 0; // recordModelSnapshot threw → model_snapshots stays empty
246
+ /**
247
+ * Live 0–1 pressure — how full the context window is relative to the
248
+ * compaction threshold.
249
+ *
250
+ * RECONCILE (BACKLOG dual-basis flicker): when the model context window is
251
+ * known we base pressure consistently on the *percentage* basis
252
+ * (`lastCtxPercent / (tierPct*100)`). This keeps the band stable whether the
253
+ * latest context event carried a token count or only a percentage, so the
254
+ * threshold comparison doesn't jump when a token-count event arrives vs a
255
+ * percent-only event. We only fall back to the token-count basis
256
+ * (`config.thresholdTokens`) when the window is unknown (e.g. before the first
257
+ * context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
151
258
  */
152
259
  get pressure() {
153
- if (this.lastCtxTokens != null && this.lastCtxTokens > 0 && this.config.thresholdTokens > 0) {
260
+ if (this.lastCtxWindow > 0 &&
261
+ this.config.tierPct != null &&
262
+ this.lastCtxPercent != null) {
263
+ // pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
264
+ // exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
265
+ // fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
266
+ // token-based pressureRatio(currentTokens, effectiveThreshold) reading so
267
+ // the band doesn't jump when a token-count vs percent-only event arrives.
268
+ return pressureFromPct(this.lastCtxPercent / this.config.tierPct);
269
+ }
270
+ if (this.lastCtxTokens != null &&
271
+ this.lastCtxTokens > 0 &&
272
+ this.config.thresholdTokens > 0) {
154
273
  return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
155
274
  }
156
275
  return pressureFromPct(this.lastCtxPercent);
157
276
  }
277
+ /**
278
+ * The live compaction FIRE POINT in tokens: the effective threshold scaled by
279
+ * the current model context window (`tierPct * window`) when known, else the
280
+ * boot fallback `config.thresholdTokens`. This is what the FAST GATE /
281
+ * `autoCompactCheck` / agent_end durable-trigger compare against, so
282
+ * compaction fires at tier% of the window for ANY model size (200k or 1M),
283
+ * always below pi's native auto-compaction (~80% of window).
284
+ */
285
+ get effectiveThreshold() {
286
+ return effectiveThresholdTokens({
287
+ tierPct: this.config.tierPct,
288
+ fallbackThreshold: this.config.thresholdTokens,
289
+ window: this.lastCtxWindow,
290
+ });
291
+ }
158
292
  /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
159
293
  get pressureBand() {
160
294
  return pressureBand(this.pressure);
161
295
  }
162
296
  constructor(config) {
163
297
  this.config = config;
164
- this.store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
165
- this.logger = new Logger({ enabled: config.debug, path: join(config.stateDir, "mega-compact.log") });
298
+ this.store = new VectorStore({
299
+ dedupSim: config.dedupSim,
300
+ stateDir: config.stateDir,
301
+ });
302
+ this.logger = new Logger({
303
+ enabled: config.debug,
304
+ path: join(config.stateDir, "mega-compact.log"),
305
+ });
166
306
  this.dashboard = new Dashboard(config.stateDir);
167
307
  this.currentStateDir = config.stateDir;
168
308
  }
@@ -173,14 +313,22 @@ export class MegaRuntime {
173
313
  * and events are fully isolated. Falls back to the global default outside git.
174
314
  */
175
315
  bindRepo(cwd) {
176
- const dir = cwd ? repoStateDir(cwd, this.config.stateDir) : this.config.stateDir;
177
- const key = cwd ? resolveRepoRoot(cwd) ?? dir : dir;
316
+ const dir = cwd
317
+ ? repoStateDir(cwd, this.config.stateDir)
318
+ : this.config.stateDir;
319
+ const key = cwd ? (resolveRepoRoot(cwd) ?? dir) : dir;
178
320
  if (key === this.activeRepoRoot)
179
321
  return dir;
180
322
  this.activeRepoRoot = key;
181
323
  this.currentStateDir = dir;
182
- this.store = new VectorStore({ dedupSim: this.config.dedupSim, stateDir: dir });
183
- this.logger = new Logger({ enabled: this.config.debug, path: join(dir, "mega-compact.log") });
324
+ this.store = new VectorStore({
325
+ dedupSim: this.config.dedupSim,
326
+ stateDir: dir,
327
+ });
328
+ this.logger = new Logger({
329
+ enabled: this.config.debug,
330
+ path: join(dir, "mega-compact.log"),
331
+ });
184
332
  this.dashboard = new Dashboard(dir);
185
333
  // Aggregate this repo into the machine-wide index so the multi-repo
186
334
  // dashboard (Summary / All-repos tabs) can show it alongside every other
@@ -190,7 +338,7 @@ export class MegaRuntime {
190
338
  try {
191
339
  const repo = this.store.repoStats();
192
340
  const di = this.store.dataInvariant();
193
- const root = key !== dir ? key : resolveRepoRoot(cwd ?? dir) ?? dir;
341
+ const root = key !== dir ? key : (resolveRepoRoot(cwd ?? dir) ?? dir);
194
342
  upsertRepoRegistry({
195
343
  repoRoot: root,
196
344
  displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
@@ -224,8 +372,16 @@ export class MegaRuntime {
224
372
  outputRate: modelSnap.outputRate,
225
373
  }
226
374
  : undefined;
227
- const armed = this.lastCtxPercent != null && this.lastCtxPercent >= this.config.fastGatePct;
228
- const ready = armed && (this.lastCtxTokens ?? 0) >= this.config.thresholdTokens;
375
+ // effectiveThresholdPct: the live fire point as a % of the window (null for
376
+ // `custom`, which has no tierPct). Used by armed/ready + the dashboard.
377
+ const effectiveThresholdPct = this.config.tierPct != null ? this.config.tierPct * 100 : null;
378
+ // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
379
+ // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
380
+ // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
381
+ const armed = this.lastCtxPercent != null &&
382
+ this.lastCtxPercent >=
383
+ Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
384
+ const ready = armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
229
385
  this.dashboard.snapshot({
230
386
  version: 1,
231
387
  updatedAt: new Date().toISOString(),
@@ -236,7 +392,9 @@ export class MegaRuntime {
236
392
  pressure: this.pressure,
237
393
  config: {
238
394
  fastGatePct: this.config.fastGatePct,
239
- thresholdTokens: this.config.thresholdTokens,
395
+ thresholdTokens: this.effectiveThreshold,
396
+ tierPct: this.config.tierPct,
397
+ effectiveThresholdPct,
240
398
  anchorUserMessages: this.config.anchorUserMessages,
241
399
  preserveRecent: this.config.preserveRecent,
242
400
  auto: this.config.auto,
@@ -252,10 +410,32 @@ export class MegaRuntime {
252
410
  dedupSkips: this.rt.dedupSkips,
253
411
  dedupAttempts: this.rt.dedupAttempts,
254
412
  },
255
- context: { tokens: this.lastCtxTokens, percent: this.lastCtxPercent, contextWindow: this.lastCtxWindow },
256
- trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.config.thresholdTokens, fastGatePct: this.config.fastGatePct },
413
+ context: {
414
+ tokens: this.lastCtxTokens,
415
+ percent: this.lastCtxPercent,
416
+ contextWindow: this.lastCtxWindow,
417
+ },
418
+ trigger: {
419
+ armed,
420
+ ready,
421
+ currentTokens: this.lastCtxTokens,
422
+ thresholdTokens: this.effectiveThreshold,
423
+ fastGatePct: this.config.fastGatePct,
424
+ tierPct: this.config.tierPct,
425
+ effectiveThresholdPct,
426
+ },
257
427
  crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
258
- store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, originalTokens: st.originalTokens, tokensSaved: this.rt.tokensSaved, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
428
+ store: {
429
+ checkpointCount: st.checkpointCount,
430
+ totalTokenEstimate: st.totalTokenEstimate,
431
+ originalTokens: st.originalTokens,
432
+ tokensSaved: this.rt.tokensSaved,
433
+ injectedCount: st.injectedCount,
434
+ dedupHitRate: st.dedupHitRate,
435
+ storageDedupRate: st.storageDedupRate,
436
+ dedupAttempts: st.dedupAttempts,
437
+ dedupCollapsed: st.dedupCollapsed,
438
+ },
259
439
  // Reconciled token accounting (single canonical formula, session + repo).
260
440
  // Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
261
441
  // deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
@@ -264,14 +444,19 @@ export class MegaRuntime {
264
444
  tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
265
445
  tokensOut: st.totalTokenEstimate,
266
446
  tokensFreed: this.rt.tokensSaved,
267
- compressionPct: (this.rt.tokensSaved + st.totalTokenEstimate) > 0 ? this.rt.tokensSaved / (this.rt.tokensSaved + st.totalTokenEstimate) : 0,
447
+ compressionPct: this.rt.tokensSaved + st.totalTokenEstimate > 0
448
+ ? this.rt.tokensSaved /
449
+ (this.rt.tokensSaved + st.totalTokenEstimate)
450
+ : 0,
268
451
  dedupPct: st.storageDedupRate,
269
452
  },
270
453
  repo: {
271
454
  tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
272
455
  tokensOut: repo.totalTokenEstimate,
273
456
  tokensFreed: repo.tokensSaved,
274
- compressionPct: (repo.tokensSaved + repo.totalTokenEstimate) > 0 ? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate) : 0,
457
+ compressionPct: repo.tokensSaved + repo.totalTokenEstimate > 0
458
+ ? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate)
459
+ : 0,
275
460
  dedupPct: repo.storageDedupRate,
276
461
  },
277
462
  },
@@ -295,102 +480,188 @@ export class MegaRuntime {
295
480
  });
296
481
  // Live stats widget above the editor
297
482
  if (ctx) {
298
- const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
299
- const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
300
- const pctStr = this.lastCtxPercent != null ? `${Math.round(this.lastCtxPercent * 10) / 10}%` : "?%";
483
+ // ── gather widget data (computed per snapshot, rendered per frame) ────
484
+ const tokStr = this.lastCtxTokens != null
485
+ ? `${Math.round(this.lastCtxTokens / 1000)}k`
486
+ : "?";
487
+ const maxStr = this.lastCtxWindow > 0
488
+ ? `${Math.round(this.lastCtxWindow / 1000)}k`
489
+ : "?";
490
+ const pctStr = this.lastCtxPercent != null
491
+ ? `${Math.round(this.lastCtxPercent * 10) / 10}%`
492
+ : "?%";
301
493
  // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
302
- // mega), not the static env preset. It climbs as context fills, so the
303
- // user can see the system react. The base preset is shown as a dim suffix.
494
+ // mega), not the static env preset. It climbs as context fills.
304
495
  const liveBand = this.pressureBand;
305
496
  const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
306
- const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
497
+ const triggerLabel = ready
498
+ ? `${C.green}● ready${C.reset}`
499
+ : armed
500
+ ? `${C.amber}◐ armed${C.reset}`
501
+ : `${C.gray}○ idle${C.reset}`;
307
502
  // Storage dedup rate is cumulative (store-wide, per-repo) and survives
308
- // session resets. Always show a number: 0% before any compaction, a
309
- // decimal for sub-10% rates so small-but-real dedup isn't rounded away.
503
+ // session resets. Always show a number (decimal for sub-10%).
310
504
  const storageRate = st.storageDedupRate; // 0..1
311
505
  const dedupStr = storageRate * 100 >= 10
312
506
  ? `${Math.round(storageRate * 100)}%`
313
507
  : `${(storageRate * 100).toFixed(1)}%`;
314
- // Reconciled token accounting ONE canonical formula for session + repo,
315
- // matching the dashboard so the two never disagree. unit format: M at/above
316
- // 1e6, k at/above 1e3, raw below — so 5,472,700 → "5.5M", 24,100 "24.1k",
317
- // 142 → "142". Dropped (in) = Freed + Kept; Freed = rt.tokensSaved (session)
318
- // / repo.tokensSaved meta (repo); Kept = totalTokenEstimate (stored).
319
- const fmt = (x) => x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
320
- : x >= 1000 ? `${(x / 1000).toFixed(1)}k`
321
- : `${Math.round(x)}`;
322
- const agentStr = this.activeAgents > 0 ? ` │ 🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}` : "";
508
+ // Agents view: count + status (S27 per-agent tokens are gated on P0).
509
+ const agentLabel = this.activeAgents > 0
510
+ ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
511
+ : `${C.dim}🤖 idle${C.reset}`;
512
+ const agentStr = ` ${agentLabel}`;
323
513
  const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
324
- // Phase 3 pulsing status glyph while a compaction is in flight.
325
- const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
326
- // --- reconciled in/out view (session + repo) ---------------------------
514
+ // Reconciled in/out view (session + repo) ONE canonical formula.
327
515
  const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
328
516
  const sessKept = st.totalTokenEstimate;
329
- const sessFreed = this.rt.tokensSaved;
330
- const sessPct = sessIn > 0 ? sessFreed / sessIn : 0;
517
+ const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
331
518
  const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
332
519
  const repoKept = repo.totalTokenEstimate;
333
- const repoFreed = repo.tokensSaved;
334
- const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
335
- // Retro gradient bar — `w` cells, each shaded by fill position so it
336
- // reads as a smooth green→amber→red ramp. Used for CONTEXT fill where
337
- // low=green (room to spare) and high=red (near the limit) — the only
338
- // live-moving metric worth a bar. Savings ratios saturate near 100% and
339
- // are shown as explanatory numbers instead (see L2).
340
- const ramp = (pct, w = 12) => {
341
- const cells = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
342
- const scaled = Math.max(0, Math.min(w, pct * w));
343
- const full = Math.floor(scaled);
344
- const frac = scaled - full;
345
- const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
346
- let out = "";
347
- for (let i = 0; i < full; i++)
348
- out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
349
- if (fracCell)
350
- out += (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
351
- out += C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
352
- return out;
353
- };
354
- const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
520
+ const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
355
521
  const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
356
522
  const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
357
- const lines = [
358
- // L1 — header: tier + ctx-fill bar (20-cell, green=room→red=full) +
359
- // tokens + status glyph + checkpoints + agents/turn. Widened to use the
360
- // terminal width; the context bar is the only live-moving bar.
361
- ` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} ${ramp(ctxPct, 20)} ${C.bold}${pctStr}${C.reset} ${tokStr}/${maxStr} │ ${triggerLabel} │ ${st.checkpointCount} chk${agentStr}${turnStr}`,
362
- // L2 — savings EXPLAINED, not bar'd. The freed/(freed+kept) ratio
363
- // saturates near 100% once cumulative freed dwarfs live kept (4.8mil
364
- // freed vs 612 kept), so a bar is visually useless. Instead show the
365
- // compaction story: "in→kept (X% freed)" reads as "compacted N tokens
366
- // down to M, freeing X%". Plus repo-wide chk/session counts.
367
- ` ${C.magenta}dup ${dedupStr}${C.reset} ${C.gray}sess${C.reset} ${fmt(sessIn)}→${fmt(sessKept)} kept ${C.green}(${sTxt}% freed)${C.reset} · ${C.gray}all-time${C.reset} ${fmt(repoIn)}→${fmt(repoKept)} kept ${C.blue}(${rTxt}% freed)${C.reset} │ ${repo.checkpointCount} chk/${repo.sessionCount} sess`,
523
+ const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
524
+ // Model + provider (S26 capture) for the header.
525
+ const modelName = modelSnap?.modelName ?? modelSnap?.modelId ?? "?";
526
+ const modelStr = modelSnap?.provider
527
+ ? `${modelName}·${modelSnap.provider}`
528
+ : modelName;
529
+ // Since-last-compact (ms; null until first compaction this session).
530
+ const sinceCompact = this.rt.lastCompactAt != null
531
+ ? Date.now() - this.rt.lastCompactAt
532
+ : null;
533
+ // Memory store: embedder + compression ratio (original / stored).
534
+ const embedderName = this.embedderName();
535
+ const compRatio = st.originalTokens > 0 && st.totalTokenEstimate > 0
536
+ ? st.originalTokens / st.totalTokenEstimate
537
+ : st.originalTokens > 0
538
+ ? 1
539
+ : 0;
540
+ const compStr = compRatio >= 1 ? `${compRatio.toFixed(1)}x` : "—";
541
+ // Cross-repo drift status (cached, read-only).
542
+ const driftStatus = this.driftStatus();
543
+ const agentsActive = this.activeAgents > 0;
544
+ this.widgetData = {
545
+ version: ownVersion(),
546
+ tierLabel,
547
+ triggerLabel,
548
+ pctStr,
549
+ tokStr,
550
+ maxStr,
551
+ ctxPct,
552
+ chk: st.checkpointCount,
553
+ agentStr,
554
+ turnStr,
555
+ dedupStr,
556
+ sessIn,
557
+ sessKept,
558
+ sTxt,
559
+ repoIn,
560
+ repoKept,
561
+ rTxt,
562
+ repoChk: repo.checkpointCount,
563
+ repoSess: repo.sessionCount,
564
+ modelStr,
565
+ sinceCompact,
566
+ embedderName,
567
+ compStr,
568
+ driftStatus,
569
+ agentsActive,
570
+ fresh: Date.now() - this.lastActivityAt < 4000,
571
+ ticker: this.ticker,
572
+ lastWhy: this.lastWhy,
573
+ tierTrace: this.tierTrace,
574
+ pulsing: this.pulsing,
575
+ };
576
+ // Auto-fit: register a factory so pi re-renders the panel at the REAL
577
+ // terminal width every frame (tui.columns), instead of guessing with
578
+ // process.stdout.columns. buildWidgetLines reads this.widgetData live.
579
+ this.renderWidget(ctx);
580
+ }
581
+ }
582
+ /** Register the above-editor widget as a width-aware factory so pi re-renders
583
+ * it at the REAL terminal width every frame (auto-fit wide/narrow). The
584
+ * factory returns a minimal Component whose render() reads this.widgetData.
585
+ */
586
+ renderWidget(ctx) {
587
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, _theme) => ({
588
+ render: (width) => this.buildWidgetLines(width > 0 ? width : 200),
589
+ invalidate: () => { },
590
+ }), { placement: "aboveEditor" });
591
+ }
592
+ /** Build the full-width panel lines from the latest snapshot. Cheap: reads
593
+ * only this.widgetData + a couple of live counters; no DB/IO. */
594
+ buildWidgetLines(width) {
595
+ const wd = this.widgetData;
596
+ if (!wd) {
597
+ return [
598
+ panelBar(width, "─"),
599
+ panelLine(" mega-compact: warming up…", width),
600
+ panelBar(width, "─"),
368
601
  ];
369
- // Live "now processing" line + why + recent deduped/compacted events,
370
- // collapsed to ONE rotating line (fresh only). The ticker ring buffer
371
- // (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
372
- // through recent files in real time while activity fires. We rotate on a
373
- // 250ms step (same cadence as the pulse), using an event counter as the
374
- // deterministic phase so consecutive repaints advance the visible entry.
375
- const fresh = Date.now() - this.lastActivityAt < 4000;
376
- if (this.tierTrace && fresh) {
377
- lines.push(` ${pulse}${this.tierTrace}`);
378
- }
379
- else if (this.ticker.length > 0) {
380
- const step = Math.floor(Date.now() / 250);
381
- const idx = this.ticker.length - 1 - (step % this.ticker.length);
382
- const head = this.ticker[idx].text;
383
- const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
384
- const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
385
- lines.push(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`);
386
- }
387
- else if (this.pulsing) {
388
- lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
389
- }
390
- // (Accounting folded into L2's "in→kept (X% freed)" framing — freed =
391
- // in − kept is implied, and the saturated-ratio bars are gone.)
392
- ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
393
602
  }
603
+ const pulse = wd.pulsing
604
+ ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} `
605
+ : "";
606
+ const lines = [
607
+ // top border
608
+ panelBar(width, "─"),
609
+ // L1 — header: tier + ctx bar + pct/tokens + status + model + chk + agents/turn
610
+ panelLine(` ${C.amber}⚡ ${wd.tierLabel}${C.reset} v${C.bold}${wd.version}${C.reset} ${ramp(wd.ctxPct, 20)} ${C.bold}${wd.pctStr}${C.reset} ${wd.tokStr}/${wd.maxStr} │ ${wd.triggerLabel} │ ${C.cyan}${wd.modelStr}${C.reset} │ ${wd.chk} chk${wd.agentStr}${wd.turnStr}`, width),
611
+ // L2 — savings reconciled (session + all-time)
612
+ panelLine(` ${C.magenta}dup ${wd.dedupStr}${C.reset} │ ${C.gray}sess${C.reset} ${fmtTokens(wd.sessIn)}→${fmtTokens(wd.sessKept)} kept ${C.green}(${wd.sTxt}% freed)${C.reset} · ${C.gray}all-time${C.reset} ${fmtTokens(wd.repoIn)}→${fmtTokens(wd.repoKept)} kept ${C.blue}(${wd.rTxt}% freed)${C.reset} │ ${wd.repoChk} chk/${wd.repoSess} sess`, width),
613
+ // L3 — memory store + compression + drift + since-compact (NEW)
614
+ panelLine(` ${C.gray}mem${C.reset} ${wd.embedderName} · ${wd.chk} chunks · ${C.blue}comp ${wd.compStr}${C.reset} │ ${C.gray}drift${C.reset} ${wd.driftStatus === "ok" ? C.green : C.amber}${wd.driftStatus}${C.reset} │ ${C.gray}compact${C.reset} ${sinceCompactStr(wd.sinceCompact)}`, width),
615
+ ];
616
+ // L4 — agents block (S27, count + status; per-agent tokens gated on P0)
617
+ if (wd.agentsActive) {
618
+ lines.push(panelLine(` ${C.cyan}🤖 ${this.activeAgents} active${wd.turnStr}${C.reset}`, width));
619
+ }
620
+ // L5 — live ticker / activity (♻ deduped … why, or tier trace, or pulsing)
621
+ if (wd.tierTrace && wd.fresh) {
622
+ lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width));
623
+ }
624
+ else if (wd.ticker.length > 0) {
625
+ const step = Math.floor(Date.now() / 250);
626
+ const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
627
+ const head = wd.ticker[idx].text;
628
+ const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
629
+ const more = wd.ticker.length > 1
630
+ ? ` ${C.dim}(+${wd.ticker.length - 1} more)${C.reset}`
631
+ : "";
632
+ lines.push(panelLine(` ${wd.fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`, width));
633
+ }
634
+ else if (wd.pulsing) {
635
+ lines.push(panelLine(` ${pulse}${C.teal}compacting…${C.reset}`, width));
636
+ }
637
+ // bottom border
638
+ lines.push(panelBar(width, "─"));
639
+ return lines;
640
+ }
641
+ /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
642
+ embedderName() {
643
+ // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
644
+ // the embedder factory uses so the label matches what's actually running.
645
+ return process.env.MEGACOMPACT_MINILM === "true" ||
646
+ process.env.MEGACOMPACT_MINILM === "1"
647
+ ? "MiniLM"
648
+ : "Trigram";
649
+ }
650
+ /** Cross-repo drift status (ok | warn), cached for 30s (opens the registry DB). */
651
+ driftStatus() {
652
+ const now = Date.now();
653
+ if (this.driftCache && now - this.driftCache.at < 30_000)
654
+ return this.driftCache.status;
655
+ let status = "ok";
656
+ try {
657
+ const report = detectCrossRepoDrift();
658
+ status = report.totals.warn > 0 ? "warn" : "ok";
659
+ }
660
+ catch {
661
+ status = "ok";
662
+ }
663
+ this.driftCache = { at: now, status };
664
+ return status;
394
665
  }
395
666
  setStatus(ctx, text) {
396
667
  this.statusKey = text;
@@ -409,6 +680,7 @@ export class MegaRuntime {
409
680
  dedupSkips: 0,
410
681
  dedupAttempts: 0,
411
682
  tokensSaved: 0,
683
+ lastCompactAt: null,
412
684
  };
413
685
  this.statusKey = undefined;
414
686
  this.activeAgents = 0;
@@ -427,15 +699,22 @@ export class MegaRuntime {
427
699
  */
428
700
  captureModel(ctx) {
429
701
  const m = ctx.model;
430
- if (!m)
702
+ if (!m) {
703
+ this.appendEvent("captureModel:no-model", { cwd: ctx.cwd });
431
704
  return;
432
- if (this.currentModel && this.currentModel.modelId === m.id && this.currentModel.provider === m.provider)
705
+ }
706
+ if (this.currentModel &&
707
+ this.currentModel.modelId === m.id &&
708
+ this.currentModel.provider === m.provider)
433
709
  return;
434
710
  let providerName = null;
435
711
  try {
436
- providerName = ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null;
712
+ providerName =
713
+ ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null;
714
+ }
715
+ catch {
716
+ /* optional */
437
717
  }
438
- catch { /* optional */ }
439
718
  const snap = {
440
719
  provider: m.provider,
441
720
  providerName,
@@ -448,9 +727,32 @@ export class MegaRuntime {
448
727
  reasoning: !!m.reasoning,
449
728
  };
450
729
  this.currentModel = { ...snap, capturedAt: Date.now() };
730
+ this.diagCaptureModelCalls++;
731
+ const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
732
+ // S26: previously a single silent `catch {}` hid every capture failure, so
733
+ // model_snapshots stayed empty and the cost card read $0.00 with zero signal.
734
+ // Split per-write + append to events.log (always-on, dashboard live-streams
735
+ // it) + bump a DIAG counter so a live capture surfaces the root cause.
451
736
  try {
452
- const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
453
737
  recordModelSnapshot(repo, snap, this.currentStateDir);
738
+ this.appendEvent("captureModel:recorded", {
739
+ repo,
740
+ modelId: snap.modelId,
741
+ provider: snap.provider,
742
+ inputRate: snap.inputRate,
743
+ outputRate: snap.outputRate,
744
+ });
745
+ }
746
+ catch (e) {
747
+ this.diagCaptureModelFails++;
748
+ this.appendEvent("captureModel:record-failed", {
749
+ repo,
750
+ modelId: snap.modelId,
751
+ error: e instanceof Error ? e.message : String(e),
752
+ stack: e instanceof Error ? e.stack : undefined,
753
+ });
754
+ }
755
+ try {
454
756
  // Denormalize the active model into the machine-wide index so the
455
757
  // All-repos dashboard table can show provider/model per repo without
456
758
  // opening every repo's DB. Best-effort + non-fatal.
@@ -464,7 +766,28 @@ export class MegaRuntime {
464
766
  displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
465
767
  });
466
768
  }
467
- catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
769
+ catch (e) {
770
+ this.appendEvent("captureModel:index-record-failed", {
771
+ repo,
772
+ modelId: snap.modelId,
773
+ error: e instanceof Error ? e.message : String(e),
774
+ });
775
+ }
776
+ }
777
+ /**
778
+ * Append a structured line to the repo's events.log — the always-on
779
+ * diagnostics sink the dashboard live-streams. Unlike this.logger (gated by
780
+ * config.debug), this fires in production, so capture failures surface during
781
+ * a real capture even with debugging off. Best-effort + non-fatal.
782
+ */
783
+ appendEvent(event, fields) {
784
+ try {
785
+ mkdirSync(this.currentStateDir, { recursive: true });
786
+ appendFileSync(join(this.currentStateDir, "events.log"), JSON.stringify({ ts: Date.now(), event, ...fields }) + "\n");
787
+ }
788
+ catch {
789
+ /* non-fatal */
790
+ }
468
791
  }
469
792
  /** S21: state dir of the currently bound repo (where memories live). */
470
793
  getStateDir() {
@@ -474,10 +797,13 @@ export class MegaRuntime {
474
797
  makeTierCallback(ctx) {
475
798
  const order = ["L0", "L1", "L2", "new"];
476
799
  const seen = new Map();
477
- const glyph = (status) => status === "deduped" ? `${C.green}✓${C.reset}` :
478
- status === "passed" ? `${C.dim}○${C.reset}` :
479
- status === "scanning" ? `${C.amber}…${C.reset}` :
480
- `${C.cyan}●${C.reset}`;
800
+ const glyph = (status) => status === "deduped"
801
+ ? `${C.green}✓${C.reset}`
802
+ : status === "passed"
803
+ ? `${C.dim}○${C.reset}`
804
+ : status === "scanning"
805
+ ? `${C.amber}…${C.reset}`
806
+ : `${C.cyan}●${C.reset}`;
481
807
  return (ev) => {
482
808
  const label = ev.tier === "new"
483
809
  ? `${C.cyan}stored${C.reset}`
@@ -494,7 +820,9 @@ export class MegaRuntime {
494
820
  try {
495
821
  this.snapshot(ctx);
496
822
  }
497
- catch { /* non-fatal */ }
823
+ catch {
824
+ /* non-fatal */
825
+ }
498
826
  };
499
827
  }
500
828
  // Phase 3 — recall/activity ticker ring buffer.