pi-mega-compact 0.7.2 → 0.7.4

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.
@@ -19,9 +19,24 @@ import { VectorStore } from "../src/vectorStore.js";
19
19
  import { toEngineMessages } from "../src/adapt.js";
20
20
  import { normalizeSessionId } from "../src/store.js";
21
21
  import { Logger } from "../src/log.js";
22
- import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, type ModelSnapshot } from "../src/store/sqlite.js";
22
+ import {
23
+ recordModelSnapshot,
24
+ latestModelSnapshot,
25
+ upsertRepoRegistry,
26
+ recordRepoModel,
27
+ type ModelSnapshot,
28
+ } from "../src/store/sqlite.js";
23
29
  import { detectCrossRepoDrift } from "../src/driftDetection.js";
24
- import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens, type MegaConfig, type PressureBand } from "./mega-config.js";
30
+ import {
31
+ repoStateDir,
32
+ resolveRepoRoot,
33
+ pressureRatio,
34
+ pressureFromPct,
35
+ pressureBand,
36
+ effectiveThresholdTokens,
37
+ type MegaConfig,
38
+ type PressureBand,
39
+ } from "./mega-config.js";
25
40
  import { Dashboard, type DashboardSnapshot } from "./mega-dashboard.js";
26
41
 
27
42
  export const STATUS_KEY = "mega-compact";
@@ -31,47 +46,50 @@ export const MARKER_TYPE = "mega-compact-marker";
31
46
  /** Cached npm version, read once from this extension's own package.json. */
32
47
  let CACHED_VERSION: string | null = null;
33
48
  function ownVersion(): string {
34
- if (CACHED_VERSION !== null) return CACHED_VERSION;
35
- let v = "?";
36
- try {
37
- const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
38
- const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
39
- v = pkg.version ?? "?";
40
- } catch {
41
- v = "?";
42
- }
43
- CACHED_VERSION = v;
44
- return v;
49
+ if (CACHED_VERSION !== null) return CACHED_VERSION;
50
+ let v = "?";
51
+ try {
52
+ const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
53
+ const pkg = JSON.parse(
54
+ readFileSync(join(here, "..", "package.json"), "utf-8"),
55
+ );
56
+ v = pkg.version ?? "?";
57
+ } catch {
58
+ v = "?";
59
+ }
60
+ CACHED_VERSION = v;
61
+ return v;
45
62
  }
46
63
 
47
64
  /** Per-session runtime state kept in the closure (mirrors neuralwatt-mcr). */
48
65
  interface SessionRuntime {
49
- sessionId: string;
50
- persistedThisSession: boolean;
51
- lastCheckpointId: string | undefined;
52
- lastCompactedFrom: number;
53
- lastCompactedTokens: number;
54
- dedupSkips: number; // compactions skipped because regionHash already stored
55
- dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
56
- tokensSaved: number; // this session-instance only: reset on session_start
57
- lastCompactAt: number | null; // wall-clock ms of the last compaction this session
66
+ sessionId: string;
67
+ persistedThisSession: boolean;
68
+ lastCheckpointId: string | undefined;
69
+ lastCompactedFrom: number;
70
+ lastCompactedTokens: number;
71
+ dedupSkips: number; // compactions skipped because regionHash already stored
72
+ dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
73
+ tokensSaved: number; // this session-instance only: reset on session_start
74
+ lastCompactAt: number | null; // wall-clock ms of the last compaction this session
75
+ lastNativeCompactAt: number | null; // COMPACT-DEDUP FIX: wall-clock ms of the last NATIVE pi compaction (session_compact event) — used by the agent_end/legacy race guard to skip a redundant ctx.compact() that would throw "Already compacted".
58
76
  }
59
77
 
60
78
  /** ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
61
79
  * escape codes (see wrapTextWithAnsi), so raw escapes render as colors. No
62
80
  * chalk dependency needed — these are just strings. */
63
81
  export const C = {
64
- reset: "\x1b[0m",
65
- dim: "\x1b[2m",
66
- bold: "\x1b[1m",
67
- amber: "\x1b[38;5;214m", // tier / ready
68
- green: "\x1b[38;5;120m", // saved
69
- cyan: "\x1b[38;5;51m", // used / live activity
70
- teal: "\x1b[38;5;37m", // processing (compress/dedup)
71
- magenta: "\x1b[38;5;201m", // dedup rate
72
- blue: "\x1b[38;5;75m", // repo totals
73
- gray: "\x1b[38;5;245m", // labels
74
- red: "\x1b[38;5;203m", // pressure / overflow
82
+ reset: "\x1b[0m",
83
+ dim: "\x1b[2m",
84
+ bold: "\x1b[1m",
85
+ amber: "\x1b[38;5;214m", // tier / ready
86
+ green: "\x1b[38;5;120m", // saved
87
+ cyan: "\x1b[38;5;51m", // used / live activity
88
+ teal: "\x1b[38;5;37m", // processing (compress/dedup)
89
+ magenta: "\x1b[38;5;201m", // dedup rate
90
+ blue: "\x1b[38;5;75m", // repo totals
91
+ gray: "\x1b[38;5;245m", // labels
92
+ red: "\x1b[38;5;203m", // pressure / overflow
75
93
  };
76
94
 
77
95
  const PULSE = ["◐", "◓", "◑", "◒"];
@@ -88,729 +106,915 @@ const PANEL_RST = "\x1b[0m" + PANEL_BG; // reset fg but retain panel bg
88
106
 
89
107
  /** Visible cell width of a string, ignoring ANSI SGR/OSC escapes. */
