pi-mega-compact 0.6.9 → 0.7.0

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.
@@ -8,13 +8,13 @@
8
8
  */
9
9
  import { normalizeSessionId } from "../src/store.js";
10
10
  import { autoCompactCheck } from "../src/compact.js";
11
- import { estimateSessionTokens } from "../src/tokens.js";
12
- import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
13
- import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryReview } from "./mega-pipeline.js";
11
+ import { estimateSessionTokens, estimateBlockTokens } from "../src/tokens.js";
12
+ import { recentUserQuery, WIDGET_KEY, } from "./mega-runtime.js";
13
+ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryReview, } from "./mega-pipeline.js";
14
14
  import { recallMemoriesAndInline } from "../src/recall.js";
15
- import { driveNativeCompaction } from "./mega-compact-driver.js";
15
+ import { driveNativeCompaction, } from "./mega-compact-driver.js";
16
16
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
17
- import { pressureFromPct, memoryReviewCadence } from "./mega-config.js";
17
+ import { pressureFromPct, memoryReviewCadence, } from "./mega-config.js";
18
18
  /**
19
19
  * DIAG accessor for the headless test harness: the most recently constructed
20
20
  * MegaRuntime, so a test that loads the compiled extension via its default
@@ -52,19 +52,30 @@ export function registerEventHandlers(pi, runtime, config) {
52
52
  // S17: use the async variant on resume so cross-repo HNSW recall can
53
53
  // augment when this repo's store is thin. session_start is an async-safe
54
54
  // point (unlike the mid-turn context handler, which stays sync).
55
- const r = await doRecallAsync(runtime, config, ctx, query, "resume", { crossRepo: config.crossRepoEnabled });
55
+ const r = await doRecallAsync(runtime, config, ctx, query, "resume", {
56
+ crossRepo: config.crossRepoEnabled,
57
+ });
56
58
  if (!r.empty) {
57
59
  runtime.pendingRecallBlock = r.block;
58
- const crossLabel = r.toInject.some((h) => h.repoId) ? " (cross-repo)" : "";
60
+ const crossLabel = r.toInject.some((h) => h.repoId)
61
+ ? " (cross-repo)"
62
+ : "";
59
63
  runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt${crossLabel}`);
60
- runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId), crossRepo: r.toInject.some((h) => h.repoId) });
64
+ runtime.logger.info("auto-inline", {
65
+ reason: event.reason,
66
+ query,
67
+ injected: r.toInject.map((h) => h.checkpoint.checkpointId),
68
+ crossRepo: r.toInject.some((h) => h.repoId),
69
+ });
61
70
  }
62
71
  }
63
72
  // S21: parallel memory recall. Same async context so we can await without
64
73
  // breaking the handler contract. Best-effort — never throws.
65
74
  try {
66
75
  const mr = await recallMemoriesAndInline({
67
- query, stateDir: runtime.getStateDir(), limit: 5,
76
+ query,
77
+ stateDir: runtime.getStateDir(),
78
+ limit: 5,
68
79
  crossRepo: config.crossRepoEnabled,
69
80
  crossRepoCosine: config.crossRepoCosine,
70
81
  });
@@ -75,7 +86,10 @@ export function registerEventHandlers(pi, runtime, config) {
75
86
  runtime.logger.warn("memory-recall skipped", { err: String(err) });
76
87
  }
77
88
  }
78
- runtime.dashboard.event("session_start", { reason: event.reason, sessionId: runtime.rt.sessionId });
89
+ runtime.dashboard.event("session_start", {
90
+ reason: event.reason,
91
+ sessionId: runtime.rt.sessionId,
92
+ });
79
93
  runtime.snapshot(ctx);
80
94
  });
81
95
  pi.on("session_tree", async (_event, ctx) => {
@@ -89,11 +103,21 @@ export function registerEventHandlers(pi, runtime, config) {
89
103
  const r = doRecall(runtime, config, ctx, query, "resume");
90
104
  if (!r.empty) {
91
105
  runtime.pendingRecallBlock = r.block;
92
- runtime.logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
106
+ runtime.logger.info("auto-inline", {
107
+ reason: "session_tree",
108
+ query,
109
+ injected: r.toInject.map((h) => h.checkpoint.checkpointId),
110
+ });
93
111
  }
94
112
  // S21: parallel memory recall. Trigram embedder is sub-ms; await is fine.
95
113
  try {
96
- const mr = await recallMemoriesAndInline({ query, stateDir: runtime.getStateDir(), limit: 5, crossRepo: config.crossRepoEnabled, crossRepoCosine: config.crossRepoCosine });
114
+ const mr = await recallMemoriesAndInline({
115
+ query,
116
+ stateDir: runtime.getStateDir(),
117
+ limit: 5,
118
+ crossRepo: config.crossRepoEnabled,
119
+ crossRepoCosine: config.crossRepoCosine,
120
+ });
97
121
  if (!mr.empty)
98
122
  runtime.pendingMemoryRecallBlock = mr.block;
99
123
  }
@@ -102,7 +126,9 @@ export function registerEventHandlers(pi, runtime, config) {
102
126
  }
103
127
  }
104
128
  }
105
- runtime.dashboard.event("session_tree", { sessionId: runtime.rt.sessionId });
129
+ runtime.dashboard.event("session_tree", {
130
+ sessionId: runtime.rt.sessionId,
131
+ });
106
132
  runtime.snapshot(ctx);
107
133
  });
108
134
  // ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
@@ -126,7 +152,9 @@ export function registerEventHandlers(pi, runtime, config) {
126
152
  // ---- Agent tracking for real-time widget + status-line updates ---------
127
153
  pi.on("agent_start", async (_event, ctx) => {
128
154
  runtime.activeAgents++;
129
- runtime.dashboard.event("agent_start", { activeAgents: runtime.activeAgents });
155
+ runtime.dashboard.event("agent_start", {
156
+ activeAgents: runtime.activeAgents,
157
+ });
130
158
  // Surface live agent activity on the status line (toolbar), not just the
131
159
  // above-editor widget — otherwise concurrent agents look frozen.
132
160
  runtime.setStatus(ctx, `mega-compact: ▶ ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`);
@@ -134,7 +162,9 @@ export function registerEventHandlers(pi, runtime, config) {
134
162
  });
135
163
  pi.on("agent_end", async (_event, ctx) => {
136
164
  runtime.activeAgents = Math.max(0, runtime.activeAgents - 1);
137
- runtime.dashboard.event("agent_end", { activeAgents: runtime.activeAgents });
165
+ runtime.dashboard.event("agent_end", {
166
+ activeAgents: runtime.activeAgents,
167
+ });
138
168
  if (runtime.activeAgents > 0) {
139
169
  runtime.setStatus(ctx, `mega-compact: ▶ ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`);
140
170
  }
@@ -153,7 +183,7 @@ export function registerEventHandlers(pi, runtime, config) {
153
183
  // DIAG (team-run relief): surface whether the agent is idle + over
154
184
  // threshold at agent_end so we can see if a mid-run durable-trim trigger
155
185
  // *should* have fired but didn't.
156
- const overThreshold = (runtime.lastCtxTokens ?? 0) >= config.thresholdTokens;
186
+ const overThreshold = (runtime.lastCtxTokens ?? 0) >= runtime.effectiveThreshold;
157
187
  runtime.diagAgentEndIdle++;
158
188
  runtime.logger.info("agent-end-idle", {
159
189
  sessionId: runtime.rt.sessionId,
@@ -163,7 +193,9 @@ export function registerEventHandlers(pi, runtime, config) {
163
193
  ctxPct: runtime.lastCtxPercent,
164
194
  ctxTokens: runtime.lastCtxTokens,
165
195
  thresholdTokens: config.thresholdTokens,
166
- wouldNudge: idle && queued && now >= runtime.resumeNudgeUntil,
196
+ wouldNudge: idle &&
197
+ (queued || overThreshold) &&
198
+ now >= runtime.resumeNudgeUntil,
167
199
  });
168
200
  // S16+S24: MID-RUN DURABLE TRIM. During a long team run (sub-agents),
169
201
  // pi's native durable compaction only fires from _checkCompaction at
@@ -178,6 +210,18 @@ export function registerEventHandlers(pi, runtime, config) {
178
210
  // pi would actually compact (piCompactWouldNoop skips the user-facing
179
211
  // no-op throw), and debounced (one durable trim per 2s) to avoid
180
212
  // thrashing the transcript while sub-agents keep settling.
213
+ //
214
+ // FIX "compacts but doesn't resume": the manual ctx.compact() path
215
+ // STOPS the agent loop (agent-session.js:1345). The old resume-nudge
216
+ // was gated on `queued`, so when a sub-agent settled with no
217
+ // *immediately* queued message, the trim fired but the nudge did not,
218
+ // and the (stopped) session hung. The trim still fires on
219
+ // `idle && overThreshold` — we intentionally do NOT add a `!queued`
220
+ // guard, because that would suppress mid-run relief exactly during
221
+ // team-run waves where queued is usually true and relief is needed
222
+ // most. Instead we DECOUPLE the nudge from `queued`: after a durable
223
+ // trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
224
+ let didDurableTrim = false;
181
225
  if (idle && overThreshold && now >= runtime.debounceUntil) {
182
226
  if (!piCompactWouldNoop(ctx)) {
183
227
  runtime.debounceUntil = now + 2000;
@@ -186,11 +230,18 @@ export function registerEventHandlers(pi, runtime, config) {
186
230
  sessionId: runtime.rt.sessionId,
187
231
  ctxTokens: runtime.lastCtxTokens,
188
232
  thresholdTokens: config.thresholdTokens,
233
+ queued,
189
234
  });
190
235
  ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort
236
+ didDurableTrim = true;
191
237
  }
192
238
  }
193
- if (idle && queued && now >= runtime.resumeNudgeUntil) {
239
+ // Restart the agent after a mid-run durable trim (which stopped it), or
240
+ // when it settled idle with queued work. Decoupled from `queued` for the
241
+ // durable-trim case — see FIX note above. Debounced 30s; never blocks.
242
+ if (idle &&
243
+ now >= runtime.resumeNudgeUntil &&
244
+ (didDurableTrim || queued)) {
194
245
  runtime.resumeNudgeUntil = now + 30_000;
195
246
  pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
196
247
  }
@@ -255,14 +306,15 @@ export function registerEventHandlers(pi, runtime, config) {
255
306
  return;
256
307
  const messages = event.messages;
257
308
  const view = runtime.engineView(messages);
258
- const currentTokens = usage?.tokens ?? estimateSessionTokens(view) ??
309
+ const currentTokens = usage?.tokens ??
310
+ estimateSessionTokens(view) ??
259
311
  Math.round((pct / 100) * (usage?.contextWindow ?? 0));
260
- // FAST GATE: token-based (tier threshold), not percentage-based.
261
- if (currentTokens < config.thresholdTokens) {
312
+ // FAST GATE: token-based (tier% of the window), not a static amount.
313
+ if (currentTokens < runtime.effectiveThreshold) {
262
314
  runtime.diagCtxFastGate++;
263
315
  return;
264
316
  }
265
- const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
317
+ const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
266
318
  if (!check.shouldCompact) {
267
319
  runtime.diagCtxNoCompact++;
268
320
  return;
@@ -277,7 +329,9 @@ export function registerEventHandlers(pi, runtime, config) {
277
329
  // Adaptive compression (Fix E): scale compression strength + keepFrom depth
278
330
  // with how close we are to the model context limit.
279
331
  const pressure = pressureFromPct(pct);
280
- const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
332
+ const ran = runCompact(pi, runtime, config, ctx, messages, {
333
+ compressionPressure: pressure,
334
+ });
281
335
  if (ran.skipped) {
282
336
  runtime.diagCtxRunSkipped++;
283
337
  return;
@@ -287,7 +341,9 @@ export function registerEventHandlers(pi, runtime, config) {
287
341
  // Read live from env (in addition to the load-time config) so the flag can be
288
342
  // toggled per-test without reloading the module; config.legacyDurableTrim is
289
343
  // the cached default. (Mirrors how piCompactWouldNoop re-reads its floor.)
290
- const legacy = config.legacyDurableTrim || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
344
+ const legacy = config.legacyDurableTrim ||
345
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" ||
346
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
291
347
  if (legacy) {
292
348
  if (piCompactWouldNoop(ctx))
293
349
  return;
@@ -305,7 +361,9 @@ export function registerEventHandlers(pi, runtime, config) {
305
361
  // without reloading the module.
306
362
  try {
307
363
  const anchorEnv = process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
308
- const anchorUserMessages = (anchorEnv != null && anchorEnv !== "" && Number.isFinite(Number(anchorEnv)))
364
+ const anchorUserMessages = anchorEnv != null &&
365
+ anchorEnv !== "" &&
366
+ Number.isFinite(Number(anchorEnv))
309
367
  ? Number(anchorEnv)
310
368
  : config.anchorUserMessages;
311
369
  const cut = computeLiveTrimCut(view, {
@@ -382,7 +440,7 @@ export function registerEventHandlers(pi, runtime, config) {
382
440
  return {}; // let pi run its own native compaction
383
441
  try {
384
442
  const result = driveNativeCompaction(event, runtime, config);
385
- if (result) {
443
+ if (result && result.compaction.summary?.trim()) {
386
444
  runtime.diagBeforeCompactSupplied++;
387
445
  runtime.logger.info("native-compact", {
388
446
  sessionId: runtime.rt.sessionId,
@@ -390,8 +448,30 @@ export function registerEventHandlers(pi, runtime, config) {
390
448
  tokensBefore: result.compaction.tokensBefore,
391
449
  summaryTokens: result.compaction.estimatedTokensAfter,
392
450
  });
451
+ nudgeResume(pi, runtime);
393
452
  return { compaction: result.compaction };
394
453
  }
454
+ // FIX "compacts but doesn't resume" + "Nothing to compact" regression:
455
+ // when we have nothing to summarize (anchor floor protects everything →
456
+ // messagesToSummarize empty) or our Trident/RAPTOR summary came back
457
+ // EMPTY, pi's OWN compact() throws "Nothing to compact (session too
458
+ // small)" and leaves the session stuck with no resume context. Instead
459
+ // of returning {} (which makes pi run its throwing compact()), supply a
460
+ // fallback compaction from prep.firstKeptEntryId with a minimal resume
461
+ // summary. This ALWAYS injects a compact summary so the session
462
+ // resumes, and never surfaces the "Nothing to compact" error to the user.
463
+ const fb = fallbackCompaction(event);
464
+ if (fb) {
465
+ runtime.diagBeforeCompactSupplied++;
466
+ runtime.logger.info("native-compact-fallback", {
467
+ sessionId: runtime.rt.sessionId,
468
+ firstKeptEntryId: fb.compaction.firstKeptEntryId,
469
+ tokensBefore: fb.compaction.tokensBefore,
470
+ reason: event.reason,
471
+ });
472
+ nudgeResume(pi, runtime);
473
+ return { compaction: fb.compaction };
474
+ }
395
475
  }
396
476
  catch (err) {
397
477
  runtime.logger.error("native-compact-failed", {
@@ -399,7 +479,51 @@ export function registerEventHandlers(pi, runtime, config) {
399
479
  error: String(err instanceof Error ? err.message : err),
400
480
  });
401
481
  }
402
- // Fall back to pi's own native compaction if we can't supply one.
482
+ // Absolute last resort: let pi run its own (may throw "Nothing to compact").
403
483
  return {};
404
484
  });
485
+ /**
486
+ * Build a minimal fallback compaction so pi never runs its throwing compact().
487
+ *
488
+ * Used when our Trident/RAPTOR summary is empty or there is nothing to
489
+ * summarize (the anchor floor protects every message). We still record a
490
+ * resume summary + truncate from prep.firstKeptEntryId so the session always
491
+ * gets a compact summary and resumes. Returns undefined only if pi handed us
492
+ * no preparation cut point at all.
493
+ */
494
+ function fallbackCompaction(event) {
495
+ const prep = event.preparation;
496
+ if (!prep?.firstKeptEntryId)
497
+ return undefined;
498
+ // When messagesToSummarize is empty the anchor floor protects everything,
499
+ // so firstKeptEntryId == current first entry and the trim is a no-op — but
500
+ // we still record a resume summary so the session has context after compaction.
501
+ const tokensBefore = prep.tokensBefore ?? 0;
502
+ const summary = `[mega-compact] context compacted at ${tokensBefore.toLocaleString()} tokens ` +
503
+ `(anchor floor active). Continue from the most recent messages above.`;
504
+ return {
505
+ compaction: {
506
+ summary,
507
+ firstKeptEntryId: prep.firstKeptEntryId,
508
+ tokensBefore,
509
+ estimatedTokensAfter: estimateBlockTokens(summary),
510
+ },
511
+ };
512
+ }
513
+ /**
514
+ * Debounced resume-nudge: restart the agent loop after a compaction (which
515
+ * may have stopped it). Idempotent — one nudge per 30s, never blocks.
516
+ */
517
+ function nudgeResume(pi, runtime) {
518
+ try {
519
+ const now = Date.now();
520
+ if (now >= runtime.resumeNudgeUntil) {
521
+ runtime.resumeNudgeUntil = now + 30_000;
522
+ pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
523
+ }
524
+ }
525
+ catch {
526
+ /* non-fatal: a failed nudge never blocks */
527
+ }
528
+ }
405
529
  }
