pi-mega-compact 0.7.0 → 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.
@@ -16,8 +16,9 @@ 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, effectiveThresholdTokens } 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";
@@ -73,13 +74,20 @@ function visibleWidth(s) {
73
74
  let w = 0;
74
75
  for (const ch of stripped) {
75
76
  const cp = ch.codePointAt(0) ?? 0;
76
- const wide = cp >= 0x1100 && ((cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0x303e) ||
77
- (cp >= 0x3041 && cp <= 0x33ff) || (cp >= 0x3400 && cp <= 0x4dbf) ||
78
- (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0xa000 && cp <= 0xa4cf) ||
79
- (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
80
- (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
81
- (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) ||
82
- (cp >= 0x20000 && cp <= 0x3fffd));
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));
83
91
  w += wide ? 2 : 1;
84
92
  }
85
93
  return w;
@@ -94,6 +102,48 @@ function panelLine(content, width) {
94
102
  function panelBar(width, ch = "─") {
95
103
  return PANEL_BG + ch.repeat(Math.max(0, width)) + "\x1b[0m";
96
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
+ }
97
147
  export class MegaRuntime {
98
148
  config;
99
149
  // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
@@ -113,6 +163,7 @@ export class MegaRuntime {
113
163
  dedupSkips: 0,
114
164
  dedupAttempts: 0,
115
165
  tokensSaved: 0,
166
+ lastCompactAt: null,
116
167
  };
117
168
  debounceUntil = 0;
118
169
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -159,6 +210,11 @@ export class MegaRuntime {
159
210
  lastCtxTokens = null;
160
211
  lastCtxPercent = null;
161
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;
162
218
  /**
163
219
  * DIAG counters for the "team run doesn't relieve context" investigation.
164
220
  * Plain integers, incremented at the three compaction decision points. They
@@ -201,7 +257,9 @@ export class MegaRuntime {
201
257
  * context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
202
258
  */
203
259
  get pressure() {
204
- if (this.lastCtxWindow > 0 && this.config.tierPct != null && this.lastCtxPercent != null) {
260
+ if (this.lastCtxWindow > 0 &&
261
+ this.config.tierPct != null &&
262
+ this.lastCtxPercent != null) {
205
263
  // pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
206
264
  // exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
207
265
  // fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
@@ -209,7 +267,9 @@ export class MegaRuntime {
209
267
  // the band doesn't jump when a token-count vs percent-only event arrives.
210
268
  return pressureFromPct(this.lastCtxPercent / this.config.tierPct);
211
269
  }
212
- if (this.lastCtxTokens != null && this.lastCtxTokens > 0 && this.config.thresholdTokens > 0) {
270
+ if (this.lastCtxTokens != null &&
271
+ this.lastCtxTokens > 0 &&
272
+ this.config.thresholdTokens > 0) {
213
273
  return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
214
274
  }
215
275
  return pressureFromPct(this.lastCtxPercent);
@@ -235,8 +295,14 @@ export class MegaRuntime {
235
295
  }
236
296
  constructor(config) {
237
297
  this.config = config;
238
- this.store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
239
- 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
+ });
240
306
  this.dashboard = new Dashboard(config.stateDir);
241
307
  this.currentStateDir = config.stateDir;
242
308
  }
@@ -247,14 +313,22 @@ export class MegaRuntime {
247
313
  * and events are fully isolated. Falls back to the global default outside git.
248
314
  */
249
315
  bindRepo(cwd) {
250
- const dir = cwd ? repoStateDir(cwd, this.config.stateDir) : this.config.stateDir;
251
- 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;
252
320
  if (key === this.activeRepoRoot)
253
321
  return dir;
254
322
  this.activeRepoRoot = key;
255
323
  this.currentStateDir = dir;
256
- this.store = new VectorStore({ dedupSim: this.config.dedupSim, stateDir: dir });
257
- 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
+ });
258
332
  this.dashboard = new Dashboard(dir);
259
333
  // Aggregate this repo into the machine-wide index so the multi-repo
260
334
  // dashboard (Summary / All-repos tabs) can show it alongside every other
@@ -264,7 +338,7 @@ export class MegaRuntime {
264
338
  try {
265
339
  const repo = this.store.repoStats();
266
340
  const di = this.store.dataInvariant();
267
- const root = key !== dir ? key : resolveRepoRoot(cwd ?? dir) ?? dir;
341
+ const root = key !== dir ? key : (resolveRepoRoot(cwd ?? dir) ?? dir);
268
342
  upsertRepoRegistry({
269
343
  repoRoot: root,
270
344
  displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
@@ -304,7 +378,9 @@ export class MegaRuntime {
304
378
  // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
305
379
  // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
306
380
  // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
307
- const armed = this.lastCtxPercent != null && this.lastCtxPercent >= Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
381
+ const armed = this.lastCtxPercent != null &&
382
+ this.lastCtxPercent >=
383
+ Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
308
384
  const ready = armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
309
385
  this.dashboard.snapshot({
310
386
  version: 1,
@@ -334,10 +410,32 @@ export class MegaRuntime {
334
410
  dedupSkips: this.rt.dedupSkips,
335
411
  dedupAttempts: this.rt.dedupAttempts,
336
412
  },
337
- context: { tokens: this.lastCtxTokens, percent: this.lastCtxPercent, contextWindow: this.lastCtxWindow },
338
- trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.effectiveThreshold, fastGatePct: this.config.fastGatePct, tierPct: this.config.tierPct, effectiveThresholdPct },
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
+ },
339
427
  crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
340
- 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
+ },
341
439
  // Reconciled token accounting (single canonical formula, session + repo).
342
440
  // Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
343
441
  // deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
@@ -346,14 +444,19 @@ export class MegaRuntime {
346
444
  tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
347
445
  tokensOut: st.totalTokenEstimate,
348
446
  tokensFreed: this.rt.tokensSaved,
349
- 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,
350
451
  dedupPct: st.storageDedupRate,
351
452
  },
352
453
  repo: {
353
454
  tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
354
455
  tokensOut: repo.totalTokenEstimate,
355
456
  tokensFreed: repo.tokensSaved,
356
- 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,
357
460
  dedupPct: repo.storageDedupRate,
358
461
  },
359
462
  },
@@ -377,112 +480,188 @@ export class MegaRuntime {
377
480
  });
378
481
  // Live stats widget above the editor
379
482
  if (ctx) {
380
- const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
381
- const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
382
- 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
+ : "?%";
383
493
  // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
384
- // mega), not the static env preset. It climbs as context fills, so the
385
- // 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.
386
495
  const liveBand = this.pressureBand;
387
496
  const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
388
- 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}`;
389
502
  // Storage dedup rate is cumulative (store-wide, per-repo) and survives
390
- // session resets. Always show a number: 0% before any compaction, a
391
- // 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%).
392
504
  const storageRate = st.storageDedupRate; // 0..1
393
505
  const dedupStr = storageRate * 100 >= 10
394
506
  ? `${Math.round(storageRate * 100)}%`
395
507
  : `${(storageRate * 100).toFixed(1)}%`;
396
- // Reconciled token accounting ONE canonical formula for session + repo,
397
- // matching the dashboard so the two never disagree. unit format: M at/above
398
- // 1e6, k at/above 1e3, raw below — so 5,472,700 → "5.5M", 24,100 → "24.1k",
399
- // 142 → "142". Dropped (in) = Freed + Kept; Freed = rt.tokensSaved (session)
400
- // / repo.tokensSaved meta (repo); Kept = totalTokenEstimate (stored).
401
- const fmt = (x) => x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
402
- : x >= 1000 ? `${(x / 1000).toFixed(1)}k`
403
- : `${Math.round(x)}`;
404
- // Agents view: ALWAYS show the agent line so status is visible even when
405
- // idle (previously hidden at 0). 🤖 N agents when active, dimmed 🤖 idle
406
- // when none — this is the restored "agents view" (count + status). Real
407
- // per-agent/sub-agent token usage is scoped in Sprint 27.
508
+ // Agents view: count + status (S27 per-agent tokens are gated on P0).
408
509
  const agentLabel = this.activeAgents > 0
409
510
  ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
410
511
  : `${C.dim}🤖 idle${C.reset}`;
411
512
  const agentStr = ` │ ${agentLabel}`;
412
513
  const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
413
- // Phase 3 pulsing status glyph while a compaction is in flight.
414
- const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
415
- // --- reconciled in/out view (session + repo) ---------------------------
514
+ // Reconciled in/out view (session + repo) ONE canonical formula.
416
515
  const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
417
516
  const sessKept = st.totalTokenEstimate;
418
- const sessFreed = this.rt.tokensSaved;
419
- const sessPct = sessIn > 0 ? sessFreed / sessIn : 0;
517
+ const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
420
518
  const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
421
519
  const repoKept = repo.totalTokenEstimate;
422
- const repoFreed = repo.tokensSaved;
423
- const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
424
- // Retro gradient bar — `w` cells, each shaded by fill position so it
425
- // reads as a smooth green→amber→red ramp. Used for CONTEXT fill where
426
- // low=green (room to spare) and high=red (near the limit) — the only
427
- // live-moving metric worth a bar. Savings ratios saturate near 100% and
428
- // are shown as explanatory numbers instead (see L2).
429
- const ramp = (pct, w = 12) => {
430
- const cells = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
431
- const scaled = Math.max(0, Math.min(w, pct * w));
432
- const full = Math.floor(scaled);
433
- const frac = scaled - full;
434
- const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
435
- let out = "";
436
- for (let i = 0; i < full; i++)
437
- out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
438
- if (fracCell)
439
- out += (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
440
- out += C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
441
- return out;
442
- };
443
- const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
520
+ const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
444
521
  const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
445
522
  const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
446
- // Full-width panel: read the real terminal width and pad each line with a
447
- // panel background so the above-editor widget reads as a full-width status
448
- // bar. pi's widget renderer does not pass width to setWidget(), so we pad
449
- // ourselves. Falls back to 200 cols when stdout.columns is unavailable.
450
- const W = process.stdout?.columns ?? 200;
451
- const lines = [
452
- // top border full-width hairline
453
- panelBar(W, "─"),
454
- // L1 — header: tier + ctx-fill bar (20-cell, green=room→red=full) +
455
- // tokens + status glyph + checkpoints + agents/turn. The context bar is
456
- // the only live-moving bar; the whole block is padded to full width.
457
- panelLine(` ${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}`, W),
458
- // L2 savings EXPLAINED, not bar'd. The freed/(freed+kept) ratio
459
- // saturates near 100% once cumulative freed dwarfs live kept, so a bar
460
- // is visually useless; show the compaction story instead.
461
- panelLine(` ${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`, W),
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, "─"),
462
601
  ];
463
- // Live "now processing" line + why + recent deduped/compacted events,
464
- // collapsed to ONE rotating line (fresh only); padded to full width.
465
- const fresh = Date.now() - this.lastActivityAt < 4000;
466
- if (this.tierTrace && fresh) {
467
- lines.push(panelLine(` ${pulse}${this.tierTrace}`, W));
468
- }
469
- else if (this.ticker.length > 0) {
470
- const step = Math.floor(Date.now() / 250);
471
- const idx = this.ticker.length - 1 - (step % this.ticker.length);
472
- const head = this.ticker[idx].text;
473
- const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
474
- const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
475
- lines.push(panelLine(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`, W));
476
- }
477
- else if (this.pulsing) {
478
- lines.push(panelLine(` ${pulse}${C.teal}compacting…${C.reset}`, W));
479
- }
480
- // bottom border — full-width hairline closes the panel
481
- lines.push(panelBar(W, "─"));
482
- // (Accounting folded into L2's "in→kept (X% freed)" framing — freed =
483
- // in − kept is implied, and the saturated-ratio bars are gone.)
484
- ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
485
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;
486
665
  }
487
666
  setStatus(ctx, text) {
488
667
  this.statusKey = text;
@@ -501,6 +680,7 @@ export class MegaRuntime {
501
680
  dedupSkips: 0,
502
681
  dedupAttempts: 0,
503
682
  tokensSaved: 0,
683
+ lastCompactAt: null,
504
684
  };
505
685
  this.statusKey = undefined;
506
686
  this.activeAgents = 0;
@@ -523,13 +703,18 @@ export class MegaRuntime {
523
703
  this.appendEvent("captureModel:no-model", { cwd: ctx.cwd });
524
704
  return;
525
705
  }
526
- if (this.currentModel && this.currentModel.modelId === m.id && this.currentModel.provider === m.provider)
706
+ if (this.currentModel &&
707
+ this.currentModel.modelId === m.id &&
708
+ this.currentModel.provider === m.provider)
527
709
  return;
528
710
  let providerName = null;
529
711
  try {
530
- providerName = ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null;
712
+ providerName =
713
+ ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null;
714
+ }
715
+ catch {
716
+ /* optional */
531
717
  }
532
- catch { /* optional */ }
533
718
  const snap = {
534
719
  provider: m.provider,
535
720
  providerName,
@@ -551,14 +736,18 @@ export class MegaRuntime {
551
736
  try {
552
737
  recordModelSnapshot(repo, snap, this.currentStateDir);
553
738
  this.appendEvent("captureModel:recorded", {
554
- repo, modelId: snap.modelId, provider: snap.provider,
555
- inputRate: snap.inputRate, outputRate: snap.outputRate,
739
+ repo,
740
+ modelId: snap.modelId,
741
+ provider: snap.provider,
742
+ inputRate: snap.inputRate,
743
+ outputRate: snap.outputRate,
556
744
  });
557
745
  }
558
746
  catch (e) {
559
747
  this.diagCaptureModelFails++;
560
748
  this.appendEvent("captureModel:record-failed", {
561
- repo, modelId: snap.modelId,
749
+ repo,
750
+ modelId: snap.modelId,
562
751
  error: e instanceof Error ? e.message : String(e),
563
752
  stack: e instanceof Error ? e.stack : undefined,
564
753
  });
@@ -579,7 +768,8 @@ export class MegaRuntime {
579
768
  }
580
769
  catch (e) {
581
770
  this.appendEvent("captureModel:index-record-failed", {
582
- repo, modelId: snap.modelId,
771
+ repo,
772
+ modelId: snap.modelId,
583
773
  error: e instanceof Error ? e.message : String(e),
584
774
  });
585
775
  }
@@ -595,7 +785,9 @@ export class MegaRuntime {
595
785
  mkdirSync(this.currentStateDir, { recursive: true });
596
786
  appendFileSync(join(this.currentStateDir, "events.log"), JSON.stringify({ ts: Date.now(), event, ...fields }) + "\n");
597
787
  }
598
- catch { /* non-fatal */ }
788
+ catch {
789
+ /* non-fatal */
790
+ }
599
791
  }
600
792
  /** S21: state dir of the currently bound repo (where memories live). */
601
793
  getStateDir() {
@@ -605,10 +797,13 @@ export class MegaRuntime {
605
797
  makeTierCallback(ctx) {
606
798
  const order = ["L0", "L1", "L2", "new"];
607
799
  const seen = new Map();
608
- const glyph = (status) => status === "deduped" ? `${C.green}✓${C.reset}` :
609
- status === "passed" ? `${C.dim}○${C.reset}` :
610
- status === "scanning" ? `${C.amber}…${C.reset}` :
611
- `${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}`;
612
807
  return (ev) => {
613
808
  const label = ev.tier === "new"
614
809
  ? `${C.cyan}stored${C.reset}`
@@ -625,7 +820,9 @@ export class MegaRuntime {
625
820
  try {
626
821
  this.snapshot(ctx);
627
822
  }
628
- catch { /* non-fatal */ }
823
+ catch {
824
+ /* non-fatal */
825
+ }
629
826
  };
630
827
  }
631
828
  // Phase 3 — recall/activity ticker ring buffer.