pi-mega-compact 0.7.0 → 0.7.2

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.
@@ -67,6 +67,7 @@ export function driveNativeCompaction(event, runtime, config) {
67
67
  runtime.rt.lastCompactedFrom = keepFrom;
68
68
  runtime.rt.lastCompactedTokens = tokensBefore;
69
69
  runtime.rt.tokensSaved += savedTokens;
70
+ runtime.rt.lastCompactAt = Date.now();
70
71
  runtime.rt.persistedThisSession = true;
71
72
  return {
72
73
  compaction: {
@@ -104,6 +104,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
104
104
  ? result.originalTokenEstimate
105
105
  : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
106
106
  runtime.rt.tokensSaved += saved;
107
+ runtime.rt.lastCompactAt = Date.now();
107
108
  if (result.deduped)
108
109
  runtime.rt.dedupSkips++;
109
110
  // Grow the rolling "saved" goal so the progress bar always has a fresh
@@ -17,6 +17,7 @@ import { toEngineMessages } from "../src/adapt.js";
17
17
  import { normalizeSessionId } from "../src/store.js";
18
18
  import { Logger } from "../src/log.js";
19
19
  import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel } from "../src/store/sqlite.js";
20
+ import { detectCrossRepoDrift } from "../src/driftDetection.js";
20
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";
@@ -85,6 +86,28 @@ function visibleWidth(s) {
85
86
  return w;
86
87
  }
87
88
  /** Pad a content string (with ANSI colors) to `width` cells using panel bg. */
89
+ function spreadGroups(width, groups, indent = 0) {
90
+ if (groups.length === 0)
91
+ return panelLine('', width);
92
+ // Calculate visible width of each group
93
+ const groupWidths = groups.map(g => visibleWidth(PANEL_BG + g.replace(/\x1b\[0m/g, PANEL_RST)));
94
+ const totalContent = groupWidths.reduce((a, b) => a + b, 0);
95
+ const separator = ` ${C.dim}│${C.reset} `;
96
+ const sepWidth = visibleWidth(PANEL_BG + separator.replace(/\x1b\[0m/g, PANEL_RST));
97
+ const totalSep = sepWidth * (groups.length - 1);
98
+ const available = width - indent - totalContent - totalSep;
99
+ const gapSize = groups.length > 1 ? Math.max(1, Math.floor(available / (groups.length - 1))) : 0;
100
+ const extra = groups.length > 1 ? available - gapSize * (groups.length - 1) : 0;
101
+ let line = ' '.repeat(indent);
102
+ for (let i = 0; i < groups.length; i++) {
103
+ line += groups[i];
104
+ if (i < groups.length - 1) {
105
+ const gap = gapSize + (i < extra ? 1 : 0);
106
+ line += separator + ' '.repeat(Math.max(0, gap));
107
+ }
108
+ }
109
+ return panelLine(line, width);
110
+ }
88
111
  function panelLine(content, width) {
89
112
  const withBg = PANEL_BG + content.replace(/\x1b\[0m/g, PANEL_RST);
90
113
  const pad = Math.max(0, width - visibleWidth(withBg));
@@ -94,6 +117,44 @@ function panelLine(content, width) {
94
117
  function panelBar(width, ch = "─") {
95
118
  return PANEL_BG + ch.repeat(Math.max(0, width)) + "\x1b[0m";
96
119
  }
120
+ /** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
121
+ * 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
122
+ function fmtTokens(x) {
123
+ return x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
124
+ : x >= 1000 ? `${(x / 1000).toFixed(1)}k`
125
+ : `${Math.round(x)}`;
126
+ }
127
+ /** Retro gradient bar — `w` cells shaded by fill position (green→amber→red).
128
+ * Used for CONTEXT fill where low=green (room) and high=red (near the limit). */
129
+ function ramp(pct, w = 12) {
130
+ const cells = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
131
+ const scaled = Math.max(0, Math.min(w, pct * w));
132
+ const full = Math.floor(scaled);
133
+ const frac = scaled - full;
134
+ const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
135
+ let out = "";
136
+ for (let i = 0; i < full; i++)
137
+ out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
138
+ if (fracCell)
139
+ out += (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
140
+ out += C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
141
+ return out;
142
+ }
143
+ /** Human "time since" string from a millisecond delta (or null → "never"). */
144
+ function sinceCompactStr(ms) {
145
+ if (ms == null)
146
+ return "never";
147
+ const s = Math.floor(ms / 1000);
148
+ if (s < 60)
149
+ return `${s}s ago`;
150
+ const m = Math.floor(s / 60);
151
+ if (m < 60)
152
+ return `${m}m ago`;
153
+ const h = Math.floor(m / 60);
154
+ if (h < 24)
155
+ return `${h}h ago`;
156
+ return `${Math.floor(h / 24)}d ago`;
157
+ }
97
158
  export class MegaRuntime {
98
159
  config;
99
160
  // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
@@ -113,6 +174,7 @@ export class MegaRuntime {
113
174
  dedupSkips: 0,
114
175
  dedupAttempts: 0,
115
176
  tokensSaved: 0,
177
+ lastCompactAt: null,
116
178
  };
117
179
  debounceUntil = 0;
118
180
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -159,6 +221,11 @@ export class MegaRuntime {
159
221
  lastCtxTokens = null;
160
222
  lastCtxPercent = null;
161
223
  lastCtxWindow = 0;
224
+ // Latest computed widget payload (recomputed per snapshot, rendered per frame).
225
+ widgetData = null;
226
+ // Cached cross-repo drift status (recomputed at most every 30s — it opens the
227
+ // machine-wide registry DB, so we don't want to do it on every render frame).
228
+ driftCache = null;
162
229
  /**
163
230
  * DIAG counters for the "team run doesn't relieve context" investigation.
164
231
  * Plain integers, incremented at the three compaction decision points. They
@@ -377,112 +444,154 @@ export class MegaRuntime {
377
444
  });
378
445
  // Live stats widget above the editor
379
446
  if (ctx) {
447
+ // ── gather widget data (computed per snapshot, rendered per frame) ────
380
448
  const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
381
449
  const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
382
450
  const pctStr = this.lastCtxPercent != null ? `${Math.round(this.lastCtxPercent * 10) / 10}%` : "?%";
383
451
  // 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.
452
+ // mega), not the static env preset. It climbs as context fills.
386
453
  const liveBand = this.pressureBand;
387
454
  const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
388
455
  const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
389
456
  // 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.
457
+ // session resets. Always show a number (decimal for sub-10%).
392
458
  const storageRate = st.storageDedupRate; // 0..1
393
459
  const dedupStr = storageRate * 100 >= 10
394
460
  ? `${Math.round(storageRate * 100)}%`
395
461
  : `${(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.
462
+ // Agents view: count + status (S27 per-agent tokens are gated on P0).
408
463
  const agentLabel = this.activeAgents > 0
409
464
  ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
410
465
  : `${C.dim}🤖 idle${C.reset}`;
411
466
  const agentStr = ` │ ${agentLabel}`;
412
467
  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) ---------------------------
468
+ // Reconciled in/out view (session + repo) ONE canonical formula.
416
469
  const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
417
470
  const sessKept = st.totalTokenEstimate;
418
- const sessFreed = this.rt.tokensSaved;
419
- const sessPct = sessIn > 0 ? sessFreed / sessIn : 0;
471
+ const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
420
472
  const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
421
473
  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;
474
+ const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
444
475
  const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
445
476
  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),
462
- ];
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" });
477
+ const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
478
+ // Model + provider (S26 capture) for the header.
479
+ const modelName = modelSnap?.modelName ?? modelSnap?.modelId ?? "?";
480
+ const modelStr = modelSnap?.provider ? `${modelName}·${modelSnap.provider}` : modelName;
481
+ // Since-last-compact (ms; null until first compaction this session).
482
+ const sinceCompact = this.rt.lastCompactAt != null ? Date.now() - this.rt.lastCompactAt : null;
483
+ // Memory store: embedder + compression ratio (original / stored).
484
+ const embedderName = this.embedderName();
485
+ const compRatio = st.originalTokens > 0 && st.totalTokenEstimate > 0
486
+ ? st.originalTokens / st.totalTokenEstimate
487
+ : (st.originalTokens > 0 ? 1 : 0);
488
+ const compStr = compRatio >= 1 ? `${compRatio.toFixed(1)}x` : "—";
489
+ // Cross-repo drift status (cached, read-only).
490
+ const driftStatus = this.driftStatus();
491
+ const agentsActive = this.activeAgents > 0;
492
+ this.widgetData = {
493
+ version: ownVersion(),
494
+ tierLabel, triggerLabel, pctStr, tokStr, maxStr, ctxPct,
495
+ chk: st.checkpointCount, agentStr, turnStr, dedupStr,
496
+ sessIn, sessKept, sTxt, repoIn, repoKept, rTxt,
497
+ repoChk: repo.checkpointCount, repoSess: repo.sessionCount,
498
+ modelStr, sinceCompact, embedderName, compStr, driftStatus, agentsActive,
499
+ fresh: Date.now() - this.lastActivityAt < 4000,
500
+ ticker: this.ticker, lastWhy: this.lastWhy, tierTrace: this.tierTrace, pulsing: this.pulsing,
501
+ };
502
+ // Auto-fit: register a factory so pi re-renders the panel at the REAL
503
+ // terminal width every frame (tui.columns), instead of guessing with
504
+ // process.stdout.columns. buildWidgetLines reads this.widgetData live.
505
+ this.renderWidget(ctx);
506
+ }
507
+ }
508
+ /** Register the above-editor widget as a width-aware factory so pi re-renders
509
+ * it at the REAL terminal width every frame (auto-fit wide/narrow). The
510
+ * factory returns a minimal Component whose render() reads this.widgetData.
511
+ */
512
+ renderWidget(ctx) {
513
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, _theme) => ({
514
+ render: (width) => this.buildWidgetLines(width > 0 ? width : 200),
515
+ invalidate: () => { },
516
+ }), { placement: "aboveEditor" });
517
+ }
518
+ /** Build the full-width panel lines from the latest snapshot. Cheap: reads
519
+ * only this.widgetData + a couple of live counters; no DB/IO. */
520
+ buildWidgetLines(width) {
521
+ const wd = this.widgetData;
522
+ if (!wd) {
523
+ return [panelBar(width, "─"), panelLine(" mega-compact: warming up…", width), panelBar(width, "─")];
524
+ }
525
+ const pulse = wd.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
526
+ const lines = [
527
+ // top border
528
+ panelBar(width, "─"),
529
+ // L1 — header: tier + ctx bar + pct/tokens + status + model + chk + agents/turn
530
+ spreadGroups(width, [
531
+ `${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}`,
532
+ wd.triggerLabel,
533
+ `${C.cyan}${wd.modelStr}${C.reset}`,
534
+ `${wd.chk} chk${wd.agentStr}${wd.turnStr}`,
535
+ ]),
536
+ // L2 — savings reconciled (session + all-time)
537
+ spreadGroups(width, [
538
+ `${C.magenta}dup ${wd.dedupStr}${C.reset}`,
539
+ `${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}`,
540
+ `${wd.repoChk} chk/${wd.repoSess} sess`,
541
+ ]),
542
+ // L3 — memory store + compression + drift + since-compact (NEW)
543
+ spreadGroups(width, [
544
+ `${C.gray}mem${C.reset} ${wd.embedderName} · ${wd.chk} chunks · ${C.blue}comp ${wd.compStr}${C.reset}`,
545
+ `${C.gray}drift${C.reset} ${wd.driftStatus === "ok" ? C.green : C.amber}${wd.driftStatus}${C.reset}`,
546
+ `${C.gray}compact${C.reset} ${sinceCompactStr(wd.sinceCompact)}`,
547
+ ]),
548
+ ];
549
+ // L4 — agents block (S27, count + status; per-agent tokens gated on P0)
550
+ if (wd.agentsActive) {
551
+ lines.push(panelLine(` ${C.cyan}🤖 ${this.activeAgents} active${wd.turnStr}${C.reset}`, width));
552
+ }
553
+ // L5 — live ticker / activity (♻ deduped … why, or tier trace, or pulsing)
554
+ if (wd.tierTrace && wd.fresh) {
555
+ lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width));
556
+ }
557
+ else if (wd.ticker.length > 0) {
558
+ const step = Math.floor(Date.now() / 250);
559
+ const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
560
+ const head = wd.ticker[idx].text;
561
+ const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
562
+ const more = wd.ticker.length > 1 ? ` ${C.dim}(+${wd.ticker.length - 1} more)${C.reset}` : "";
563
+ lines.push(panelLine(` ${wd.fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`, width));
564
+ }
565
+ else if (wd.pulsing) {
566
+ lines.push(panelLine(` ${pulse}${C.teal}compacting…${C.reset}`, width));
567
+ }
568
+ // bottom border
569
+ lines.push(panelBar(width, "─"));
570
+ return lines;
571
+ }
572
+ /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
573
+ embedderName() {
574
+ // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
575
+ // the embedder factory uses so the label matches what's actually running.
576
+ return process.env.MEGACOMPACT_MINILM === "true" || process.env.MEGACOMPACT_MINILM === "1"
577
+ ? "MiniLM"
578
+ : "Trigram";
579
+ }
580
+ /** Cross-repo drift status (ok | warn), cached for 30s (opens the registry DB). */
581
+ driftStatus() {
582
+ const now = Date.now();
583
+ if (this.driftCache && now - this.driftCache.at < 30_000)
584
+ return this.driftCache.status;
585
+ let status = "ok";
586
+ try {
587
+ const report = detectCrossRepoDrift();
588
+ status = report.totals.warn > 0 ? "warn" : "ok";
589
+ }
590
+ catch {
591
+ status = "ok";
485
592
  }
593
+ this.driftCache = { at: now, status };
594
+ return status;
486
595
  }
487
596
  setStatus(ctx, text) {
488
597
  this.statusKey = text;
@@ -501,6 +610,7 @@ export class MegaRuntime {
501
610
  dedupSkips: 0,
502
611
  dedupAttempts: 0,
503
612
  tokensSaved: 0,
613
+ lastCompactAt: null,
504
614
  };
505
615
  this.statusKey = undefined;
506
616
  this.activeAgents = 0;
@@ -92,6 +92,7 @@ export function driveNativeCompaction(
92
92
  runtime.rt.lastCompactedFrom = keepFrom;
93
93
  runtime.rt.lastCompactedTokens = tokensBefore;
94
94
  runtime.rt.tokensSaved += savedTokens;
95
+ runtime.rt.lastCompactAt = Date.now();
95
96
  runtime.rt.persistedThisSession = true;
96
97
 
97
98
  return {
@@ -18,7 +18,7 @@ import { estimateBlockTokens } from "../src/tokens.js";
18
18
  import { touchSession, logDaily } from "../src/store/sqlite.js";
19
19
  import { consolidateMemories } from "../src/memory.js";
20
20
  import {
21
- MegaRuntime,
21
+ type MegaRuntime,
22
22
  C,
23
23
  MARKER_TYPE,
24
24
  } from "./mega-runtime.js";
@@ -146,6 +146,7 @@ function doCompact(
146
146
  ? result.originalTokenEstimate
147
147
  : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
148
148
  runtime.rt.tokensSaved += saved;
149
+ runtime.rt.lastCompactAt = Date.now();
149
150
  if (result.deduped) runtime.rt.dedupSkips++;
150
151
  // Grow the rolling "saved" goal so the progress bar always has a fresh
151
152
  // denominator (we don't want it pinned at 100% once we pass an old target).
@@ -20,6 +20,7 @@ import { toEngineMessages } from "../src/adapt.js";
20
20
  import { normalizeSessionId } from "../src/store.js";
21
21
  import { Logger } from "../src/log.js";
22
22
  import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, type ModelSnapshot } from "../src/store/sqlite.js";
23
+ import { detectCrossRepoDrift } from "../src/driftDetection.js";
23
24
  import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens, type MegaConfig, type PressureBand } from "./mega-config.js";
24
25
  import { Dashboard, type DashboardSnapshot } from "./mega-dashboard.js";
25
26
 
@@ -53,6 +54,7 @@ interface SessionRuntime {
53
54
  dedupSkips: number; // compactions skipped because regionHash already stored
54
55
  dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
55
56
  tokensSaved: number; // this session-instance only: reset on session_start
57
+ lastCompactAt: number | null; // wall-clock ms of the last compaction this session
56
58
  }
57
59
 
58
60
  /** ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
@@ -107,6 +109,29 @@ function visibleWidth(s: string): number {
107
109
  }
108
110
 
109
111
  /** Pad a content string (with ANSI colors) to `width` cells using panel bg. */
112
+ function spreadGroups(width: number, groups: string[], indent = 0): string {
113
+ if (groups.length === 0) return panelLine('', width);
114
+ // Calculate visible width of each group
115
+ const groupWidths = groups.map(g => visibleWidth(PANEL_BG + g.replace(/\x1b\[0m/g, PANEL_RST)));
116
+ const totalContent = groupWidths.reduce((a, b) => a + b, 0);
117
+ const separator = ` ${C.dim}│${C.reset} `;
118
+ const sepWidth = visibleWidth(PANEL_BG + separator.replace(/\x1b\[0m/g, PANEL_RST));
119
+ const totalSep = sepWidth * (groups.length - 1);
120
+ const available = width - indent - totalContent - totalSep;
121
+ const gapSize = groups.length > 1 ? Math.max(1, Math.floor(available / (groups.length - 1))) : 0;
122
+ const extra = groups.length > 1 ? available - gapSize * (groups.length - 1) : 0;
123
+
124
+ let line = ' '.repeat(indent);
125
+ for (let i = 0; i < groups.length; i++) {
126
+ line += groups[i];
127
+ if (i < groups.length - 1) {
128
+ const gap = gapSize + (i < extra ? 1 : 0);
129
+ line += separator + ' '.repeat(Math.max(0, gap));
130
+ }
131
+ }
132
+ return panelLine(line, width);
133
+ }
134
+
110
135
  function panelLine(content: string, width: number): string {
111
136
  const withBg = PANEL_BG + content.replace(/\x1b\[0m/g, PANEL_RST);
112
137
  const pad = Math.max(0, width - visibleWidth(withBg));
@@ -118,8 +143,75 @@ function panelBar(width: number, ch = "─"): string {
118
143
  return PANEL_BG + ch.repeat(Math.max(0, width)) + "\x1b[0m";
119
144
  }
120
145
 
146
+ /** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
147
+ * 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
148
+ function fmtTokens(x: number): string {
149
+ return x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
150
+ : x >= 1000 ? `${(x / 1000).toFixed(1)}k`
151
+ : `${Math.round(x)}`;
152
+ }
153
+
154
+ /** Retro gradient bar — `w` cells shaded by fill position (green→amber→red).
155
+ * Used for CONTEXT fill where low=green (room) and high=red (near the limit). */
156
+ function ramp(pct: number, w = 12): string {
157
+ const cells = ["▏","▎","▍","▌","▋","▊","▉","█"];
158
+ const scaled = Math.max(0, Math.min(w, pct * w));
159
+ const full = Math.floor(scaled);
160
+ const frac = scaled - full;
161
+ const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
162
+ let out = "";
163
+ for (let i = 0; i < full; i++) out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
164
+ if (fracCell) out += (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
165
+ out += C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
166
+ return out;
167
+ }
168
+
169
+ /** Human "time since" string from a millisecond delta (or null → "never"). */
170
+ function sinceCompactStr(ms: number | null): string {
171
+ if (ms == null) return "never";
172
+ const s = Math.floor(ms / 1000);
173
+ if (s < 60) return `${s}s ago`;
174
+ const m = Math.floor(s / 60);
175
+ if (m < 60) return `${m}m ago`;
176
+ const h = Math.floor(m / 60);
177
+ if (h < 24) return `${h}h ago`;
178
+ return `${Math.floor(h / 24)}d ago`;
179
+ }
180
+
121
181
  interface TickerEntry { text: string; at: number; }
122
182
 
183
+ /** Immutable snapshot of everything the above-editor widget needs to render.
184
+ * Computed once per `snapshot()` (event-driven) and read by `buildWidgetLines`
185
+ * on every TUI render frame, so frame rendering stays allocation-cheap and the
186
+ * panel auto-fits whatever width pi passes to the setWidget factory. */
187
+ interface WidgetData {
188
+ version: string;
189
+ tierLabel: string;
190
+ triggerLabel: string;
191
+ pctStr: string;
192
+ tokStr: string;
193
+ maxStr: string;
194
+ ctxPct: number;
195
+ chk: number;
196
+ agentStr: string;
197
+ turnStr: string;
198
+ dedupStr: string;
199
+ sessIn: number; sessKept: number; sTxt: string;
200
+ repoIn: number; repoKept: number; rTxt: string;
201
+ repoChk: number; repoSess: number;
202
+ modelStr: string;
203
+ sinceCompact: number | null;
204
+ embedderName: string;
205
+ compStr: string;
206
+ driftStatus: "ok" | "warn";
207
+ agentsActive: boolean;
208
+ fresh: boolean;
209
+ ticker: TickerEntry[];
210
+ lastWhy: string | undefined;
211
+ tierTrace: string | undefined;
212
+ pulsing: boolean;
213
+ }
214
+
123
215
  export class MegaRuntime {
124
216
  config: MegaConfig;
125
217
  // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
@@ -140,6 +232,7 @@ export class MegaRuntime {
140
232
  dedupSkips: 0,
141
233
  dedupAttempts: 0,
142
234
  tokensSaved: 0,
235
+ lastCompactAt: null,
143
236
  };
144
237
  debounceUntil = 0;
145
238
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -188,6 +281,12 @@ export class MegaRuntime {
188
281
  lastCtxPercent: number | null = null;
189
282
  lastCtxWindow = 0;
190
283
 
284
+ // Latest computed widget payload (recomputed per snapshot, rendered per frame).
285
+ widgetData: WidgetData | null = null;
286
+ // Cached cross-repo drift status (recomputed at most every 30s — it opens the
287
+ // machine-wide registry DB, so we don't want to do it on every render frame).
288
+ private driftCache: { at: number; status: "ok" | "warn" } | null = null;
289
+
191
290
  /**
192
291
  * DIAG counters for the "team run doesn't relieve context" investigation.
193
292
  * Plain integers, incremented at the three compaction decision points. They
@@ -413,109 +512,155 @@ export class MegaRuntime {
413
512
 
414
513
  // Live stats widget above the editor
415
514
  if (ctx) {
515
+ // ── gather widget data (computed per snapshot, rendered per frame) ────
416
516
  const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
417
517
  const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
418
518
  const pctStr = this.lastCtxPercent != null ? `${Math.round(this.lastCtxPercent * 10) / 10}%` : "?%";
419
519
  // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
420
- // mega), not the static env preset. It climbs as context fills, so the
421
- // user can see the system react. The base preset is shown as a dim suffix.
520
+ // mega), not the static env preset. It climbs as context fills.
422
521
  const liveBand = this.pressureBand;
423
522
  const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
424
523
  const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
425
524
  // Storage dedup rate is cumulative (store-wide, per-repo) and survives
426
- // session resets. Always show a number: 0% before any compaction, a
427
- // decimal for sub-10% rates so small-but-real dedup isn't rounded away.
525
+ // session resets. Always show a number (decimal for sub-10%).
428
526
  const storageRate = st.storageDedupRate; // 0..1
429
527
  const dedupStr = storageRate * 100 >= 10
430
528
  ? `${Math.round(storageRate * 100)}%`
431
529
  : `${(storageRate * 100).toFixed(1)}%`;
432
- // Reconciled token accounting ONE canonical formula for session + repo,
433
- // matching the dashboard so the two never disagree. unit format: M at/above
434
- // 1e6, k at/above 1e3, raw below — so 5,472,700 → "5.5M", 24,100 → "24.1k",
435
- // 142 → "142". Dropped (in) = Freed + Kept; Freed = rt.tokensSaved (session)
436
- // / repo.tokensSaved meta (repo); Kept = totalTokenEstimate (stored).
437
- const fmt = (x: number) =>
438
- x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
439
- : x >= 1000 ? `${(x / 1000).toFixed(1)}k`
440
- : `${Math.round(x)}`;
441
- // Agents view: ALWAYS show the agent line so status is visible even when
442
- // idle (previously hidden at 0). 🤖 N agents when active, dimmed 🤖 idle
443
- // when none — this is the restored "agents view" (count + status). Real
444
- // per-agent/sub-agent token usage is scoped in Sprint 27.
530
+ // Agents view: count + status (S27 per-agent tokens are gated on P0).
445
531
  const agentLabel = this.activeAgents > 0
446
532
  ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
447
533
  : `${C.dim}🤖 idle${C.reset}`;
448
534
  const agentStr = ` │ ${agentLabel}`;
449
535
  const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
450
- // Phase 3 pulsing status glyph while a compaction is in flight.
451
- const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
452
- // --- reconciled in/out view (session + repo) ---------------------------
536
+ // Reconciled in/out view (session + repo) ONE canonical formula.
453
537
  const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
454
538
  const sessKept = st.totalTokenEstimate;
455
- const sessFreed = this.rt.tokensSaved;
456
- const sessPct = sessIn > 0 ? sessFreed / sessIn : 0;
539
+ const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
457
540
  const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
458
541
  const repoKept = repo.totalTokenEstimate;
459
- const repoFreed = repo.tokensSaved;
460
- const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
461
- // Retro gradient bar — `w` cells, each shaded by fill position so it
462
- // reads as a smooth green→amber→red ramp. Used for CONTEXT fill where
463
- // low=green (room to spare) and high=red (near the limit) — the only
464
- // live-moving metric worth a bar. Savings ratios saturate near 100% and
465
- // are shown as explanatory numbers instead (see L2).
466
- const ramp = (pct: number, w = 12): string => {
467
- const cells = ["▏","▎","▍","▌","▋","▊","▉","█"];
468
- const scaled = Math.max(0, Math.min(w, pct * w));
469
- const full = Math.floor(scaled);
470
- const frac = scaled - full;
471
- const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
472
- let out = "";
473
- for (let i = 0; i < full; i++) out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
474
- if (fracCell) out += (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
475
- out += C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
476
- return out;
477
- };
478
- const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
542
+ const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
479
543
  const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
480
544
  const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
481
- // Full-width panel: read the real terminal width and pad each line with a
482
- // panel background so the above-editor widget reads as a full-width status
483
- // bar. pi's widget renderer does not pass width to setWidget(), so we pad
484
- // ourselves. Falls back to 200 cols when stdout.columns is unavailable.
485
- const W = process.stdout?.columns ?? 200;
486
- const lines = [
487
- // top border full-width hairline
488
- panelBar(W, "─"),
489
- // L1 header: tier + ctx-fill bar (20-cell, green=room→red=full) +
490
- // tokens + status glyph + checkpoints + agents/turn. The context bar is
491
- // the only live-moving bar; the whole block is padded to full width.
492
- 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),
493
- // L2 savings EXPLAINED, not bar'd. The freed/(freed+kept) ratio
494
- // saturates near 100% once cumulative freed dwarfs live kept, so a bar
495
- // is visually useless; show the compaction story instead.
496
- 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),
497
- ];
498
- // Live "now processing" line + why + recent deduped/compacted events,
499
- // collapsed to ONE rotating line (fresh only); padded to full width.
500
- const fresh = Date.now() - this.lastActivityAt < 4000;
501
- if (this.tierTrace && fresh) {
502
- lines.push(panelLine(` ${pulse}${this.tierTrace}`, W));
503
- } else if (this.ticker.length > 0) {
504
- const step = Math.floor(Date.now() / 250);
505
- const idx = this.ticker.length - 1 - (step % this.ticker.length);
506
- const head = this.ticker[idx].text;
507
- const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
508
- const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
509
- lines.push(panelLine(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`, W));
510
- } else if (this.pulsing) {
511
- lines.push(panelLine(` ${pulse}${C.teal}compacting…${C.reset}`, W));
512
- }
513
- // bottom border — full-width hairline closes the panel
514
- lines.push(panelBar(W, "─"));
515
- // (Accounting folded into L2's "in→kept (X% freed)" framing — freed =
516
- // in kept is implied, and the saturated-ratio bars are gone.)
517
- ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
545
+ const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
546
+ // Model + provider (S26 capture) for the header.
547
+ const modelName = modelSnap?.modelName ?? modelSnap?.modelId ?? "?";
548
+ const modelStr = modelSnap?.provider ? `${modelName}·${modelSnap.provider}` : modelName;
549
+ // Since-last-compact (ms; null until first compaction this session).
550
+ const sinceCompact = this.rt.lastCompactAt != null ? Date.now() - this.rt.lastCompactAt : null;
551
+ // Memory store: embedder + compression ratio (original / stored).
552
+ const embedderName = this.embedderName();
553
+ const compRatio = st.originalTokens > 0 && st.totalTokenEstimate > 0
554
+ ? st.originalTokens / st.totalTokenEstimate
555
+ : (st.originalTokens > 0 ? 1 : 0);
556
+ const compStr = compRatio >= 1 ? `${compRatio.toFixed(1)}x` : "—";
557
+ // Cross-repo drift status (cached, read-only).
558
+ const driftStatus = this.driftStatus();
559
+ const agentsActive = this.activeAgents > 0;
560
+
561
+ this.widgetData = {
562
+ version: ownVersion(),
563
+ tierLabel, triggerLabel, pctStr, tokStr, maxStr, ctxPct,
564
+ chk: st.checkpointCount, agentStr, turnStr, dedupStr,
565
+ sessIn, sessKept, sTxt, repoIn, repoKept, rTxt,
566
+ repoChk: repo.checkpointCount, repoSess: repo.sessionCount,
567
+ modelStr, sinceCompact, embedderName, compStr, driftStatus, agentsActive,
568
+ fresh: Date.now() - this.lastActivityAt < 4000,
569
+ ticker: this.ticker, lastWhy: this.lastWhy, tierTrace: this.tierTrace, pulsing: this.pulsing,
570
+ };
571
+ // Auto-fit: register a factory so pi re-renders the panel at the REAL
572
+ // terminal width every frame (tui.columns), instead of guessing with
573
+ // process.stdout.columns. buildWidgetLines reads this.widgetData live.
574
+ this.renderWidget(ctx);
575
+ }
576
+ }
577
+
578
+ /** Register the above-editor widget as a width-aware factory so pi re-renders
579
+ * it at the REAL terminal width every frame (auto-fit wide/narrow). The
580
+ * factory returns a minimal Component whose render() reads this.widgetData.
581
+ */
582
+ private renderWidget(ctx: ExtensionContext): void {
583
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, _theme) => ({
584
+ render: (width: number) => this.buildWidgetLines(width > 0 ? width : 200),
585
+ invalidate: () => {},
586
+ }), { placement: "aboveEditor" });
587
+ }
588
+
589
+ /** Build the full-width panel lines from the latest snapshot. Cheap: reads
590
+ * only this.widgetData + a couple of live counters; no DB/IO. */
591
+ private buildWidgetLines(width: number): string[] {
592
+ const wd = this.widgetData;
593
+ if (!wd) {
594
+ return [panelBar(width, "─"), panelLine(" mega-compact: warming up…", width), panelBar(width, "─")];
595
+ }
596
+ const pulse = wd.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
597
+ const lines: string[] = [
598
+ // top border
599
+ panelBar(width, "─"),
600
+ // L1 — header: tier + ctx bar + pct/tokens + status + model + chk + agents/turn
601
+ spreadGroups(width, [
602
+ `${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}`,
603
+ wd.triggerLabel,
604
+ `${C.cyan}${wd.modelStr}${C.reset}`,
605
+ `${wd.chk} chk${wd.agentStr}${wd.turnStr}`,
606
+ ]),
607
+ // L2 — savings reconciled (session + all-time)
608
+ spreadGroups(width, [
609
+ `${C.magenta}dup ${wd.dedupStr}${C.reset}`,
610
+ `${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}`,
611
+ `${wd.repoChk} chk/${wd.repoSess} sess`,
612
+ ]),
613
+ // L3 — memory store + compression + drift + since-compact (NEW)
614
+ spreadGroups(width, [
615
+ `${C.gray}mem${C.reset} ${wd.embedderName} · ${wd.chk} chunks · ${C.blue}comp ${wd.compStr}${C.reset}`,
616
+ `${C.gray}drift${C.reset} ${wd.driftStatus === "ok" ? C.green : C.amber}${wd.driftStatus}${C.reset}`,
617
+ `${C.gray}compact${C.reset} ${sinceCompactStr(wd.sinceCompact)}`,
618
+ ]),
619
+ ];
620
+ // L4 — agents block (S27, count + status; per-agent tokens gated on P0)
621
+ if (wd.agentsActive) {
622
+ lines.push(panelLine(` ${C.cyan}🤖 ${this.activeAgents} active${wd.turnStr}${C.reset}`, width));
623
+ }
624
+ // L5 — live ticker / activity (♻ deduped … why, or tier trace, or pulsing)
625
+ if (wd.tierTrace && wd.fresh) {
626
+ lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width));
627
+ } else if (wd.ticker.length > 0) {
628
+ const step = Math.floor(Date.now() / 250);
629
+ const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
630
+ const head = wd.ticker[idx].text;
631
+ const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
632
+ const more = wd.ticker.length > 1 ? ` ${C.dim}(+${wd.ticker.length - 1} more)${C.reset}` : "";
633
+ lines.push(panelLine(` ${wd.fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`, width));
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
+
642
+ /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
643
+ private embedderName(): string {
644
+ // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
645
+ // the embedder factory uses so the label matches what's actually running.
646
+ return process.env.MEGACOMPACT_MINILM === "true" || process.env.MEGACOMPACT_MINILM === "1"
647
+ ? "MiniLM"
648
+ : "Trigram";
649
+ }
650
+
651
+ /** Cross-repo drift status (ok | warn), cached for 30s (opens the registry DB). */
652
+ private driftStatus(): "ok" | "warn" {
653
+ const now = Date.now();
654
+ if (this.driftCache && now - this.driftCache.at < 30_000) return this.driftCache.status;
655
+ let status: "ok" | "warn" = "ok";
656
+ try {
657
+ const report = detectCrossRepoDrift();
658
+ status = report.totals.warn > 0 ? "warn" : "ok";
659
+ } catch {
660
+ status = "ok";
518
661
  }
662
+ this.driftCache = { at: now, status };
663
+ return status;
519
664
  }
520
665
 
521
666
  setStatus(ctx: ExtensionContext, text: string | undefined): void {
@@ -535,6 +680,7 @@ export class MegaRuntime {
535
680
  dedupSkips: 0,
536
681
  dedupAttempts: 0,
537
682
  tokensSaved: 0,
683
+ lastCompactAt: null,
538
684
  };
539
685
  this.statusKey = undefined;
540
686
  this.activeAgents = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",