@@ -11,13 +11,13 @@
11
11
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
12
12
  import { join, dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
- import { readFileSync } from "node:fs";
14
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
15
15
  import { VectorStore } from "../src/vectorStore.js";
16
16
  import { toEngineMessages } from "../src/adapt.js";
17
17
  import { normalizeSessionId } from "../src/store.js";
18
18
  import { Logger } from "../src/log.js";
19
19
  import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel } from "../src/store/sqlite.js";
20
- import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand } from "./mega-config.js";
20
+ import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens } from "./mega-config.js";
21
21
  import { Dashboard } from "./mega-dashboard.js";
22
22
  export const STATUS_KEY = "mega-compact";
23
23
  export const WIDGET_KEY = "mega-compact-stats";
@@ -56,6 +56,44 @@ export const C = {
56
56
  red: "\x1b[38;5;203m", // pressure / overflow
57
57
  };
58
58
  const PULSE = ["◐", "◓", "◑", "◒"];
59
+ // ── Full-width widget panel helpers ────────────────────────────────────────
60
+ // pi's above-editor widget renderer (a Container of Text lines) does NOT pass
61
+ // a terminal width to setWidget(), so lines render left-aligned by default. To
62
+ // make the widget read as a full-width status panel we pad each line to the
63
+ // real terminal width with a background fill. NOTE: C.reset is a FULL SGR
64
+ // reset, so we re-apply the panel bg after every reset to keep the background
65
+ // continuous under colored text (and under pi's own trailing reset).
66
+ const PANEL_BG = "\x1b[48;5;236m"; // dark slate panel background
67
+ const PANEL_RST = "\x1b[0m" + PANEL_BG; // reset fg but retain panel bg
68
+ /** Visible cell width of a string, ignoring ANSI SGR/OSC escapes. */
69
+ function visibleWidth(s) {
70
+ const stripped = s
71
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")
72
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
73
+ let w = 0;
74
+ for (const ch of stripped) {
75
+ const cp = ch.codePointAt(0) ?? 0;
76
+ const wide = cp >= 0x1100 && ((cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0x303e) ||
77
+ (cp >= 0x3041 && cp <= 0x33ff) || (cp >= 0x3400 && cp <= 0x4dbf) ||
78
+ (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0xa000 && cp <= 0xa4cf) ||
79
+ (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
80
+ (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
81
+ (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) ||
82
+ (cp >= 0x20000 && cp <= 0x3fffd));
83
+ w += wide ? 2 : 1;
84
+ }
85
+ return w;
86
+ }
87
+ /** Pad a content string (with ANSI colors) to `width` cells using panel bg. */
88
+ function panelLine(content, width) {
89
+ const withBg = PANEL_BG + content.replace(/\x1b\[0m/g, PANEL_RST);
90
+ const pad = Math.max(0, width - visibleWidth(withBg));
91
+ return withBg + " ".repeat(pad) + "\x1b[0m";
92
+ }
93
+ /** A full-width hairline bar (top/bottom border of the panel). */
94
+ function panelBar(width, ch = "─") {
95
+ return PANEL_BG + ch.repeat(Math.max(0, width)) + "\x1b[0m";
96
+ }
59
97
  export class MegaRuntime {
60
98
  config;
61
99
  // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
@@ -142,19 +180,55 @@ export class MegaRuntime {
142
180
  diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
143
181
  diagCtxThrown = 0; // live-trim try threw (caught)
144
182
  /**
145
- * Live 0–1 pressure: how full the context window is relative to the compaction
146
- * threshold. Computed from the most recent context event the runtime already
147
- * tracks (token count when available the direct signal otherwise the usage
148
- * percentage). This is the single "how full" number every subsystem reads; the
149
- * toolbar/dashboard tier label is `pressureBand` over this, so it climbs
150
- * low→mega as context rises (S24). Always finite + in [0,1].
183
+ * S26 capture instrumentation: the "model_snapshots empty $0.00 cost card"
184
+ * bug was invisible because captureModel swallowed the DB write in a silent
185
+ * `catch {}`. These always-updated counters (zero cost) let a headless test or
186
+ * a live capture tell whether captureModel ran and whether the snapshot landed.
187
+ */
188
+ diagCaptureModelCalls = 0; // captureModel entered with a populated ctx.model
189
+ diagCaptureModelFails = 0; // recordModelSnapshot threw → model_snapshots stays empty
190
+ /**
191
+ * Live 0–1 pressure — how full the context window is relative to the
192
+ * compaction threshold.
193
+ *
194
+ * RECONCILE (BACKLOG dual-basis flicker): when the model context window is
195
+ * known we base pressure consistently on the *percentage* basis
196
+ * (`lastCtxPercent / (tierPct*100)`). This keeps the band stable whether the
197
+ * latest context event carried a token count or only a percentage, so the
198
+ * threshold comparison doesn't jump when a token-count event arrives vs a
199
+ * percent-only event. We only fall back to the token-count basis
200
+ * (`config.thresholdTokens`) when the window is unknown (e.g. before the first
201
+ * context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
151
202
  */
152
203
  get pressure() {
204
+ if (this.lastCtxWindow > 0 && this.config.tierPct != null && this.lastCtxPercent != null) {
205
+ // pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
206
+ // exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
207
+ // fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
208
+ // token-based pressureRatio(currentTokens, effectiveThreshold) reading so
209
+ // the band doesn't jump when a token-count vs percent-only event arrives.
210
+ return pressureFromPct(this.lastCtxPercent / this.config.tierPct);
211
+ }
153
212
  if (this.lastCtxTokens != null && this.lastCtxTokens > 0 && this.config.thresholdTokens > 0) {
154
213
  return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
155
214
  }
156
215
  return pressureFromPct(this.lastCtxPercent);
157
216
  }
217
+ /**
218
+ * The live compaction FIRE POINT in tokens: the effective threshold scaled by
219
+ * the current model context window (`tierPct * window`) when known, else the
220
+ * boot fallback `config.thresholdTokens`. This is what the FAST GATE /
221
+ * `autoCompactCheck` / agent_end durable-trigger compare against, so
222
+ * compaction fires at tier% of the window for ANY model size (200k or 1M),
223
+ * always below pi's native auto-compaction (~80% of window).
224
+ */
225
+ get effectiveThreshold() {
226
+ return effectiveThresholdTokens({
227
+ tierPct: this.config.tierPct,
228
+ fallbackThreshold: this.config.thresholdTokens,
229
+ window: this.lastCtxWindow,
230
+ });
231
+ }
158
232
  /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
159
233
  get pressureBand() {
160
234
  return pressureBand(this.pressure);
@@ -224,8 +298,14 @@ export class MegaRuntime {
224
298
  outputRate: modelSnap.outputRate,
225
299
  }
226
300
  : undefined;
227
- const armed = this.lastCtxPercent != null && this.lastCtxPercent >= this.config.fastGatePct;
228
- const ready = armed && (this.lastCtxTokens ?? 0) >= this.config.thresholdTokens;
301
+ // effectiveThresholdPct: the live fire point as a % of the window (null for
302
+ // `custom`, which has no tierPct). Used by armed/ready + the dashboard.
303
+ const effectiveThresholdPct = this.config.tierPct != null ? this.config.tierPct * 100 : null;
304
+ // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
305
+ // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
306
+ // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
307
+ const armed = this.lastCtxPercent != null && this.lastCtxPercent >= Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
308
+ const ready = armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
229
309
  this.dashboard.snapshot({
230
310
  version: 1,
231
311
  updatedAt: new Date().toISOString(),
@@ -236,7 +316,9 @@ export class MegaRuntime {
236
316
  pressure: this.pressure,
237
317
  config: {
238
318
  fastGatePct: this.config.fastGatePct,
239
- thresholdTokens: this.config.thresholdTokens,
319
+ thresholdTokens: this.effectiveThreshold,
320
+ tierPct: this.config.tierPct,
321
+ effectiveThresholdPct,
240
322
  anchorUserMessages: this.config.anchorUserMessages,
241
323
  preserveRecent: this.config.preserveRecent,
242
324
  auto: this.config.auto,
@@ -253,7 +335,7 @@ export class MegaRuntime {
253
335
  dedupAttempts: this.rt.dedupAttempts,
254
336
  },
255
337
  context: { tokens: this.lastCtxTokens, percent: this.lastCtxPercent, contextWindow: this.lastCtxWindow },
256
- trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.config.thresholdTokens, fastGatePct: this.config.fastGatePct },
338
+ trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.effectiveThreshold, fastGatePct: this.config.fastGatePct, tierPct: this.config.tierPct, effectiveThresholdPct },
257
339
  crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
258
340
  store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, originalTokens: st.originalTokens, tokensSaved: this.rt.tokensSaved, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
259
341
  // Reconciled token accounting (single canonical formula, session + repo).
@@ -319,7 +401,14 @@ export class MegaRuntime {
319
401
  const fmt = (x) => x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
320
402
  : x >= 1000 ? `${(x / 1000).toFixed(1)}k`
321
403
  : `${Math.round(x)}`;
322
- const agentStr = this.activeAgents > 0 ? ` │ 🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}` : "";
404
+ // Agents view: ALWAYS show the agent line so status is visible even when
405
+ // idle (previously hidden at 0). 🤖 N agents when active, dimmed 🤖 idle
406
+ // when none — this is the restored "agents view" (count + status). Real
407
+ // per-agent/sub-agent token usage is scoped in Sprint 27.
408
+ const agentLabel = this.activeAgents > 0
409
+ ? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
410
+ : `${C.dim}🤖 idle${C.reset}`;
411
+ const agentStr = ` │ ${agentLabel}`;
323
412
  const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
324
413
  // Phase 3 — pulsing status glyph while a compaction is in flight.
325
414
  const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
@@ -354,27 +443,28 @@ export class MegaRuntime {
354
443
  const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
355
444
  const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
356
445
  const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
446
+ // Full-width panel: read the real terminal width and pad each line with a
447
+ // panel background so the above-editor widget reads as a full-width status
448
+ // bar. pi's widget renderer does not pass width to setWidget(), so we pad
449
+ // ourselves. Falls back to 200 cols when stdout.columns is unavailable.
450
+ const W = process.stdout?.columns ?? 200;
357
451
  const lines = [
452
+ // top border — full-width hairline
453
+ panelBar(W, "─"),
358
454
  // L1 — header: tier + ctx-fill bar (20-cell, green=room→red=full) +
359
- // tokens + status glyph + checkpoints + agents/turn. Widened to use the
360
- // terminal width; the context bar is the only live-moving bar.
361
- ` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} ${ramp(ctxPct, 20)} ${C.bold}${pctStr}${C.reset} ${tokStr}/${maxStr} │ ${triggerLabel} │ ${st.checkpointCount} chk${agentStr}${turnStr}`,
455
+ // tokens + status glyph + checkpoints + agents/turn. The context bar is
456
+ // the only live-moving bar; the whole block is padded to full width.
457
+ panelLine(` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} ${ramp(ctxPct, 20)} ${C.bold}${pctStr}${C.reset} ${tokStr}/${maxStr} │ ${triggerLabel} │ ${st.checkpointCount} chk${agentStr}${turnStr}`, W),
362
458
  // L2 — savings EXPLAINED, not bar'd. The freed/(freed+kept) ratio
363
- // saturates near 100% once cumulative freed dwarfs live kept (4.8mil
364
- // freed vs 612 kept), so a bar is visually useless. Instead show the
365
- // compaction story: "in→kept (X% freed)" reads as "compacted N tokens
366
- // down to M, freeing X%". Plus repo-wide chk/session counts.
367
- ` ${C.magenta}dup ${dedupStr}${C.reset} │ ${C.gray}sess${C.reset} ${fmt(sessIn)}→${fmt(sessKept)} kept ${C.green}(${sTxt}% freed)${C.reset} · ${C.gray}all-time${C.reset} ${fmt(repoIn)}→${fmt(repoKept)} kept ${C.blue}(${rTxt}% freed)${C.reset} │ ${repo.checkpointCount} chk/${repo.sessionCount} sess`,
459
+ // saturates near 100% once cumulative freed dwarfs live kept, so a bar
460
+ // is visually useless; show the compaction story instead.
461
+ panelLine(` ${C.magenta}dup ${dedupStr}${C.reset} ${C.gray}sess${C.reset} ${fmt(sessIn)}→${fmt(sessKept)} kept ${C.green}(${sTxt}% freed)${C.reset} · ${C.gray}all-time${C.reset} ${fmt(repoIn)}→${fmt(repoKept)} kept ${C.blue}(${rTxt}% freed)${C.reset} │ ${repo.checkpointCount} chk/${repo.sessionCount} sess`, W),
368
462
  ];
369
463
  // Live "now processing" line + why + recent deduped/compacted events,
370
- // collapsed to ONE rotating line (fresh only). The ticker ring buffer
371
- // (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
372
- // through recent files in real time while activity fires. We rotate on a
373
- // 250ms step (same cadence as the pulse), using an event counter as the
374
- // deterministic phase so consecutive repaints advance the visible entry.
464
+ // collapsed to ONE rotating line (fresh only); padded to full width.
375
465
  const fresh = Date.now() - this.lastActivityAt < 4000;
376
466
  if (this.tierTrace && fresh) {
377
- lines.push(` ${pulse}${this.tierTrace}`);
467
+ lines.push(panelLine(` ${pulse}${this.tierTrace}`, W));
378
468
  }
379
469
  else if (this.ticker.length > 0) {
380
470
  const step = Math.floor(Date.now() / 250);
@@ -382,11 +472,13 @@ export class MegaRuntime {
382
472
  const head = this.ticker[idx].text;
383
473
  const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
384
474
  const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
385
- lines.push(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`);
475
+ lines.push(panelLine(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`, W));
386
476
  }
387
477
  else if (this.pulsing) {
388
- lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
478
+ lines.push(panelLine(` ${pulse}${C.teal}compacting…${C.reset}`, W));
389
479
  }
480
+ // bottom border — full-width hairline closes the panel
481
+ lines.push(panelBar(W, "─"));
390
482
  // (Accounting folded into L2's "in→kept (X% freed)" framing — freed =
391
483
  // in − kept is implied, and the saturated-ratio bars are gone.)
392
484
  ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
@@ -427,8 +519,10 @@ export class MegaRuntime {
427
519
  */
428
520
  captureModel(ctx) {
429
521
  const m = ctx.model;
430
- if (!m)
522
+ if (!m) {
523
+ this.appendEvent("captureModel:no-model", { cwd: ctx.cwd });
431
524
  return;
525
+ }
432
526
  if (this.currentModel && this.currentModel.modelId === m.id && this.currentModel.provider === m.provider)
433
527
  return;
434
528
  let providerName = null;
@@ -448,9 +542,28 @@ export class MegaRuntime {
448
542
  reasoning: !!m.reasoning,
449
543
  };
450
544
  this.currentModel = { ...snap, capturedAt: Date.now() };
545
+ this.diagCaptureModelCalls++;
546
+ const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
547
+ // S26: previously a single silent `catch {}` hid every capture failure, so
548
+ // model_snapshots stayed empty and the cost card read $0.00 with zero signal.
549
+ // Split per-write + append to events.log (always-on, dashboard live-streams
550
+ // it) + bump a DIAG counter so a live capture surfaces the root cause.
451
551
  try {
452
- const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
453
552
  recordModelSnapshot(repo, snap, this.currentStateDir);
553
+ this.appendEvent("captureModel:recorded", {
554
+ repo, modelId: snap.modelId, provider: snap.provider,
555
+ inputRate: snap.inputRate, outputRate: snap.outputRate,
556
+ });
557
+ }
558
+ catch (e) {
559
+ this.diagCaptureModelFails++;
560
+ this.appendEvent("captureModel:record-failed", {
561
+ repo, modelId: snap.modelId,
562
+ error: e instanceof Error ? e.message : String(e),
563
+ stack: e instanceof Error ? e.stack : undefined,
564
+ });
565
+ }
566
+ try {
454
567
  // Denormalize the active model into the machine-wide index so the
455
568
  // All-repos dashboard table can show provider/model per repo without
456
569
  // opening every repo's DB. Best-effort + non-fatal.
@@ -464,7 +577,25 @@ export class MegaRuntime {
464
577
  displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
465
578
  });
466
579
  }
467
- catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
580
+ catch (e) {
581
+ this.appendEvent("captureModel:index-record-failed", {
582
+ repo, modelId: snap.modelId,
583
+ error: e instanceof Error ? e.message : String(e),
584
+ });
585
+ }
586
+ }
587
+ /**
588
+ * Append a structured line to the repo's events.log — the always-on
589
+ * diagnostics sink the dashboard live-streams. Unlike this.logger (gated by
590
+ * config.debug), this fires in production, so capture failures surface during
591
+ * a real capture even with debugging off. Best-effort + non-fatal.
592
+ */
593
+ appendEvent(event, fields) {
594
+ try {
595
+ mkdirSync(this.currentStateDir, { recursive: true });
596
+ appendFileSync(join(this.currentStateDir, "events.log"), JSON.stringify({ ts: Date.now(), event, ...fields }) + "\n");
597
+ }
598
+ catch { /* non-fatal */ }
468
599
  }
469
600
  /** S21: state dir of the currently bound repo (where memories live). */
470
601
  getStateDir() {