pi-mega-compact 0.7.0 → 0.7.1

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