90
108
  function visibleWidth(s: string): number {
91
- const stripped = s
92
- .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")
93
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
94
- let w = 0;
95
- for (const ch of stripped) {
96
- const cp = ch.codePointAt(0) ?? 0;
97
- const wide = cp >= 0x1100 && (
98
- (cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0x303e) ||
99
- (cp >= 0x3041 && cp <= 0x33ff) || (cp >= 0x3400 && cp <= 0x4dbf) ||
100
- (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0xa000 && cp <= 0xa4cf) ||
101
- (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
102
- (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
103
- (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) ||
104
- (cp >= 0x20000 && cp <= 0x3fffd)
105
- );
106
- w += wide ? 2 : 1;
107
- }
108
- return w;
109
+ const stripped = s
110
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")
111
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
112
+ let w = 0;
113
+ for (const ch of stripped) {
114
+ const cp = ch.codePointAt(0) ?? 0;
115
+ const wide =
116
+ cp >= 0x1100 &&
117
+ (cp <= 0x115f ||
118
+ (cp >= 0x2e80 && cp <= 0x303e) ||
119
+ (cp >= 0x3041 && cp <= 0x33ff) ||
120
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
121
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
122
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
123
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
124
+ (cp >= 0xf900 && cp <= 0xfaff) ||
125
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
126
+ (cp >= 0xff00 && cp <= 0xff60) ||
127
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
128
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
129
+ (cp >= 0x20000 && cp <= 0x3fffd));
130
+ w += wide ? 2 : 1;
131
+ }
132
+ return w;
109
133
  }
110
134
 
111
135
  /** 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);
136
+ /** Wrap a string (with ANSI codes) to fit within `maxWidth` visible chars.
137
+ * Splits at separators or whitespace when possible. */
138
+ function wrapLine(text: string, maxWidth: number): string[] {
139
+ if (maxWidth <= 0) return [text];
140
+ const result: string[] = [];
141
+ let current = "";
142
+ let currentW = 0;
143
+ // Split at boundaries first
144
+ const segments = text.split("│");
145
+ for (let i = 0; i < segments.length; i++) {
146
+ const seg = (i > 0 ? "│" : "") + segments[i];
147
+ const segW = visibleWidth(PANEL_BG + seg.replace(/\x1b\[0m/g, PANEL_RST));
148
+ if (currentW + segW <= maxWidth || currentW === 0) {
149
+ current += seg;
150
+ currentW += segW;
151
+ } else {
152
+ result.push(current);
153
+ current = seg;
154
+ currentW = segW;
155
+ }
156
+ }
157
+ if (current) result.push(current);
158
+ return result;
133
159
  }
134
160
 
135
161
  function panelLine(content: string, width: number): string {
136
- const withBg = PANEL_BG + content.replace(/\x1b\[0m/g, PANEL_RST);
137
- const pad = Math.max(0, width - visibleWidth(withBg));
138
- return withBg + " ".repeat(pad) + "\x1b[0m";
162
+ const withBg = PANEL_BG + content.replace(/\x1b\[0m/g, PANEL_RST);
163
+ const pad = Math.max(0, width - visibleWidth(withBg));
164
+ return withBg + " ".repeat(pad) + "\x1b[0m";
139
165
  }
140
166
 
141
167
  /** A full-width hairline bar (top/bottom border of the panel). */
142
168
  function panelBar(width: number, ch = "─"): string {
143
- return PANEL_BG + ch.repeat(Math.max(0, width)) + "\x1b[0m";
169
+ return PANEL_BG + ch.repeat(Math.max(0, width)) + "\x1b[0m";
144
170
  }
145
171
 
146
172
  /** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
147
173
  * 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
148
174
  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)}`;
175
+ return x >= 1_000_000
176
+ ? `${(x / 1_000_000).toFixed(1)}mil`
177
+ : x >= 1000
178
+ ? `${(x / 1000).toFixed(1)}k`
179
+ : `${Math.round(x)}`;
152
180
  }
153
181
 
154
182
  /** Retro gradient bar — `w` cells shaded by fill position (green→amber→red).
155
183
  * Used for CONTEXT fill where low=green (room) and high=red (near the limit). */
156
184
  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;
185
+ const cells = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
186
+ const scaled = Math.max(0, Math.min(w, pct * w));
187
+ const full = Math.floor(scaled);
188
+ const frac = scaled - full;
189
+ const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
190
+ let out = "";
191
+ for (let i = 0; i < full; i++)
192
+ out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
193
+ if (fracCell)
194
+ out +=
195
+ (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
196
+ out +=
197
+ C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
198
+ return out;
167
199
  }
168
200
 
169
201
  /** Human "time since" string from a millisecond delta (or null → "never"). */
170
202
  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`;
203
+ if (ms == null) return "never";
204
+ const s = Math.floor(ms / 1000);
205
+ if (s < 60) return `${s}s ago`;
206
+ const m = Math.floor(s / 60);
207
+ if (m < 60) return `${m}m ago`;
208
+ const h = Math.floor(m / 60);
209
+ if (h < 24) return `${h}h ago`;
210
+ return `${Math.floor(h / 24)}d ago`;
179
211
  }
180
212
 
181
- interface TickerEntry { text: string; at: number; }
213
+ interface TickerEntry {
214
+ text: string;
215
+ at: number;
216
+ }
182
217
 
183
218
  /** Immutable snapshot of everything the above-editor widget needs to render.
184
219
  * Computed once per `snapshot()` (event-driven) and read by `buildWidgetLines`
185
220
  * on every TUI render frame, so frame rendering stays allocation-cheap and the
186
221
  * panel auto-fits whatever width pi passes to the setWidget factory. */
187
222
  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;
223
+ version: string;
224
+ tierLabel: string;
225
+ triggerLabel: string;
226
+ pctStr: string;
227
+ tokStr: string;
228
+ maxStr: string;
229
+ ctxPct: number;
230
+ chk: number;
231
+ agentStr: string;
232
+ turnStr: string;
233
+ dedupStr: string;
234
+ sessIn: number;
235
+ sessKept: number;
236
+ sTxt: string;
237
+ repoIn: number;
238
+ repoKept: number;
239
+ rTxt: string;
240
+ repoChk: number;
241
+ repoSess: number;
242
+ modelStr: string;
243
+ sinceCompact: number | null;
244
+ embedderName: string;
245
+ compStr: string;
246
+ driftStatus: "ok" | "warn";
247
+ agentsActive: boolean;
248
+ fresh: boolean;
249
+ ticker: TickerEntry[];
250
+ lastWhy: string | undefined;
251
+ tierTrace: string | undefined;
252
+ pulsing: boolean;
213
253
  }
214
254
 
215
255
  export class MegaRuntime {
216
- config: MegaConfig;
217
- // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
218
- // gets its own isolated state dir. They start bound to the global default.
219
- store: VectorStore;
220
- logger: Logger;
221
- dashboard: Dashboard;
222
- activeRepoRoot: string | null = null;
223
- currentStateDir: string;
224
-
225
- // The only mutable per-session state. Reset on session_start / session_tree.
226
- rt: SessionRuntime = {
227
- sessionId: normalizeSessionId(undefined),
228
- persistedThisSession: false,
229
- lastCheckpointId: undefined,
230
- lastCompactedFrom: 0,
231
- lastCompactedTokens: 0,
232
- dedupSkips: 0,
233
- dedupAttempts: 0,
234
- tokensSaved: 0,
235
- lastCompactAt: null,
236
- };
237
- debounceUntil = 0;
238
- // S16: debounce for the agent_end resume nudge (avoid busy-loops).
239
- resumeNudgeUntil = 0;
240
- // Agent tracking for real-time widget updates
241
- activeAgents = 0;
242
- currentTurn = 0;
243
- // Recall block produced by auto-inline (resume/branch) that the next
244
- // before_agent_start should prepend to the system prompt. Unset after use.
245
- pendingRecallBlock: string | undefined;
246
- // S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
247
- // semantics; composed with the checkpoint block in before_agent_start.
248
- pendingMemoryRecallBlock: string | undefined;
249
- statusKey: string | undefined; // current status text for dashboard
250
- // Active model/provider (for real cost estimation). Captured from ctx.model
251
- // on model_select + session_start; persisted to SQL so cost + the dashboard
252
- // can read it without a live ctx.
253
- currentModel: ModelSnapshot | undefined;
254
- // Live "what it's doing right now" timestamp, used for the fresh-window.
255
- lastActivityAt = 0;
256
- // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
257
- // Built from the store's sync onTier callback during a compaction so the user
258
- // watches each tier evaluate in real time. Cleared once the outcome settles.
259
- tierTrace: string | undefined;
260
- // Phase 3 — standout toolbar state.
261
- // Recall/activity ticker: a small ring buffer (≤5) of recent compact/recall
262
- // events so the widget shows a live history instead of a single last action.
263
- ticker: TickerEntry[] = [];
264
- readonly TICKER_MAX = 5;
265
- // Pulsing status: set true while a compaction is in flight, cleared on result.
266
- pulsing = false;
267
- // S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
268
- // the current compaction. The pipeline reads this after a successful compact
269
- // to decide whether to fire `consolidateMemories` (skip the work entirely
270
- // when no memory rows changed).
271
- memoriesTouchedThisCompaction = 0;
272
- // Rolling "saved" goal for the progress bar — grows as we save more, so the
273
- // bar always has a meaningful denominator (never sits at 100% forever).
274
- savedGoal = 50_000;
275
- // Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
276
- // while fresh.
277
- lastWhy: string | undefined = undefined;
278
-
279
- // Context tracking for the dashboard (updated in the context handler).
280
- lastCtxTokens: number | null = null;
281
- lastCtxPercent: number | null = null;
282
- lastCtxWindow = 0;
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
-
290
- /**
291
- * DIAG counters for the "team run doesn't relieve context" investigation.
292
- * Plain integers, incremented at the three compaction decision points. They
293
- * let a headless test drive the real event handlers and assert the firing
294
- * cadence without scraping log files. Inert in production (the live-trim and
295
- * before-compact probes also emit logger.info, but these counters are always
296
- * updated and cost nothing).
297
- */
298
- diagLiveTrimFires = 0; // context handler returned a trimmed view
299
- diagBeforeCompactFires = 0; // session_before_compact handler entered
300
- diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
301
- diagAgentEndIdle = 0; // agent_end with activeAgents===0
302
- diagAgentEndDurable = 0; // agent_end fired ctx.compact() (mid-run durable trim)
303
- // Per-skip-path counters for the team-run diagnosis.
304
- diagCtxFastGate = 0; // returned at token fast-gate (below threshold)
305
- diagCtxNoCompact = 0; // autoCompactCheck().shouldCompact === false
306
- diagCtxDebounce = 0; // debounceUntil not yet elapsed
307
- diagCtxRunSkipped = 0; // runCompact() returned skipped
308
- diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
309
- diagCtxThrown = 0; // live-trim try threw (caught)
310
-
311
- /**
312
- * S26 capture instrumentation: the "model_snapshots empty → $0.00 cost card"
313
- * bug was invisible because captureModel swallowed the DB write in a silent
314
- * `catch {}`. These always-updated counters (zero cost) let a headless test or
315
- * a live capture tell whether captureModel ran and whether the snapshot landed.
316
- */
317
- diagCaptureModelCalls = 0; // captureModel entered with a populated ctx.model
318
- diagCaptureModelFails = 0; // recordModelSnapshot threw → model_snapshots stays empty
319
-
320
- /**
321
- * Live 0–1 pressure — how full the context window is relative to the
322
- * compaction threshold.
323
- *
324
- * RECONCILE (BACKLOG dual-basis flicker): when the model context window is
325
- * known we base pressure consistently on the *percentage* basis
326
- * (`lastCtxPercent / (tierPct*100)`). This keeps the band stable whether the
327
- * latest context event carried a token count or only a percentage, so the
328
- * threshold comparison doesn't jump when a token-count event arrives vs a
329
- * percent-only event. We only fall back to the token-count basis
330
- * (`config.thresholdTokens`) when the window is unknown (e.g. before the first
331
- * context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
332
- */
333
- get pressure(): number {
334
- if (this.lastCtxWindow > 0 && this.config.tierPct != null && this.lastCtxPercent != null) {
335
- // pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
336
- // exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
337
- // fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
338
- // token-based pressureRatio(currentTokens, effectiveThreshold) reading so
339
- // the band doesn't jump when a token-count vs percent-only event arrives.
340
- return pressureFromPct(this.lastCtxPercent / this.config.tierPct);
341
- }
342
- if (this.lastCtxTokens != null && this.lastCtxTokens > 0 && this.config.thresholdTokens > 0) {
343
- return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
344
- }
345
- return pressureFromPct(this.lastCtxPercent);
346
- }
347
-
348
- /**
349
- * The live compaction FIRE POINT in tokens: the effective threshold scaled by
350
- * the current model context window (`tierPct * window`) when known, else the
351
- * boot fallback `config.thresholdTokens`. This is what the FAST GATE /
352
- * `autoCompactCheck` / agent_end durable-trigger compare against, so
353
- * compaction fires at tier% of the window for ANY model size (200k or 1M),
354
- * always below pi's native auto-compaction (~80% of window).
355
- */
356
- get effectiveThreshold(): number {
357
- return effectiveThresholdTokens({
358
- tierPct: this.config.tierPct,
359
- fallbackThreshold: this.config.thresholdTokens,
360
- window: this.lastCtxWindow,
361
- });
362
- }
363
-
364
- /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
365
- get pressureBand(): PressureBand {
366
- return pressureBand(this.pressure);
367
- }
368
-
369
- constructor(config: MegaConfig) {
370
- this.config = config;
371
- this.store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
372
- this.logger = new Logger({ enabled: config.debug, path: join(config.stateDir, "mega-compact.log") });
373
- this.dashboard = new Dashboard(config.stateDir);
374
- this.currentStateDir = config.stateDir;
375
- }
376
-
377
- // ---- per-repo binding -----------------------------------------------------
378
-
379
- /**
380
- * Point store/dashboard/logger at the current repo's state dir. Rebuilds the
381
- * instances only when the repo root changes, so cross-repo dedup stats, db,
382
- * and events are fully isolated. Falls back to the global default outside git.
383
- */
384
- bindRepo(cwd: string | undefined): string {
385
- const dir = cwd ? repoStateDir(cwd, this.config.stateDir) : this.config.stateDir;
386
- const key = cwd ? resolveRepoRoot(cwd) ?? dir : dir;
387
- if (key === this.activeRepoRoot) return dir;
388
- this.activeRepoRoot = key;
389
- this.currentStateDir = dir;
390
- this.store = new VectorStore({ dedupSim: this.config.dedupSim, stateDir: dir });
391
- this.logger = new Logger({ enabled: this.config.debug, path: join(dir, "mega-compact.log") });
392
- this.dashboard = new Dashboard(dir);
393
- // Aggregate this repo into the machine-wide index so the multi-repo
394
- // dashboard (Summary / All-repos tabs) can show it alongside every other
395
- // repo. Best-effort + non-fatal: a read-only index dir or contention must
396
- // never break the per-repo compaction path. Runs only on repo-switch
397
- // (this branch), so it's infrequent not per-context-event.
398
- try {
399
- const repo = this.store.repoStats();
400
- const di = this.store.dataInvariant();
401
- const root = key !== dir ? key : resolveRepoRoot(cwd ?? dir) ?? dir;
402
- upsertRepoRegistry({
403
- repoRoot: root,
404
- displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
405
- stateDir: dir,
406
- checkpointCount: repo.checkpointCount,
407
- tokensSaved: repo.tokensSaved,
408
- compressedOriginalBytes: di.compressedOriginalBytes,
409
- });
410
- } catch {
411
- /* non-fatal: index aggregation must not block compaction */
412
- }
413
- return dir;
414
- }
415
-
416
- // ---- dashboard snapshot + widget ------------------------------------------
417
-
418
- /** Collect live state and write it to disk (+ paint the above-editor widget). */
419
- snapshot(ctx?: ExtensionContext): void {
420
- if (ctx) this.bindRepo(ctx.cwd);
421
- const st = this.store.stats(this.rt.sessionId);
422
- const repo = this.store.repoStats();
423
- const di = this.store.dataInvariant();
424
- // Active model/provider for the current-repo card + the multi-repo table.
425
- const modelSnap = latestModelSnapshot(this.currentStateDir);
426
- const model = modelSnap
427
- ? {
428
- name: modelSnap.modelName ?? modelSnap.modelId,
429
- provider: modelSnap.provider,
430
- providerName: modelSnap.providerName ?? "",
431
- inputRate: modelSnap.inputRate,
432
- outputRate: modelSnap.outputRate,
433
- }
434
- : undefined;
435
- // effectiveThresholdPct: the live fire point as a % of the window (null for
436
- // `custom`, which has no tierPct). Used by armed/ready + the dashboard.
437
- const effectiveThresholdPct = this.config.tierPct != null ? this.config.tierPct * 100 : null;
438
- // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
439
- // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
440
- // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
441
- const armed = this.lastCtxPercent != null && this.lastCtxPercent >= Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
442
- const ready = armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
443
- this.dashboard.snapshot({
444
- version: 1,
445
- updatedAt: new Date().toISOString(),
446
- // S24: the headline tier is the LIVE pressure band; the env preset is kept
447
- // alongside as presetTier so the dashboard can show both.
448
- tier: this.pressureBand,
449
- presetTier: this.config.tier,
450
- pressure: this.pressure,
451
- config: {
452
- fastGatePct: this.config.fastGatePct,
453
- thresholdTokens: this.effectiveThreshold,
454
- tierPct: this.config.tierPct,
455
- effectiveThresholdPct,
456
- anchorUserMessages: this.config.anchorUserMessages,
457
- preserveRecent: this.config.preserveRecent,
458
- auto: this.config.auto,
459
- autoInline: this.config.autoInline,
460
- },
461
- session: {
462
- id: this.rt.sessionId,
463
- state: this.statusKey ?? "idle",
464
- persistedThisSession: this.rt.persistedThisSession,
465
- lastCheckpointId: this.rt.lastCheckpointId ?? null,
466
- lastCompactedFrom: this.rt.lastCompactedFrom,
467
- lastCompactedTokens: this.rt.lastCompactedTokens,
468
- dedupSkips: this.rt.dedupSkips,
469
- dedupAttempts: this.rt.dedupAttempts,
470
- },
471
- context: { tokens: this.lastCtxTokens, percent: this.lastCtxPercent, contextWindow: this.lastCtxWindow },
472
- trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.effectiveThreshold, fastGatePct: this.config.fastGatePct, tierPct: this.config.tierPct, effectiveThresholdPct },
473
- crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
474
- 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 },
475
- // Reconciled token accounting (single canonical formula, session + repo).
476
- // Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
477
- // deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
478
- compression: {
479
- session: {
480
- tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
481
- tokensOut: st.totalTokenEstimate,
482
- tokensFreed: this.rt.tokensSaved,
483
- compressionPct: (this.rt.tokensSaved + st.totalTokenEstimate) > 0 ? this.rt.tokensSaved / (this.rt.tokensSaved + st.totalTokenEstimate) : 0,
484
- dedupPct: st.storageDedupRate,
485
- },
486
- repo: {
487
- tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
488
- tokensOut: repo.totalTokenEstimate,
489
- tokensFreed: repo.tokensSaved,
490
- compressionPct: (repo.tokensSaved + repo.totalTokenEstimate) > 0 ? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate) : 0,
491
- dedupPct: repo.storageDedupRate,
492
- },
493
- },
494
- repo: {
495
- checkpointCount: repo.checkpointCount,
496
- totalTokenEstimate: repo.totalTokenEstimate,
497
- originalTokens: repo.originalTokens,
498
- tokensSaved: repo.tokensSaved,
499
- sessionCount: repo.sessionCount,
500
- dedupAttempts: repo.dedupAttempts,
501
- dedupCollapsed: repo.dedupCollapsed,
502
- storageDedupRate: repo.storageDedupRate,
503
- },
504
- integrity: {
505
- regionsRetained: di.regionsRetained,
506
- compressedOriginalBytes: di.compressedOriginalBytes,
507
- duplicatesCollapsed: di.duplicatesCollapsed,
508
- bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
509
- },
510
- model,
511
- } as DashboardSnapshot);
512
-
513
- // Live stats widget above the editor
514
- if (ctx) {
515
- // ── gather widget data (computed per snapshot, rendered per frame) ────
516
- const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
517
- const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
518
- const pctStr = this.lastCtxPercent != null ? `${Math.round(this.lastCtxPercent * 10) / 10}%` : "?%";
519
- // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
520
- // mega), not the static env preset. It climbs as context fills.
521
- const liveBand = this.pressureBand;
522
- const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
523
- const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
524
- // Storage dedup rate is cumulative (store-wide, per-repo) and survives
525
- // session resets. Always show a number (decimal for sub-10%).
526
- const storageRate = st.storageDedupRate; // 0..1
527
- const dedupStr = storageRate * 100 >= 10
528
- ? `${Math.round(storageRate * 100)}%`
529
- : `${(storageRate * 100).toFixed(1)}%`;
530
- // Agents view: count + status (S27 per-agent tokens are gated on P0).
531
- const agentLabel = this.activeAgents > 0
532
- ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
533
- : `${C.dim}🤖 idle${C.reset}`;
534
- const agentStr = ` │ ${agentLabel}`;
535
- const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
536
- // Reconciled in/out view (session + repo) — ONE canonical formula.
537
- const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
538
- const sessKept = st.totalTokenEstimate;
539
- const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
540
- const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
541
- const repoKept = repo.totalTokenEstimate;
542
- const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
543
- const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
544
- const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
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";
661
- }
662
- this.driftCache = { at: now, status };
663
- return status;
664
- }
665
-
666
- setStatus(ctx: ExtensionContext, text: string | undefined): void {
667
- this.statusKey = text;
668
- ctx.ui.setStatus(STATUS_KEY, text);
669
- }
670
-
671
- resetRuntime(sessionId: string | undefined): void {
672
- const sid = normalizeSessionId(sessionId);
673
- if (this.rt.sessionId === sid && this.rt.persistedThisSession) return; // same session, keep checkpoint memory
674
- this.rt = {
675
- sessionId: sid,
676
- persistedThisSession: false,
677
- lastCheckpointId: undefined,
678
- lastCompactedFrom: 0,
679
- lastCompactedTokens: 0,
680
- dedupSkips: 0,
681
- dedupAttempts: 0,
682
- tokensSaved: 0,
683
- lastCompactAt: null,
684
- };
685
- this.statusKey = undefined;
686
- this.activeAgents = 0;
687
- this.currentTurn = 0;
688
- this.lastActivityAt = 0;
689
- this.tierTrace = undefined;
690
- this.ticker.length = 0;
691
- this.pulsing = false;
692
- this.savedGoal = 50_000;
693
- this.lastWhy = undefined;
694
- }
695
-
696
- /**
697
- * Capture the active model/provider from ctx.model and persist it so cost
698
- * estimation + the dashboard can read real pricing. Cheap + idempotent-ish:
699
- * only writes a new row when the model id changes (models change rarely).
700
- */
701
- captureModel(ctx: ExtensionContext): void {
702
- const m = ctx.model;
703
- if (!m) { this.appendEvent("captureModel:no-model", { cwd: ctx.cwd }); return; }
704
- if (this.currentModel && this.currentModel.modelId === m.id && this.currentModel.provider === m.provider) return;
705
- let providerName: string | null = null;
706
- try { providerName = ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null; } catch { /* optional */ }
707
- const snap: Omit<ModelSnapshot, "capturedAt"> = {
708
- provider: m.provider,
709
- providerName,
710
- modelId: m.id,
711
- modelName: m.name ?? null,
712
- inputRate: m.cost?.input ?? 0,
713
- outputRate: m.cost?.output ?? 0,
714
- contextWindow: m.contextWindow ?? 0,
715
- maxTokens: m.maxTokens ?? 0,
716
- reasoning: !!m.reasoning,
717
- };
718
- this.currentModel = { ...snap, capturedAt: Date.now() };
719
- this.diagCaptureModelCalls++;
720
- const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
721
- // S26: previously a single silent `catch {}` hid every capture failure, so
722
- // model_snapshots stayed empty and the cost card read $0.00 with zero signal.
723
- // Split per-write + append to events.log (always-on, dashboard live-streams
724
- // it) + bump a DIAG counter so a live capture surfaces the root cause.
725
- try {
726
- recordModelSnapshot(repo, snap, this.currentStateDir);
727
- this.appendEvent("captureModel:recorded", {
728
- repo, modelId: snap.modelId, provider: snap.provider,
729
- inputRate: snap.inputRate, outputRate: snap.outputRate,
730
- });
731
- } catch (e) {
732
- this.diagCaptureModelFails++;
733
- this.appendEvent("captureModel:record-failed", {
734
- repo, modelId: snap.modelId,
735
- error: e instanceof Error ? e.message : String(e),
736
- stack: e instanceof Error ? e.stack : undefined,
737
- });
738
- }
739
- try {
740
- // Denormalize the active model into the machine-wide index so the
741
- // All-repos dashboard table can show provider/model per repo without
742
- // opening every repo's DB. Best-effort + non-fatal.
743
- recordRepoModel(repo, {
744
- provider: snap.provider,
745
- providerName: snap.providerName,
746
- modelName: snap.modelName,
747
- inputRate: snap.inputRate,
748
- outputRate: snap.outputRate,
749
- stateDir: this.currentStateDir,
750
- displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
751
- });
752
- } catch (e) {
753
- this.appendEvent("captureModel:index-record-failed", {
754
- repo, modelId: snap.modelId,
755
- error: e instanceof Error ? e.message : String(e),
756
- });
757
- }
758
- }
759
-
760
- /**
761
- * Append a structured line to the repo's events.log — the always-on
762
- * diagnostics sink the dashboard live-streams. Unlike this.logger (gated by
763
- * config.debug), this fires in production, so capture failures surface during
764
- * a real capture even with debugging off. Best-effort + non-fatal.
765
- */
766
- private appendEvent(event: string, fields: Record<string, unknown>): void {
767
- try {
768
- mkdirSync(this.currentStateDir, { recursive: true });
769
- appendFileSync(join(this.currentStateDir, "events.log"), JSON.stringify({ ts: Date.now(), event, ...fields }) + "\n");
770
- } catch { /* non-fatal */ }
771
- }
772
-
773
- /** S21: state dir of the currently bound repo (where memories live). */
774
- getStateDir(): string {
775
- return this.currentStateDir;
776
- }
777
-
778
- /** Build the sync onTier callback that paints the live per-tier trace. */
779
- makeTierCallback(ctx: ExtensionContext): (ev: { tier: "L0" | "L1" | "L2" | "new"; status: "scanning" | "deduped" | "passed" | "stored"; detail?: string }) => void {
780
- const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
781
- const seen = new Map<string, string>();
782
- const glyph = (status: string) =>
783
- status === "deduped" ? `${C.green}✓${C.reset}` :
784
- status === "passed" ? `${C.dim}○${C.reset}` :
785
- status === "scanning" ? `${C.amber}…${C.reset}` :
786
- `${C.cyan}●${C.reset}`;
787
- return (ev) => {
788
- const label =
789
- ev.tier === "new"
790
- ? `${C.cyan}stored${C.reset}`
791
- : `${ev.tier} ${glyph(ev.status)}` +
792
- (ev.detail ? ` ${C.gray}(${ev.detail})${C.reset}` : "");
793
- // Show the most recent outcome per tier (collapses re-fires).
794
- seen.set(ev.tier, label);
795
- const show: string[] = [];
796
- for (const t of order) if (seen.has(t)) show.push(seen.get(t)!);
797
- this.tierTrace = `${C.teal}⚙${C.reset} ${show.join(` ${C.gray}→${C.reset} `)}`;
798
- this.lastActivityAt = Date.now();
799
- try { this.snapshot(ctx); } catch { /* non-fatal */ }
800
- };
801
- }
802
-
803
- // Phase 3 — recall/activity ticker ring buffer.
804
- pushTicker(text: string): void {
805
- this.ticker.push({ text, at: Date.now() });
806
- while (this.ticker.length > this.TICKER_MAX) this.ticker.shift();
807
- this.lastActivityAt = Date.now();
808
- }
809
-
810
- /** Convert the messages pi hands us in the `context` event into the engine view. */
811
- engineView(messages: AgentMessage[]): ReturnType<typeof toEngineMessages> {
812
- return toEngineMessages(messages);
813
- }
256
+ config: MegaConfig;
257
+ // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
258
+ // gets its own isolated state dir. They start bound to the global default.
259
+ store: VectorStore;
260
+ logger: Logger;
261
+ dashboard: Dashboard;
262
+ activeRepoRoot: string | null = null;
263
+ currentStateDir: string;
264
+
265
+ // The only mutable per-session state. Reset on session_start / session_tree.
266
+ rt: SessionRuntime = {
267
+ sessionId: normalizeSessionId(undefined),
268
+ persistedThisSession: false,
269
+ lastCheckpointId: undefined,
270
+ lastCompactedFrom: 0,
271
+ lastCompactedTokens: 0,
272
+ dedupSkips: 0,
273
+ dedupAttempts: 0,
274
+ tokensSaved: 0,
275
+ lastCompactAt: null,
276
+ lastNativeCompactAt: null,
277
+ };
278
+ debounceUntil = 0;
279
+ // S16: debounce for the agent_end resume nudge (avoid busy-loops).
280
+ resumeNudgeUntil = 0;
281
+ // Agent tracking for real-time widget updates
282
+ activeAgents = 0;
283
+ currentTurn = 0;
284
+ // Recall block produced by auto-inline (resume/branch) that the next
285
+ // before_agent_start should prepend to the system prompt. Unset after use.
286
+ pendingRecallBlock: string | undefined;
287
+ // S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
288
+ // semantics; composed with the checkpoint block in before_agent_start.
289
+ pendingMemoryRecallBlock: string | undefined;
290
+ statusKey: string | undefined; // current status text for dashboard
291
+ // Active model/provider (for real cost estimation). Captured from ctx.model
292
+ // on model_select + session_start; persisted to SQL so cost + the dashboard
293
+ // can read it without a live ctx.
294
+ currentModel: ModelSnapshot | undefined;
295
+ // Live "what it's doing right now" timestamp, used for the fresh-window.
296
+ lastActivityAt = 0;
297
+ // Live per-tier dedup trace (Phase 1): e.g. "L0 L1 → L2 0.91 → stored".
298
+ // Built from the store's sync onTier callback during a compaction so the user
299
+ // watches each tier evaluate in real time. Cleared once the outcome settles.
300
+ tierTrace: string | undefined;
301
+ // Phase 3 standout toolbar state.
302
+ // Recall/activity ticker: a small ring buffer (≤5) of recent compact/recall
303
+ // events so the widget shows a live history instead of a single last action.
304
+ ticker: TickerEntry[] = [];
305
+ readonly TICKER_MAX = 5;
306
+ // Pulsing status: set true while a compaction is in flight, cleared on result.
307
+ pulsing = false;
308
+ // S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
309
+ // the current compaction. The pipeline reads this after a successful compact
310
+ // to decide whether to fire `consolidateMemories` (skip the work entirely
311
+ // when no memory rows changed).
312
+ memoriesTouchedThisCompaction = 0;
313
+ // Rolling "saved" goal for the progress bar grows as we save more, so the
314
+ // bar always has a meaningful denominator (never sits at 100% forever).
315
+ savedGoal = 50_000;
316
+ // Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
317
+ // while fresh.
318
+ lastWhy: string | undefined = undefined;
319
+
320
+ // Context tracking for the dashboard (updated in the context handler).
321
+ lastCtxTokens: number | null = null;
322
+ lastCtxPercent: number | null = null;
323
+ lastCtxWindow = 0;
324
+
325
+ // Latest computed widget payload (recomputed per snapshot, rendered per frame).
326
+ widgetData: WidgetData | null = null;
327
+ // Cached cross-repo drift status (recomputed at most every 30s it opens the
328
+ // machine-wide registry DB, so we don't want to do it on every render frame).
329
+ private driftCache: { at: number; status: "ok" | "warn" } | null = null;
330
+
331
+ /**
332
+ * DIAG counters for the "team run doesn't relieve context" investigation.
333
+ * Plain integers, incremented at the three compaction decision points. They
334
+ * let a headless test drive the real event handlers and assert the firing
335
+ * cadence without scraping log files. Inert in production (the live-trim and
336
+ * before-compact probes also emit logger.info, but these counters are always
337
+ * updated and cost nothing).
338
+ */
339
+ diagLiveTrimFires = 0; // context handler returned a trimmed view
340
+ diagBeforeCompactFires = 0; // session_before_compact handler entered
341
+ diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
342
+ diagAgentEndIdle = 0; // agent_end with activeAgents===0
343
+ diagAgentEndDurable = 0; // agent_end fired ctx.compact() (mid-run durable trim)
344
+ diagAgentEndDurableSkipRecent = 0; // agent_end skipped ctx.compact() compaction in last 10s (race guard)
345
+ // Per-skip-path counters for the team-run diagnosis.
346
+ diagCtxFastGate = 0; // returned at token fast-gate (below threshold)
347
+ diagCtxNoCompact = 0; // autoCompactCheck().shouldCompact === false
348
+ diagCtxDebounce = 0; // debounceUntil not yet elapsed
349
+ diagCtxRunSkipped = 0; // runCompact() returned skipped
350
+ diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
351
+ diagCtxThrown = 0; // live-trim try threw (caught)
352
+
353
+ /**
354
+ * S26 capture instrumentation: the "model_snapshots empty $0.00 cost card"
355
+ * bug was invisible because captureModel swallowed the DB write in a silent
356
+ * `catch {}`. These always-updated counters (zero cost) let a headless test or
357
+ * a live capture tell whether captureModel ran and whether the snapshot landed.
358
+ */
359
+ diagCaptureModelCalls = 0; // captureModel entered with a populated ctx.model
360
+ diagCaptureModelFails = 0; // recordModelSnapshot threw → model_snapshots stays empty
361
+
362
+ /**
363
+ * Live 0–1 pressure — how full the context window is relative to the
364
+ * compaction threshold.
365
+ *
366
+ * RECONCILE (BACKLOG dual-basis flicker): when the model context window is
367
+ * known we base pressure consistently on the *percentage* basis
368
+ * (`lastCtxPercent / (tierPct*100)`). This keeps the band stable whether the
369
+ * latest context event carried a token count or only a percentage, so the
370
+ * threshold comparison doesn't jump when a token-count event arrives vs a
371
+ * percent-only event. We only fall back to the token-count basis
372
+ * (`config.thresholdTokens`) when the window is unknown (e.g. before the first
373
+ * context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
374
+ */
375
+ get pressure(): number {
376
+ if (
377
+ this.lastCtxWindow > 0 &&
378
+ this.config.tierPct != null &&
379
+ this.lastCtxPercent != null
380
+ ) {
381
+ // pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
382
+ // exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
383
+ // fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
384
+ // token-based pressureRatio(currentTokens, effectiveThreshold) reading so
385
+ // the band doesn't jump when a token-count vs percent-only event arrives.
386
+ return pressureFromPct(this.lastCtxPercent / this.config.tierPct);
387
+ }
388
+ if (
389
+ this.lastCtxTokens != null &&
390
+ this.lastCtxTokens > 0 &&
391
+ this.config.thresholdTokens > 0
392
+ ) {
393
+ return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
394
+ }
395
+ return pressureFromPct(this.lastCtxPercent);
396
+ }
397
+
398
+ /**
399
+ * The live compaction FIRE POINT in tokens: the effective threshold scaled by
400
+ * the current model context window (`tierPct * window`) when known, else the
401
+ * boot fallback `config.thresholdTokens`. This is what the FAST GATE /
402
+ * `autoCompactCheck` / agent_end durable-trigger compare against, so
403
+ * compaction fires at tier% of the window for ANY model size (200k or 1M),
404
+ * always below pi's native auto-compaction (~80% of window).
405
+ */
406
+ get effectiveThreshold(): number {
407
+ return effectiveThresholdTokens({
408
+ tierPct: this.config.tierPct,
409
+ fallbackThreshold: this.config.thresholdTokens,
410
+ window: this.lastCtxWindow,
411
+ });
412
+ }
413
+
414
+ /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
415
+ get pressureBand(): PressureBand {
416
+ return pressureBand(this.pressure);
417
+ }
418
+
419
+ constructor(config: MegaConfig) {
420
+ this.config = config;
421
+ this.store = new VectorStore({
422
+ dedupSim: config.dedupSim,
423
+ stateDir: config.stateDir,
424
+ });
425
+ this.logger = new Logger({
426
+ enabled: config.debug,
427
+ path: join(config.stateDir, "mega-compact.log"),
428
+ });
429
+ this.dashboard = new Dashboard(config.stateDir);
430
+ this.currentStateDir = config.stateDir;
431
+ }
432
+
433
+ // ---- per-repo binding -----------------------------------------------------
434
+
435
+ /**
436
+ * Point store/dashboard/logger at the current repo's state dir. Rebuilds the
437
+ * instances only when the repo root changes, so cross-repo dedup stats, db,
438
+ * and events are fully isolated. Falls back to the global default outside git.
439
+ */
440
+ bindRepo(cwd: string | undefined): string {
441
+ const dir = cwd
442
+ ? repoStateDir(cwd, this.config.stateDir)
443
+ : this.config.stateDir;
444
+ const key = cwd ? (resolveRepoRoot(cwd) ?? dir) : dir;
445
+ if (key === this.activeRepoRoot) return dir;
446
+ this.activeRepoRoot = key;
447
+ this.currentStateDir = dir;
448
+ this.store = new VectorStore({
449
+ dedupSim: this.config.dedupSim,
450
+ stateDir: dir,
451
+ });
452
+ this.logger = new Logger({
453
+ enabled: this.config.debug,
454
+ path: join(dir, "mega-compact.log"),
455
+ });
456
+ this.dashboard = new Dashboard(dir);
457
+ // Aggregate this repo into the machine-wide index so the multi-repo
458
+ // dashboard (Summary / All-repos tabs) can show it alongside every other
459
+ // repo. Best-effort + non-fatal: a read-only index dir or contention must
460
+ // never break the per-repo compaction path. Runs only on repo-switch
461
+ // (this branch), so it's infrequent — not per-context-event.
462
+ try {
463
+ const repo = this.store.repoStats();
464
+ const di = this.store.dataInvariant();
465
+ const root = key !== dir ? key : (resolveRepoRoot(cwd ?? dir) ?? dir);
466
+ upsertRepoRegistry({
467
+ repoRoot: root,
468
+ displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
469
+ stateDir: dir,
470
+ checkpointCount: repo.checkpointCount,
471
+ tokensSaved: repo.tokensSaved,
472
+ compressedOriginalBytes: di.compressedOriginalBytes,
473
+ });
474
+ } catch {
475
+ /* non-fatal: index aggregation must not block compaction */
476
+ }
477
+ return dir;
478
+ }
479
+
480
+ // ---- dashboard snapshot + widget ------------------------------------------
481
+
482
+ /** Collect live state and write it to disk (+ paint the above-editor widget). */
483
+ snapshot(ctx?: ExtensionContext): void {
484
+ if (ctx) this.bindRepo(ctx.cwd);
485
+ const st = this.store.stats(this.rt.sessionId);
486
+ const repo = this.store.repoStats();
487
+ const di = this.store.dataInvariant();
488
+ // Active model/provider for the current-repo card + the multi-repo table.
489
+ const modelSnap = latestModelSnapshot(this.currentStateDir);
490
+ const model = modelSnap
491
+ ? {
492
+ name: modelSnap.modelName ?? modelSnap.modelId,
493
+ provider: modelSnap.provider,
494
+ providerName: modelSnap.providerName ?? "",
495
+ inputRate: modelSnap.inputRate,
496
+ outputRate: modelSnap.outputRate,
497
+ }
498
+ : undefined;
499
+ // effectiveThresholdPct: the live fire point as a % of the window (null for
500
+ // `custom`, which has no tierPct). Used by armed/ready + the dashboard.
501
+ const effectiveThresholdPct =
502
+ this.config.tierPct != null ? this.config.tierPct * 100 : null;
503
+ // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
504
+ // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
505
+ // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
506
+ const armed =
507
+ this.lastCtxPercent != null &&
508
+ this.lastCtxPercent >=
509
+ Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
510
+ const ready = armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
511
+ this.dashboard.snapshot({
512
+ version: 1,
513
+ updatedAt: new Date().toISOString(),
514
+ // S24: the headline tier is the LIVE pressure band; the env preset is kept
515
+ // alongside as presetTier so the dashboard can show both.
516
+ tier: this.pressureBand,
517
+ presetTier: this.config.tier,
518
+ pressure: this.pressure,
519
+ config: {
520
+ fastGatePct: this.config.fastGatePct,
521
+ thresholdTokens: this.effectiveThreshold,
522
+ tierPct: this.config.tierPct,
523
+ effectiveThresholdPct,
524
+ anchorUserMessages: this.config.anchorUserMessages,
525
+ preserveRecent: this.config.preserveRecent,
526
+ auto: this.config.auto,
527
+ autoInline: this.config.autoInline,
528
+ },
529
+ session: {
530
+ id: this.rt.sessionId,
531
+ state: this.statusKey ?? "idle",
532
+ persistedThisSession: this.rt.persistedThisSession,
533
+ lastCheckpointId: this.rt.lastCheckpointId ?? null,
534
+ lastCompactedFrom: this.rt.lastCompactedFrom,
535
+ lastCompactedTokens: this.rt.lastCompactedTokens,
536
+ dedupSkips: this.rt.dedupSkips,
537
+ dedupAttempts: this.rt.dedupAttempts,
538
+ },
539
+ context: {
540
+ tokens: this.lastCtxTokens,
541
+ percent: this.lastCtxPercent,
542
+ contextWindow: this.lastCtxWindow,
543
+ },
544
+ trigger: {
545
+ armed,
546
+ ready,
547
+ currentTokens: this.lastCtxTokens,
548
+ thresholdTokens: this.effectiveThreshold,
549
+ fastGatePct: this.config.fastGatePct,
550
+ tierPct: this.config.tierPct,
551
+ effectiveThresholdPct,
552
+ },
553
+ crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
554
+ store: {
555
+ checkpointCount: st.checkpointCount,
556
+ totalTokenEstimate: st.totalTokenEstimate,
557
+ originalTokens: st.originalTokens,
558
+ tokensSaved: this.rt.tokensSaved,
559
+ injectedCount: st.injectedCount,
560
+ dedupHitRate: st.dedupHitRate,
561
+ storageDedupRate: st.storageDedupRate,
562
+ dedupAttempts: st.dedupAttempts,
563
+ dedupCollapsed: st.dedupCollapsed,
564
+ },
565
+ // Reconciled token accounting (single canonical formula, session + repo).
566
+ // Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
567
+ // deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
568
+ compression: {
569
+ session: {
570
+ tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
571
+ tokensOut: st.totalTokenEstimate,
572
+ tokensFreed: this.rt.tokensSaved,
573
+ compressionPct:
574
+ this.rt.tokensSaved + st.totalTokenEstimate > 0
575
+ ? this.rt.tokensSaved /
576
+ (this.rt.tokensSaved + st.totalTokenEstimate)
577
+ : 0,
578
+ dedupPct: st.storageDedupRate,
579
+ },
580
+ repo: {
581
+ tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
582
+ tokensOut: repo.totalTokenEstimate,
583
+ tokensFreed: repo.tokensSaved,
584
+ compressionPct:
585
+ repo.tokensSaved + repo.totalTokenEstimate > 0
586
+ ? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate)
587
+ : 0,
588
+ dedupPct: repo.storageDedupRate,
589
+ },
590
+ },
591
+ repo: {
592
+ checkpointCount: repo.checkpointCount,
593
+ totalTokenEstimate: repo.totalTokenEstimate,
594
+ originalTokens: repo.originalTokens,
595
+ tokensSaved: repo.tokensSaved,
596
+ sessionCount: repo.sessionCount,
597
+ dedupAttempts: repo.dedupAttempts,
598
+ dedupCollapsed: repo.dedupCollapsed,
599
+ storageDedupRate: repo.storageDedupRate,
600
+ },
601
+ integrity: {
602
+ regionsRetained: di.regionsRetained,
603
+ compressedOriginalBytes: di.compressedOriginalBytes,
604
+ duplicatesCollapsed: di.duplicatesCollapsed,
605
+ bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
606
+ },
607
+ model,
608
+ } as DashboardSnapshot);
609
+
610
+ // Live stats widget above the editor
611
+ if (ctx) {
612
+ // ── gather widget data (computed per snapshot, rendered per frame) ────
613
+ const tokStr =
614
+ this.lastCtxTokens != null
615
+ ? `${Math.round(this.lastCtxTokens / 1000)}k`
616
+ : "?";
617
+ const maxStr =
618
+ this.lastCtxWindow > 0
619
+ ? `${Math.round(this.lastCtxWindow / 1000)}k`
620
+ : "?";
621
+ const pctStr =
622
+ this.lastCtxPercent != null
623
+ ? `${Math.round(this.lastCtxPercent * 10) / 10}%`
624
+ : "?%";
625
+ // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
626
+ // mega), not the static env preset. It climbs as context fills.
627
+ const liveBand = this.pressureBand;
628
+ const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
629
+ const triggerLabel = ready
630
+ ? `${C.green}● ready${C.reset}`
631
+ : armed
632
+ ? `${C.amber}◐ armed${C.reset}`
633
+ : `${C.gray}○ idle${C.reset}`;
634
+ // Storage dedup rate is cumulative (store-wide, per-repo) and survives
635
+ // session resets. Always show a number (decimal for sub-10%).
636
+ const storageRate = st.storageDedupRate; // 0..1
637
+ const dedupStr =
638
+ storageRate * 100 >= 10
639
+ ? `${Math.round(storageRate * 100)}%`
640
+ : `${(storageRate * 100).toFixed(1)}%`;
641
+ // Agents view: count + status (S27 per-agent tokens are gated on P0).
642
+ const agentLabel =
643
+ this.activeAgents > 0
644
+ ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
645
+ : `${C.dim}🤖 idle${C.reset}`;
646
+ const agentStr = ` │ ${agentLabel}`;
647
+ const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
648
+ // Reconciled in/out view (session + repo) — ONE canonical formula.
649
+ const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
650
+ const sessKept = st.totalTokenEstimate;
651
+ const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
652
+ const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
653
+ const repoKept = repo.totalTokenEstimate;
654
+ const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
655
+ const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
656
+ const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
657
+ const ctxPct =
658
+ this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
659
+ // Model + provider (S26 capture) for the header.
660
+ const modelName = modelSnap?.modelName ?? modelSnap?.modelId ?? "?";
661
+ const modelStr = modelSnap?.provider
662
+ ? `${modelName}·${modelSnap.provider}`
663
+ : modelName;
664
+ // Since-last-compact (ms; null until first compaction this session).
665
+ const sinceCompact =
666
+ this.rt.lastCompactAt != null
667
+ ? Date.now() - this.rt.lastCompactAt
668
+ : null;
669
+ // Memory store: embedder + compression ratio (original / stored).
670
+ const embedderName = this.embedderName();
671
+ const compRatio =
672
+ st.originalTokens > 0 && st.totalTokenEstimate > 0
673
+ ? st.originalTokens / st.totalTokenEstimate
674
+ : st.originalTokens > 0
675
+ ? 1
676
+ : 0;
677
+ const compStr = compRatio >= 1 ? `${compRatio.toFixed(1)}x` : "—";
678
+ // Cross-repo drift status (cached, read-only).
679
+ const driftStatus = this.driftStatus();
680
+ const agentsActive = this.activeAgents > 0;
681
+
682
+ this.widgetData = {
683
+ version: ownVersion(),
684
+ tierLabel,
685
+ triggerLabel,
686
+ pctStr,
687
+ tokStr,
688
+ maxStr,
689
+ ctxPct,
690
+ chk: st.checkpointCount,
691
+ agentStr,
692
+ turnStr,
693
+ dedupStr,
694
+ sessIn,
695
+ sessKept,
696
+ sTxt,
697
+ repoIn,
698
+ repoKept,
699
+ rTxt,
700
+ repoChk: repo.checkpointCount,
701
+ repoSess: repo.sessionCount,
702
+ modelStr,
703
+ sinceCompact,
704
+ embedderName,
705
+ compStr,
706
+ driftStatus,
707
+ agentsActive,
708
+ fresh: Date.now() - this.lastActivityAt < 4000,
709
+ ticker: this.ticker,
710
+ lastWhy: this.lastWhy,
711
+ tierTrace: this.tierTrace,
712
+ pulsing: this.pulsing,
713
+ };
714
+ // Auto-fit: register a factory so pi re-renders the panel at the REAL
715
+ // terminal width every frame (tui.columns), instead of guessing with
716
+ // process.stdout.columns. buildWidgetLines reads this.widgetData live.
717
+ this.renderWidget(ctx);
718
+ }
719
+ }
720
+
721
+ /** Register the above-editor widget as a width-aware factory so pi re-renders
722
+ * it at the REAL terminal width every frame (auto-fit wide/narrow). The
723
+ * factory returns a minimal Component whose render() reads this.widgetData.
724
+ */
725
+ private renderWidget(ctx: ExtensionContext): void {
726
+ ctx.ui.setWidget(
727
+ WIDGET_KEY,
728
+ (_tui, _theme) => ({
729
+ render: (width: number) =>
730
+ this.buildWidgetLines(width > 0 ? width : 200),
731
+ invalidate: () => {},
732
+ }),
733
+ { placement: "aboveEditor" },
734
+ );
735
+ }
736
+
737
+ /** Build the full-width panel lines from the latest snapshot. Cheap: reads
738
+ * only this.widgetData + a couple of live counters; no DB/IO. */
739
+ private buildWidgetLines(width: number): string[] {
740
+ const wd = this.widgetData;
741
+ if (!wd) {
742
+ return [
743
+ panelBar(width, ""),
744
+ panelLine(" mega-compact: warming up…", width),
745
+ panelBar(width, "─"),
746
+ ];
747
+ }
748
+ const pulse = wd.pulsing
749
+ ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} `
750
+ : "";
751
+ const sep = ` ${C.dim}│${C.reset} `;
752
+ // Build one long content line — let terminal wrap it naturally
753
+ const content = [
754
+ `${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}`,
755
+ wd.triggerLabel,
756
+ `${C.cyan}${wd.modelStr}${C.reset}`,
757
+ `${wd.chk} chk${wd.agentStr}${wd.turnStr}`,
758
+ `${C.magenta}dup ${wd.dedupStr}${C.reset}`,
759
+ `${C.gray}sess${C.reset} ${fmtTokens(wd.sessIn)}→${fmtTokens(wd.sessKept)} kept ${C.green}(${wd.sTxt}% freed)${C.reset}`,
760
+ `${C.gray}all-time${C.reset} ${fmtTokens(wd.repoIn)}→${fmtTokens(wd.repoKept)} kept ${C.blue}(${wd.rTxt}% freed)${C.reset}`,
761
+ `${wd.repoChk} chk/${wd.repoSess} sess`,
762
+ `${C.gray}mem${C.reset} ${wd.embedderName} · ${wd.chk} chunks · ${C.blue}comp ${wd.compStr}${C.reset}`,
763
+ `${C.gray}drift${C.reset} ${wd.driftStatus === "ok" ? C.green : C.amber}${wd.driftStatus}${C.reset}`,
764
+ `${C.gray}compact${C.reset} ${sinceCompactStr(wd.sinceCompact)}`,
765
+ ].join(sep);
766
+ // Wrap to terminal width and pad each line
767
+ const wrapped = wrapLine(content, width - 2); // 2-char indent
768
+ const lines: string[] = [
769
+ panelBar(width, "─"),
770
+ ...wrapped.map((l) => panelLine(l, width)),
771
+ ];
772
+ // L4 — agents block (S27, count + status; per-agent tokens gated on P0)
773
+ if (wd.agentsActive) {
774
+ lines.push(
775
+ panelLine(
776
+ ` ${C.cyan}🤖 ${this.activeAgents} active${wd.turnStr}${C.reset}`,
777
+ width,
778
+ ),
779
+ );
780
+ }
781
+ // L5 live ticker / activity (♻ deduped … why, or tier trace, or pulsing)
782
+ if (wd.tierTrace && wd.fresh) {
783
+ lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width));
784
+ } else if (wd.ticker.length > 0) {
785
+ const step = Math.floor(Date.now() / 250);
786
+ const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
787
+ const head = wd.ticker[idx].text;
788
+ const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
789
+ const more =
790
+ wd.ticker.length > 1
791
+ ? ` ${C.dim}(+${wd.ticker.length - 1} more)${C.reset}`
792
+ : "";
793
+ lines.push(
794
+ panelLine(
795
+ ` ${wd.fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`,
796
+ width,
797
+ ),
798
+ );
799
+ } else if (wd.pulsing) {
800
+ lines.push(panelLine(` ${pulse}${C.teal}compacting…${C.reset}`, width));
801
+ }
802
+ // bottom border
803
+ lines.push(panelBar(width, "─"));
804
+ return lines;
805
+ }
806
+
807
+ /** Active embedder name for the memory-store line (Trigram default / MiniLM). */
808
+ private embedderName(): string {
809
+ // MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
810
+ // the embedder factory uses so the label matches what's actually running.
811
+ return process.env.MEGACOMPACT_MINILM === "true" ||
812
+ process.env.MEGACOMPACT_MINILM === "1"
813
+ ? "MiniLM"
814
+ : "Trigram";
815
+ }
816
+
817
+ /** Cross-repo drift status (ok | warn), cached for 30s (opens the registry DB). */
818
+ private driftStatus(): "ok" | "warn" {
819
+ const now = Date.now();
820
+ if (this.driftCache && now - this.driftCache.at < 30_000)
821
+ return this.driftCache.status;
822
+ let status: "ok" | "warn" = "ok";
823
+ try {
824
+ const report = detectCrossRepoDrift();
825
+ status = report.totals.warn > 0 ? "warn" : "ok";
826
+ } catch {
827
+ status = "ok";
828
+ }
829
+ this.driftCache = { at: now, status };
830
+ return status;
831
+ }
832
+
833
+ setStatus(ctx: ExtensionContext, text: string | undefined): void {
834
+ this.statusKey = text;
835
+ ctx.ui.setStatus(STATUS_KEY, text);
836
+ }
837
+
838
+ resetRuntime(sessionId: string | undefined): void {
839
+ const sid = normalizeSessionId(sessionId);
840
+ if (this.rt.sessionId === sid && this.rt.persistedThisSession) return; // same session, keep checkpoint memory
841
+ this.rt = {
842
+ sessionId: sid,
843
+ persistedThisSession: false,
844
+ lastCheckpointId: undefined,
845
+ lastCompactedFrom: 0,
846
+ lastCompactedTokens: 0,
847
+ dedupSkips: 0,
848
+ dedupAttempts: 0,
849
+ tokensSaved: 0,
850
+ lastCompactAt: null,
851
+ lastNativeCompactAt: null,
852
+ };
853
+ this.statusKey = undefined;
854
+ this.activeAgents = 0;
855
+ this.currentTurn = 0;
856
+ this.lastActivityAt = 0;
857
+ this.tierTrace = undefined;
858
+ this.ticker.length = 0;
859
+ this.pulsing = false;
860
+ this.savedGoal = 50_000;
861
+ this.lastWhy = undefined;
862
+ }
863
+
864
+ /**
865
+ * Capture the active model/provider from ctx.model and persist it so cost
866
+ * estimation + the dashboard can read real pricing. Cheap + idempotent-ish:
867
+ * only writes a new row when the model id changes (models change rarely).
868
+ */
869
+ captureModel(ctx: ExtensionContext): void {
870
+ const m = ctx.model;
871
+ if (!m) {
872
+ this.appendEvent("captureModel:no-model", { cwd: ctx.cwd });
873
+ return;
874
+ }
875
+ if (
876
+ this.currentModel &&
877
+ this.currentModel.modelId === m.id &&
878
+ this.currentModel.provider === m.provider
879
+ )
880
+ return;
881
+ let providerName: string | null = null;
882
+ try {
883
+ providerName =
884
+ ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null;
885
+ } catch {
886
+ /* optional */
887
+ }
888
+ const snap: Omit<ModelSnapshot, "capturedAt"> = {
889
+ provider: m.provider,
890
+ providerName,
891
+ modelId: m.id,
892
+ modelName: m.name ?? null,
893
+ inputRate: m.cost?.input ?? 0,
894
+ outputRate: m.cost?.output ?? 0,
895
+ contextWindow: m.contextWindow ?? 0,
896
+ maxTokens: m.maxTokens ?? 0,
897
+ reasoning: !!m.reasoning,
898
+ };
899
+ this.currentModel = { ...snap, capturedAt: Date.now() };
900
+ this.diagCaptureModelCalls++;
901
+ const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
902
+ // S26: previously a single silent `catch {}` hid every capture failure, so
903
+ // model_snapshots stayed empty and the cost card read $0.00 with zero signal.
904
+ // Split per-write + append to events.log (always-on, dashboard live-streams
905
+ // it) + bump a DIAG counter so a live capture surfaces the root cause.
906
+ try {
907
+ recordModelSnapshot(repo, snap, this.currentStateDir);
908
+ this.appendEvent("captureModel:recorded", {
909
+ repo,
910
+ modelId: snap.modelId,
911
+ provider: snap.provider,
912
+ inputRate: snap.inputRate,
913
+ outputRate: snap.outputRate,
914
+ });
915
+ } catch (e) {
916
+ this.diagCaptureModelFails++;
917
+ this.appendEvent("captureModel:record-failed", {
918
+ repo,
919
+ modelId: snap.modelId,
920
+ error: e instanceof Error ? e.message : String(e),
921
+ stack: e instanceof Error ? e.stack : undefined,
922
+ });
923
+ }
924
+ try {
925
+ // Denormalize the active model into the machine-wide index so the
926
+ // All-repos dashboard table can show provider/model per repo without
927
+ // opening every repo's DB. Best-effort + non-fatal.
928
+ recordRepoModel(repo, {
929
+ provider: snap.provider,
930
+ providerName: snap.providerName,
931
+ modelName: snap.modelName,
932
+ inputRate: snap.inputRate,
933
+ outputRate: snap.outputRate,
934
+ stateDir: this.currentStateDir,
935
+ displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
936
+ });
937
+ } catch (e) {
938
+ this.appendEvent("captureModel:index-record-failed", {
939
+ repo,
940
+ modelId: snap.modelId,
941
+ error: e instanceof Error ? e.message : String(e),
942
+ });
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Append a structured line to the repo's events.log — the always-on
948
+ * diagnostics sink the dashboard live-streams. Unlike this.logger (gated by
949
+ * config.debug), this fires in production, so capture failures surface during
950
+ * a real capture even with debugging off. Best-effort + non-fatal.
951
+ */
952
+ private appendEvent(event: string, fields: Record<string, unknown>): void {
953
+ try {
954
+ mkdirSync(this.currentStateDir, { recursive: true });
955
+ appendFileSync(
956
+ join(this.currentStateDir, "events.log"),
957
+ JSON.stringify({ ts: Date.now(), event, ...fields }) + "\n",
958
+ );
959
+ } catch {
960
+ /* non-fatal */
961
+ }
962
+ }
963
+
964
+ /** S21: state dir of the currently bound repo (where memories live). */
965
+ getStateDir(): string {
966
+ return this.currentStateDir;
967
+ }
968
+
969
+ /** Build the sync onTier callback that paints the live per-tier trace. */
970
+ makeTierCallback(
971
+ ctx: ExtensionContext,
972
+ ): (ev: {
973
+ tier: "L0" | "L1" | "L2" | "new";
974
+ status: "scanning" | "deduped" | "passed" | "stored";
975
+ detail?: string;
976
+ }) => void {
977
+ const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
978
+ const seen = new Map<string, string>();
979
+ const glyph = (status: string) =>
980
+ status === "deduped"
981
+ ? `${C.green}✓${C.reset}`
982
+ : status === "passed"
983
+ ? `${C.dim}○${C.reset}`
984
+ : status === "scanning"
985
+ ? `${C.amber}…${C.reset}`
986
+ : `${C.cyan}●${C.reset}`;
987
+ return (ev) => {
988
+ const label =
989
+ ev.tier === "new"
990
+ ? `${C.cyan}stored${C.reset}`
991
+ : `${ev.tier} ${glyph(ev.status)}` +
992
+ (ev.detail ? ` ${C.gray}(${ev.detail})${C.reset}` : "");
993
+ // Show the most recent outcome per tier (collapses re-fires).
994
+ seen.set(ev.tier, label);
995
+ const show: string[] = [];
996
+ for (const t of order) if (seen.has(t)) show.push(seen.get(t)!);
997
+ this.tierTrace = `${C.teal}⚙${C.reset} ${show.join(` ${C.gray}→${C.reset} `)}`;
998
+ this.lastActivityAt = Date.now();
999
+ try {
1000
+ this.snapshot(ctx);
1001
+ } catch {
1002
+ /* non-fatal */
1003
+ }
1004
+ };
1005
+ }
1006
+
1007
+ // Phase 3 — recall/activity ticker ring buffer.
1008
+ pushTicker(text: string): void {
1009
+ this.ticker.push({ text, at: Date.now() });
1010
+ while (this.ticker.length > this.TICKER_MAX) this.ticker.shift();
1011
+ this.lastActivityAt = Date.now();
1012
+ }
1013
+
1014
+ /** Convert the messages pi hands us in the `context` event into the engine view. */
1015
+ engineView(messages: AgentMessage[]): ReturnType<typeof toEngineMessages> {
1016
+ return toEngineMessages(messages);
1017
+ }
814
1018
  }
815
1019
 
816
1020
  /**
@@ -818,20 +1022,20 @@ export class MegaRuntime {
818
1022
  * Kept as a free function (not instance state) since it only reads ctx.
819
1023
  */
820
1024
  export function recentUserQuery(ctx: ExtensionContext): string {
821
- try {
822
- const entries = ctx.sessionManager.getEntries();
823
- for (let i = entries.length - 1; i >= 0; i--) {
824
- const msgs = sessionEntryToContextMessages(entries[i]);
825
- for (let j = msgs.length - 1; j >= 0; j--) {
826
- if (msgs[j].role === "user") {
827
- const c = (msgs[j] as { content: unknown }).content;
828
- if (typeof c === "string") return c;
829
- if (Array.isArray(c)) return c.map((b: any) => b.text).join(" ");
830
- }
831
- }
832
- }
833
- } catch {
834
- /* best-effort */
835
- }
836
- return "";
1025
+ try {
1026
+ const entries = ctx.sessionManager.getEntries();
1027
+ for (let i = entries.length - 1; i >= 0; i--) {
1028
+ const msgs = sessionEntryToContextMessages(entries[i]);
1029
+ for (let j = msgs.length - 1; j >= 0; j--) {
1030
+ if (msgs[j].role === "user") {
1031
+ const c = (msgs[j] as { content: unknown }).content;
1032
+ if (typeof c === "string") return c;
1033
+ if (Array.isArray(c)) return c.map((b: any) => b.text).join(" ");
1034
+ }
1035
+ }
1036
+ }
1037
+ } catch {
1038
+ /* best-effort */
1039
+ }
1040
+ return "";
837
1041
  }