pi-mega-compact 0.4.15 → 0.4.16

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.
@@ -12,1246 +12,30 @@
12
12
  * - Message drops must preserve an anchor floor (PREVENT-PI-001) and never
13
13
  * split a toolCall/toolResult pair (PREVENT-PI-002).
14
14
  *
15
- * Sprint 3 wires: config, session state reset, the auto-trigger pipeline
16
- * (fast-gate auto_compact_check Trident+persist → context drop),
17
- * session_before_compact cancellation, the compact-marker sentinel, and the
18
- * /megacompact + /megacompact-status commands.
15
+ * The extension is split into focused modules under extensions/mega-*.ts:
16
+ * - mega-config.ts tiers, env helpers, loadConfig, per-repo scoping
17
+ * - mega-dashboard.ts live snapshot writer (dashboard.json / events.log)
18
+ * - mega-runtime.ts shared live state (MegaRuntime) + widget + model capture
19
+ * - mega-pipeline.ts runCompact (Trident+persist) + doRecall (Layer 5)
20
+ * - mega-commands.ts data/inspection slash commands
21
+ * - mega-dashboard-cmds.ts localhost dashboard server lifecycle commands
22
+ * - mega-events.ts pi lifecycle event handlers
19
23
  *
20
- * Sprint 4 wires the unified recall layer (Layer 5): recallAndInline() is the
21
- * ONLY code path that injects compacted context. It serves three entry points —
22
- * auto-inline on resume/branch (before_agent_start), on-demand /recall-context,
23
- * and the dedup sentinel — all through one dedup engine, injected via the
24
- * before_agent_start systemPrompt prepend (PREVENT-PI-003).
24
+ * This file is the thin wiring layer: it owns the default export, constructs
25
+ * the runtime, and registers handlers/commands. Behavior is unchanged.
25
26
  */
26
27
 
27
- import type { ExtensionAPI, ExtensionContext, ContextEvent, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
28
- import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
29
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
30
- import { join, dirname, sep } from "node:path";
31
- import { fileURLToPath } from "node:url";
32
- import { STATE_DIR_DEFAULT } from "../src/config.js";
33
- import { VectorStore } from "../src/vectorStore.js";
34
- import { toEngineMessages, dropCompactedRange } from "../src/adapt.js";
35
- import { compactSession } from "../src/engine.js";
36
- import { recallAndInline } from "../src/recall.js";
37
- import { autoCompactCheck } from "../src/compact.js";
38
- import { estimateSessionTokens } from "../src/tokens.js";
39
- import { normalizeSessionId } from "../src/store.js";
40
- import { touchSession, logDaily, listCheckpoints } from "../src/store/sqlite.js";
41
- import { decompressSmart } from "../src/store/compression.js";
42
- import { loadMetrics, fpRate, p95 } from "../src/monitoring.js";
43
- import { Logger } from "../src/log.js";
44
- import type { EngineMessage } from "../src/types.js";
45
- import { writeFileSync, appendFileSync, readFileSync } from "node:fs";
46
- import { existsSync, mkdirSync, unlinkSync } from "node:fs";
47
- import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
48
- import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the store per-repo
49
-
50
- const STATUS_KEY = "mega-compact";
51
- const WIDGET_KEY = "mega-compact-stats";
52
- const MARKER_TYPE = "mega-compact-marker";
53
-
54
- /** Per-session runtime state kept in the closure (mirrors neuralwatt-mcr). */
55
- interface SessionRuntime {
56
- sessionId: string;
57
- persistedThisSession: boolean;
58
- lastCheckpointId: string | undefined;
59
- lastCompactedFrom: number;
60
- lastCompactedTokens: number;
61
- dedupSkips: number; // compactions skipped because regionHash already stored
62
- dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
63
- tokensSaved: number; // this session-instance only: reset on session_start
64
- }
65
-
66
- function envFlag(name: string, fallback: number): number {
67
- const v = process.env[name];
68
- if (v == null || v === "") return fallback;
69
- const n = Number(v);
70
- return Number.isFinite(n) ? n : fallback;
71
- }
72
- function envBool(name: string, fallback: boolean): boolean {
73
- const v = process.env[name];
74
- if (v == null || v === "") return fallback;
75
- return v === "true" || v === "1";
76
- }
77
-
78
- /**
79
- * Named compaction tiers. A tier sets the token threshold at which the
80
- * auto-trigger persists a checkpoint; pick by how aggressively you want the
81
- * session trimmed. Explicit MEGACOMPACT_THRESHOLD_TOKENS always wins.
82
- */
83
- const COMPACT_TIERS = {
84
- low: 50_000,
85
- medium: 100_000,
86
- high: 200_000,
87
- ultra: 1_000_000,
88
- mega: 10_000_000,
89
- } as const;
90
- export type CompactTier = keyof typeof COMPACT_TIERS;
91
-
92
- /** Resolve the effective token threshold from TIER (or explicit) env vars. */
93
- function resolveThreshold(): { tier: CompactTier | "custom"; thresholdTokens: number } {
94
- const explicit = process.env.MEGACOMPACT_THRESHOLD_TOKENS;
95
- if (explicit != null && explicit !== "") {
96
- const n = Number(explicit);
97
- if (Number.isFinite(n)) return { tier: "custom", thresholdTokens: n };
98
- }
99
- const raw = (process.env.MEGACOMPACT_TIER ?? "low").toLowerCase();
100
- const tier = (raw in COMPACT_TIERS ? raw : "low") as CompactTier;
101
- return { tier, thresholdTokens: COMPACT_TIERS[tier] };
102
- }
103
-
104
- function loadConfig() {
105
- const { tier, thresholdTokens } = resolveThreshold();
106
- return {
107
- tier,
108
- // Global default; the live store/dashboard are rebound per-repo at runtime
109
- // via bindRepo() so each git repo gets its own isolated state dir.
110
- stateDir: process.env.MEGACOMPACT_STATE_DIR ?? STATE_DIR_DEFAULT,
111
- fastGatePct: envFlag("MEGACOMPACT_FAST_GATE_PCT", 70),
112
- thresholdTokens,
113
- anchorUserMessages: envFlag("MEGACOMPACT_ANCHOR_USER_MESSAGES", 3),
114
- preserveRecent: envFlag("MEGACOMPACT_PRESERVE_RECENT", 4),
115
- auto: envBool("MEGACOMPACT_AUTO", true),
116
- autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
117
- autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
118
- dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
119
- debug: envBool("MEGACOMPACT_DEBUG", false),
120
- };
121
- }
122
-
123
- /**
124
- * Resolve the current repo's git root from a cwd. Returns undefined for a
125
- * non-git directory (caller falls back to a global state dir).
126
- */
127
- function resolveRepoRoot(cwd: string): string | undefined {
128
- try {
129
- const out = execSync("git rev-parse --show-toplevel", {
130
- cwd,
131
- encoding: "utf-8",
132
- stdio: ["ignore", "pipe", "ignore"],
133
- }).trim();
134
- return out || undefined;
135
- } catch {
136
- return undefined;
137
- }
138
- }
139
-
140
- /**
141
- * Per-repo state dir: <repo>/.pi/mega-compact (tracked, so it travels with the
142
- * repo across devices — not gitignored). Falls back to `fallback` for non-git
143
- * cwds (the explicit MEGACOMPACT_STATE_DIR override, if set).
144
- */
145
- function repoStateDir(cwd: string, fallback: string): string {
146
- const root = resolveRepoRoot(cwd);
147
- if (!root) return fallback;
148
- return join(root, ".pi", "mega-compact");
149
- }
150
-
151
- // ---- Live dashboard -------------------------------------------------------
152
- // Writes dashboard.json (full snapshot) and events.log (JSONL tail) to the
153
- // state dir so any process can inspect the extension's real-time state.
154
- //
155
- // Usage:
156
- // cat ~/.pi/agent/extensions/pi-mega-compact/dashboard.json
157
- // jq . ~/.pi/agent/extensions/pi-mega-compact/dashboard.json
158
- // tail -f ~/.pi/agent/extensions/pi-mega-compact/events.log
159
-
160
- interface DashboardSnapshot {
161
- version: 1;
162
- updatedAt: string;
163
- tier: string;
164
- config: {
165
- fastGatePct: number;
166
- thresholdTokens: number;
167
- anchorUserMessages: number;
168
- preserveRecent: number;
169
- auto: boolean;
170
- autoInline: boolean;
171
- };
172
- session: {
173
- id: string;
174
- state: string;
175
- persistedThisSession: boolean;
176
- lastCheckpointId: string | null;
177
- lastCompactedFrom: number;
178
- lastCompactedTokens: number;
179
- dedupSkips: number;
180
- dedupAttempts: number;
181
- };
182
- context: {
183
- tokens: number | null;
184
- percent: number | null;
185
- contextWindow: number;
186
- };
187
- trigger: {
188
- armed: boolean; // past fast-gate %
189
- ready: boolean; // past threshold (would compact next turn)
190
- currentTokens: number | null;
191
- thresholdTokens: number;
192
- fastGatePct: number;
193
- };
194
- store: {
195
- checkpointCount: number;
196
- totalTokenEstimate: number;
197
- originalTokens: number; // Σ original dropped-region tokens (this session)
198
- tokensSaved: number; // Σ(original − stored) for this session
199
- injectedCount: number;
200
- dedupHitRate: number;
201
- storageDedupRate: number;
202
- dedupAttempts: number;
203
- dedupCollapsed: number;
204
- };
205
- crew: {
206
- activeAgents: number;
207
- currentTurn: number;
208
- };
209
- repo: {
210
- checkpointCount: number; // across all sessions in this repo's store
211
- totalTokenEstimate: number; // repo-wide stored checkpoint tokens
212
- originalTokens: number; // repo-wide Σ original dropped-region tokens
213
- tokensSaved: number; // repo-wide cumulative (original − stored) + deduped orig
214
- sessionCount: number; // distinct sessions with checkpoints
215
- dedupAttempts: number; // cumulative add() calls (store-wide)
216
- dedupCollapsed: number; // cumulative deduped collapses (store-wide)
217
- storageDedupRate: number; // deduped / attempts, 0..1
218
- };
219
- /** Phase 0 data-safety invariant (trust foundation). */
220
- integrity: {
221
- regionsRetained: number; // checkpoints with a recoverable compressed-original
222
- compressedOriginalBytes: number; // bytes of compressed-original retained (recoverable)
223
- duplicatesCollapsed: number; // dedup duplicates (original kept on survivor)
224
- bytesPermanentlyDeleted: number; // ALWAYS 0 — the invariant
225
- };
226
- }
227
-
228
- class Dashboard {
229
- private snapshotPath: string;
230
- private eventsPath: string;
231
-
232
- constructor(stateDir: string) {
233
- if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
234
- this.snapshotPath = join(stateDir, "dashboard.json");
235
- this.eventsPath = join(stateDir, "events.log");
236
- }
237
-
238
- /** Write a full state snapshot (atomically replaces previous). */
239
- snapshot(data: DashboardSnapshot): void {
240
- writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
241
- }
242
-
243
- /** Append a timestamped JSONL event line. */
244
- event(type: string, data: Record<string, unknown>): void {
245
- const line = JSON.stringify({ ts: new Date().toISOString(), type, ...data });
246
- appendFileSync(this.eventsPath, line + "\n");
247
- }
248
- }
249
-
250
- /** Convert the messages pi hands us in the `context` event into the engine view. */
251
- function engineView(messages: AgentMessage[]): EngineMessage[] {
252
- return toEngineMessages(messages);
253
- }
28
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import { loadConfig } from "./mega-config.js";
30
+ import { MegaRuntime } from "./mega-runtime.js";
31
+ import { registerEventHandlers } from "./mega-events.js";
32
+ import { registerCommands } from "./mega-commands.js";
33
+ import { registerDashboardCommands } from "./mega-dashboard-cmds.js";
254
34
 
255
35
  export default function (pi: ExtensionAPI) {
256
36
  const config = loadConfig();
257
- // Store/dashboard/logger are rebound per-repo by bindRepo() (below) so each
258
- // git repo gets its own isolated state dir. They start bound to the global
259
- // default until the first handler resolves a cwd.
260
- let store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
261
- let logger = new Logger({ enabled: config.debug, path: join(config.stateDir, "mega-compact.log") });
262
- let dashboard = new Dashboard(config.stateDir);
263
- let activeRepoRoot: string | null = null;
264
- let currentStateDir = config.stateDir;
265
-
266
- /**
267
- * Point store/dashboard/logger at the current repo's state dir. Rebuilds the
268
- * instances only when the repo root changes, so cross-repo dedup stats, db,
269
- * and events are fully isolated. Falls back to the global default outside git.
270
- */
271
- function bindRepo(cwd: string | undefined): string {
272
- const dir = cwd ? repoStateDir(cwd, config.stateDir) : config.stateDir;
273
- const key = cwd ? resolveRepoRoot(cwd) ?? dir : dir;
274
- if (key === activeRepoRoot) return dir;
275
- activeRepoRoot = key;
276
- currentStateDir = dir;
277
- store = new VectorStore({ dedupSim: config.dedupSim, stateDir: dir });
278
- logger = new Logger({ enabled: config.debug, path: join(dir, "mega-compact.log") });
279
- dashboard = new Dashboard(dir);
280
- return dir;
281
- }
282
-
283
- // --- snapshot() helper: collect live state and write it to disk ---
284
- let lastCtxTokens: number | null = null;
285
- let lastCtxPercent: number | null = null;
286
- let lastCtxWindow: number = 0;
287
-
288
- function snapshot(ctx?: ExtensionContext): void {
289
- if (ctx) bindRepo(ctx.cwd);
290
- const st = store.stats(rt.sessionId);
291
- const repo = store.repoStats();
292
- const di = store.dataInvariant();
293
- const armed = lastCtxPercent != null && lastCtxPercent >= config.fastGatePct;
294
- const ready = armed && (lastCtxTokens ?? 0) >= config.thresholdTokens;
295
- dashboard.snapshot({
296
- version: 1,
297
- updatedAt: new Date().toISOString(),
298
- tier: config.tier,
299
- config: {
300
- fastGatePct: config.fastGatePct,
301
- thresholdTokens: config.thresholdTokens,
302
- anchorUserMessages: config.anchorUserMessages,
303
- preserveRecent: config.preserveRecent,
304
- auto: config.auto,
305
- autoInline: config.autoInline,
306
- },
307
- session: {
308
- id: rt.sessionId,
309
- state: statusKey ?? "idle",
310
- persistedThisSession: rt.persistedThisSession,
311
- lastCheckpointId: rt.lastCheckpointId ?? null,
312
- lastCompactedFrom: rt.lastCompactedFrom,
313
- lastCompactedTokens: rt.lastCompactedTokens,
314
- dedupSkips: rt.dedupSkips,
315
- dedupAttempts: rt.dedupAttempts,
316
- },
317
- context: { tokens: lastCtxTokens, percent: lastCtxPercent, contextWindow: lastCtxWindow },
318
- trigger: { armed, ready, currentTokens: lastCtxTokens, thresholdTokens: config.thresholdTokens, fastGatePct: config.fastGatePct },
319
- crew: { activeAgents, currentTurn },
320
- store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, originalTokens: st.originalTokens, tokensSaved: rt.tokensSaved, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
321
- repo: {
322
- checkpointCount: repo.checkpointCount,
323
- totalTokenEstimate: repo.totalTokenEstimate,
324
- originalTokens: repo.originalTokens,
325
- tokensSaved: repo.tokensSaved,
326
- sessionCount: repo.sessionCount,
327
- dedupAttempts: repo.dedupAttempts,
328
- dedupCollapsed: repo.dedupCollapsed,
329
- storageDedupRate: repo.storageDedupRate,
330
- },
331
- integrity: {
332
- regionsRetained: di.regionsRetained,
333
- compressedOriginalBytes: di.compressedOriginalBytes,
334
- duplicatesCollapsed: di.duplicatesCollapsed,
335
- bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
336
- },
337
- });
338
-
339
- // Live stats widget above the editor
340
- if (ctx) {
341
- const tokStr = lastCtxTokens != null ? `${Math.round(lastCtxTokens / 1000)}k` : "?";
342
- const maxStr = lastCtxWindow > 0 ? `${Math.round(lastCtxWindow / 1000)}k` : "?";
343
- const pctStr = lastCtxPercent != null ? `${Math.round(lastCtxPercent * 10) / 10}%` : "?%";
344
- const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
345
- // Storage dedup rate is cumulative (store-wide, per-repo) and survives
346
- // session resets. Always show a number: 0% before any compaction, a
347
- // decimal for sub-10% rates so small-but-real dedup isn't rounded away.
348
- const storageRate = st.storageDedupRate; // 0..1
349
- const dedupStr = storageRate * 100 >= 10
350
- ? `${Math.round(storageRate * 100)}%`
351
- : `${(storageRate * 100).toFixed(1)}%`;
352
- // saved = tokens removed from context (cumulative original − stored).
353
- // Show BOTH this-session (rt.tokensSaved) and repo-wide-total
354
- // (repo.tokensSaved) so the user sees per-session progress vs the running
355
- // repo total. "used" = stored checkpoint tokens (repo.totalTokenEstimate
356
- // vs st.totalTokenEstimate). Use "k" only at/above 1000 so small-but-real
357
- // numbers stay visible (previously Math.round(x/1000) zeroed <1000).
358
- const fmt = (x: number) => (x >= 1000 ? `${(x / 1000).toFixed(1)}k` : `${x}`);
359
- const savedStr = `${C.green}${fmt(rt.tokensSaved)} sess${C.reset} / ${C.blue}${fmt(repo.tokensSaved)} repo${C.reset}`;
360
- const usedStr = `${C.cyan}${fmt(st.totalTokenEstimate)} sess${C.reset} / ${C.blue}${fmt(repo.totalTokenEstimate)} repo${C.reset}`;
361
- const agentStr = activeAgents > 0 ? ` │ 🤖 ${activeAgents} agent${activeAgents === 1 ? "" : "s"}` : "";
362
- const turnStr = currentTurn > 0 ? ` │ turn ${currentTurn}` : "";
363
- // Phase 3 — pulsing status glyph while a compaction is in flight.
364
- const pulse = pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
365
- const lines = [
366
- ` ${C.amber}⚡ ${config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
367
- ` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
368
- ];
369
- // Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
370
- if (rt.tokensSaved > 0) {
371
- const goal = Math.max(savedGoal, 1);
372
- const pct = Math.min(100, Math.round((rt.tokensSaved / goal) * 100));
373
- const filled = Math.round((pct / 100) * 10);
374
- const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
375
- lines.push(` ${C.green}saved ${fmt(rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
376
- }
377
- // Live "now processing" line — teal while fresh (≤4s), then the last-seen
378
- // action keeps the widget lively. Cleared on session reset.
379
- const fresh = Date.now() - lastActivityAt < 4000;
380
- if (tierTrace && fresh) {
381
- lines.push(` ${pulse}${tierTrace}`);
382
- } else if (currentActivity) {
383
- lines.push(` ${fresh ? C.teal : C.dim}${currentActivity}${C.reset}`);
384
- } else if (pulsing) {
385
- lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
386
- }
387
- // Phase 3 — explain-why line (fresh only).
388
- if (lastWhy && fresh) lines.push(` ${C.gray}${lastWhy}${C.reset}`);
389
- // Phase 3 — recall/activity ticker (most-recent first), fresh only.
390
- if (fresh) {
391
- for (let i = ticker.length - 1; i >= 0; i--) {
392
- if (lines.length >= 9) break; // leave room for the hint line (MAX 10)
393
- lines.push(` ${i === ticker.length - 1 ? "" : C.dim}${ticker[i].text}${C.reset}`);
394
- }
395
- }
396
- // Plain-language hint so first-time users understand the widget. Always
397
- // last, dimmed. "/mega-help explains these terms."
398
- if (lines.length < 10) {
399
- lines.push(` ${C.dim}auto-compresses old context to free space · nothing deleted · /mega-help${C.reset}`);
400
- }
401
- ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
402
- }
403
- }
404
-
405
- // The only mutable per-session state. Reset on session_start / session_tree.
406
- let rt: SessionRuntime = {
407
- sessionId: normalizeSessionId(undefined),
408
- persistedThisSession: false,
409
- lastCheckpointId: undefined,
410
- lastCompactedFrom: 0,
411
- lastCompactedTokens: 0,
412
- dedupSkips: 0,
413
- dedupAttempts: 0,
414
- tokensSaved: 0,
415
- };
416
- let debounceUntil = 0;
417
- // Agent tracking for real-time widget updates
418
- let activeAgents = 0;
419
- let currentTurn = 0;
420
- // Recall block produced by auto-inline (resume/branch) that the next
421
- // before_agent_start should prepend to the system prompt. Unset after use.
422
- let pendingRecallBlock: string | undefined;
423
- let statusKey: string | undefined; // current status text for dashboard
424
- // Live "what it's doing right now" line for the toolbar. Set on each
425
- // compaction; shown in teal while recent, then kept as the last-seen action so
426
- // the widget is never blank. Cleared on session reset.
427
- let currentActivity: string | undefined;
428
- let lastActivityAt = 0;
429
- // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
430
- // Built from the store's sync onTier callback during a compaction so the user
431
- // watches each tier evaluate in real time. Cleared once the outcome settles.
432
- let tierTrace: string | undefined;
433
- // Phase 3 — standout toolbar state.
434
- // Recall/activity ticker: a small ring buffer (≤5) of recent compact/recall
435
- // events so the widget shows a live history instead of a single last action.
436
- interface TickerEntry { text: string; at: number; }
437
- const ticker: TickerEntry[] = [];
438
- const TICKER_MAX = 5;
439
- function pushTicker(text: string): void {
440
- ticker.push({ text, at: Date.now() });
441
- while (ticker.length > TICKER_MAX) ticker.shift();
442
- lastActivityAt = Date.now();
443
- }
444
- // Pulsing status: set true while a compaction is in flight, cleared on result.
445
- let pulsing = false;
446
- // Rolling "saved" goal for the progress bar — grows as we save more, so the
447
- // bar always has a meaningful denominator (never sits at 100% forever).
448
- let savedGoal = 50_000;
449
- // Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
450
- // while fresh.
451
- let lastWhy: string | undefined = undefined;
452
- // Cycling glyph phases for the pulsing status.
453
- const PULSE = ["◐", "◓", "◑", "◒"];
454
- // ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
455
- // escape codes (see wrapTextWithAnsi), so raw escapes render as colors. No
456
- // chalk dependency needed — these are just strings.
457
- const C = {
458
- reset: "\x1b[0m",
459
- dim: "\x1b[2m",
460
- bold: "\x1b[1m",
461
- amber: "\x1b[38;5;214m", // tier / ready
462
- green: "\x1b[38;5;120m", // saved
463
- cyan: "\x1b[38;5;51m", // used / live activity
464
- teal: "\x1b[38;5;37m", // processing (compress/dedup)
465
- magenta: "\x1b[38;5;201m", // dedup rate
466
- blue: "\x1b[38;5;75m", // repo totals
467
- gray: "\x1b[38;5;245m", // labels
468
- };
469
-
470
- function setStatus(ctx: ExtensionContext, text: string | undefined) {
471
- statusKey = text;
472
- ctx.ui.setStatus(STATUS_KEY, text);
473
- }
474
-
475
- function resetRuntime(sessionId: string | undefined) {
476
- const sid = normalizeSessionId(sessionId);
477
- if (rt.sessionId === sid && rt.persistedThisSession) return; // same session, keep checkpoint memory
478
- rt = {
479
- sessionId: sid,
480
- persistedThisSession: false,
481
- lastCheckpointId: undefined,
482
- lastCompactedFrom: 0,
483
- lastCompactedTokens: 0,
484
- dedupSkips: 0,
485
- dedupAttempts: 0,
486
- tokensSaved: 0,
487
- };
488
- statusKey = undefined;
489
- activeAgents = 0;
490
- currentTurn = 0;
491
- currentActivity = undefined;
492
- lastActivityAt = 0;
493
- tierTrace = undefined;
494
- ticker.length = 0;
495
- pulsing = false;
496
- savedGoal = 50_000;
497
- lastWhy = undefined;
498
- }
499
-
500
- /** Build the sync onTier callback that paints the live per-tier trace. */
501
- function makeTierCallback(ctx: ExtensionContext): (ev: { tier: "L0" | "L1" | "L2" | "new"; status: "scanning" | "deduped" | "passed" | "stored"; detail?: string }) => void {
502
- const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
503
- const seen = new Map<string, string>();
504
- const glyph = (status: string) =>
505
- status === "deduped" ? `${C.green}✓${C.reset}` :
506
- status === "passed" ? `${C.dim}○${C.reset}` :
507
- status === "scanning" ? `${C.amber}…${C.reset}` :
508
- `${C.cyan}●${C.reset}`;
509
- return (ev) => {
510
- const label =
511
- ev.tier === "new"
512
- ? `${C.cyan}stored${C.reset}`
513
- : `${ev.tier} ${glyph(ev.status)}` +
514
- (ev.detail ? ` ${C.gray}(${ev.detail})${C.reset}` : "");
515
- // Show the most recent outcome per tier (collapses re-fires).
516
- seen.set(ev.tier, label);
517
- const show: string[] = [];
518
- for (const t of order) if (seen.has(t)) show.push(seen.get(t)!);
519
- tierTrace = `${C.teal}⚙${C.reset} ${show.join(` ${C.gray}→${C.reset} `)}`;
520
- lastActivityAt = Date.now();
521
- try { snapshot(ctx); } catch { /* non-fatal */ }
522
- };
523
- }
524
-
525
- /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
526
- function runCompact(
527
- ctx: ExtensionContext,
528
- messages: AgentMessage[],
529
- opts: { keepFrom?: number; summary?: string } = {},
530
- ) {
531
- bindRepo(ctx.cwd);
532
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
533
- resetRuntime(sid);
534
- rt.sessionId = sid;
535
-
536
- const view = engineView(messages);
537
- const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
538
- if (keepFrom <= 0) return { skipped: true as const };
539
-
540
- pulsing = true; // animate the status line while the (sync) pipeline runs
541
- const result = compactSession(
542
- {
543
- sessionId: sid,
544
- messages: view,
545
- keepFrom,
546
- summary: opts.summary,
547
- timestamp: Date.now(),
548
- onTier: makeTierCallback(ctx),
549
- },
550
- store,
551
- );
552
- pulsing = false;
553
-
554
- if (result.skipped) return { skipped: true as const };
555
- if (!result.deduped) {
556
- rt.persistedThisSession = true;
557
- rt.lastCheckpointId = result.checkpointId;
558
- }
559
- rt.lastCompactedFrom = result.compactedFrom;
560
- rt.lastCompactedTokens = result.tokenEstimate;
561
- rt.dedupAttempts++;
562
- // Honest "tokens saved" for this session-instance only:
563
- // new checkpoint → original − stored
564
- // deduped onto existing → whole original region (nothing new stored)
565
- // Resets to 0 on session_start (rt is rebuilt) — so a fresh session shows 0
566
- // while the repo's cumulative saved (SQLite meta) keeps the running total.
567
- const saved = result.deduped
568
- ? result.originalTokenEstimate
569
- : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
570
- rt.tokensSaved += saved;
571
- if (result.deduped) rt.dedupSkips++;
572
- // Grow the rolling "saved" goal so the progress bar always has a fresh
573
- // denominator (we don't want it pinned at 100% once we pass an old target).
574
- if (rt.tokensSaved > savedGoal) savedGoal = Math.ceil((rt.tokensSaved * 1.25) / 10_000) * 10_000;
575
-
576
- // Live toolbar "now processing" line: what file/region just got compacted or
577
- // deduped. Reset to the last-seen action after a few seconds (see snapshot).
578
- const files = result.filesModified ?? [];
579
- const fileLabel = files.length
580
- ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
581
- : result.regionHash.slice(0, 8);
582
- currentActivity = result.deduped
583
- ? `♻ deduped ${fileLabel}`
584
- : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
585
- lastActivityAt = Date.now();
586
- // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
587
- // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
588
- lastWhy = result.deduped
589
- ? `why: deduped@${result.dedupReason ?? "tier"}`
590
- : `why: compacted → ${result.checkpointId}`;
591
- // Recall/activity ticker: record this event in the ring buffer.
592
- const savedK = (saved / 1000).toFixed(1);
593
- pushTicker(
594
- result.deduped
595
- ? `${C.green}♻${C.reset} deduped ${fileLabel} · ${savedK}k saved`
596
- : `${C.cyan}🗜${C.reset} ${result.checkpointId} · +${savedK}k · ${fileLabel}`,
597
- );
598
- // The per-tier trace has settled into the final outcome — fold it back into
599
- // the activity line and stop showing the live trace.
600
- tierTrace = undefined;
601
-
602
- // Record session activity + a daily-log entry in the per-repo SQLite store
603
- // (foundation for resume-sessions / daily-log features). Best-effort — never
604
- // block a compaction on bookkeeping.
605
- try {
606
- const repo = resolveRepoRoot(ctx.cwd);
607
- touchSession(sid, repo, currentStateDir);
608
- logDaily(sid, "compact", result.checkpointId, saved, currentStateDir);
609
- } catch {
610
- /* non-fatal: stats bookkeeping only */
611
- }
612
-
613
- // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
614
- // skip re-vectorizing an already-compacted region (zero token cost).
615
- pi.appendEntry(MARKER_TYPE, {
616
- checkpointId: result.checkpointId,
617
- regionHash: result.regionHash,
618
- tokenEstimate: result.tokenEstimate,
619
- deduped: result.deduped,
620
- });
621
-
622
- setStatus(
623
- ctx,
624
- rt.persistedThisSession
625
- ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
626
- : `mega-compact: ready`,
627
- );
628
- logger.info("compact", {
629
- sessionId: sid,
630
- checkpointId: result.checkpointId ?? "(deduped)",
631
- deduped: result.deduped,
632
- tokenEstimate: saved,
633
- compactedFrom: result.compactedFrom,
634
- });
635
- dashboard.event("compact", {
636
- sessionId: sid,
637
- checkpointId: result.checkpointId ?? "(deduped)",
638
- deduped: result.deduped,
639
- tokenEstimate: saved,
640
- compactedFrom: result.compactedFrom,
641
- });
642
- snapshot(ctx);
643
- return { skipped: false, result, keepFrom, saved };
644
- }
645
-
646
- /**
647
- * Unified recall (Layer 5). The ONE path that injects. Returns the recall
648
- * result; callers decide whether to stage it for before_agent_start (resume)
649
- * or report it (command).
650
- */
651
- function doRecall(ctx: ExtensionContext, query: string, source: "resume" | "command") {
652
- bindRepo(ctx.cwd);
653
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
654
- const result = recallAndInline(
655
- { sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true },
656
- store,
657
- );
658
- dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
659
- if (!result.empty && result.toInject.length > 0) {
660
- const top = result.toInject[0];
661
- const scorePct = Math.round((top.score ?? 0) * 100);
662
- const files = top.checkpoint.filesModified ?? [];
663
- const label = files.length ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ") : top.checkpoint.checkpointId;
664
- pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
665
- lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
666
- }
667
- return result;
668
- }
669
-
670
- // ---- Session lifecycle (state reset points) -------------------------------
671
- pi.on("session_start", async (event, ctx) => {
672
- resetRuntime(ctx.sessionManager.getSessionId());
673
- setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
674
- // Auto-inline on resume/fork/continue: stage the most relevant checkpoints
675
- // so the next before_agent_start prepends them to the system prompt.
676
- // Triggered whenever this session already has persisted checkpoints AND a
677
- // usable query — that covers reason "resume"/"fork" (explicit) and
678
- // reason "startup" (e.g. `pi --continue`s an existing session, which still
679
- // emits "startup" but with a populated message window). A brand-new empty
680
- // session has no checkpoints, so it's naturally excluded.
681
- if (config.autoInline) {
682
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
683
- const query = recentUserQuery(ctx);
684
- if (query && store.stats(sid).checkpointCount > 0) {
685
- const r = doRecall(ctx, query, "resume");
686
- if (!r.empty) {
687
- pendingRecallBlock = r.block;
688
- setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
689
- logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
690
- }
691
- }
692
- }
693
- dashboard.event("session_start", { reason: event.reason, sessionId: rt.sessionId });
694
- snapshot(ctx);
695
- });
696
-
697
- pi.on("session_tree", async (_event, ctx) => {
698
- // Branch navigation invalidates region indexes — reset checkpoint memory but
699
- // keep the on-disk store (markers replayed from entries below if needed).
700
- resetRuntime(ctx.sessionManager.getSessionId());
701
- setStatus(ctx, "mega-compact: ready (branch)");
702
- if (config.autoInline) {
703
- const query = recentUserQuery(ctx);
704
- if (query) {
705
- const r = doRecall(ctx, query, "resume");
706
- if (!r.empty) {
707
- pendingRecallBlock = r.block;
708
- logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
709
- }
710
- }
711
- }
712
- dashboard.event("session_tree", { sessionId: rt.sessionId });
713
- snapshot(ctx);
714
- });
715
-
716
- // ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
717
- pi.on("before_agent_start", async (event, _ctx) => {
718
- if (!pendingRecallBlock) return;
719
- const block = pendingRecallBlock;
720
- pendingRecallBlock = undefined; // one-shot: consume so we never double-inject
721
- return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
722
- });
723
-
724
- pi.on("session_shutdown", async (_event, ctx) => {
725
- setStatus(ctx, undefined);
726
- activeAgents = 0;
727
- currentTurn = 0;
728
- ctx.ui.setWidget(WIDGET_KEY, [], { placement: "aboveEditor" });
729
- });
730
-
731
- // ---- Agent tracking for real-time widget + status-line updates ---------
732
- pi.on("agent_start", async (_event, ctx) => {
733
- activeAgents++;
734
- dashboard.event("agent_start", { activeAgents });
735
- // Surface live agent activity on the status line (toolbar), not just the
736
- // above-editor widget — otherwise concurrent agents look frozen.
737
- setStatus(ctx, `mega-compact: ▶ ${activeAgents} agent${activeAgents === 1 ? "" : "s"}`);
738
- snapshot(ctx);
739
- });
740
-
741
- pi.on("agent_end", async (_event, ctx) => {
742
- activeAgents = Math.max(0, activeAgents - 1);
743
- dashboard.event("agent_end", { activeAgents });
744
- if (activeAgents > 0) {
745
- setStatus(ctx, `mega-compact: ▶ ${activeAgents} agent${activeAgents === 1 ? "" : "s"}`);
746
- } else {
747
- setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
748
- }
749
- snapshot(ctx);
750
- });
751
-
752
- pi.on("turn_start", async (event, ctx) => {
753
- currentTurn = event.turnIndex;
754
- dashboard.event("turn_start", { turnIndex: event.turnIndex });
755
- snapshot(ctx);
756
- });
757
-
758
- pi.on("turn_end", async (event, ctx) => {
759
- dashboard.event("turn_end", { turnIndex: event.turnIndex });
760
- snapshot(ctx);
761
- });
762
-
763
- // ---- Auto-trigger: fast-gate → confirm → Trident+persist → drop --------
764
- pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
765
- if (!config.auto) return;
766
- const usage = ctx.getContextUsage();
767
- const pct = usage?.percent;
768
- // Always track context for the dashboard, even if we return early below.
769
- lastCtxTokens = usage?.tokens ?? null;
770
- lastCtxPercent = pct ?? null;
771
- lastCtxWindow = usage?.contextWindow ?? 0;
772
- snapshot(ctx);
773
- if (pct == null) return;
774
-
775
- const messages = event.messages;
776
- const view = engineView(messages);
777
- // Prefer the runtime's real token estimate; fall back to our heuristic
778
- // (and to a percent-of-window proxy when tokens is unknown).
779
- const currentTokens =
780
- usage?.tokens ?? estimateSessionTokens(view) ??
781
- Math.round((pct / 100) * (usage?.contextWindow ?? 0));
782
-
783
- // FAST GATE: token-based (tier threshold), not percentage-based.
784
- // A 20% gate on a 2M window = 400k, which is way above the 50k low-tier
785
- // threshold. Gate on the actual token count instead.
786
- if (currentTokens < config.thresholdTokens) return;
787
-
788
- const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
789
- if (!check.shouldCompact) return;
790
-
791
- // Debounce so we don't fire on every context event past threshold.
792
- const now = Date.now();
793
- if (now < debounceUntil) return;
794
- debounceUntil = now + 2000;
795
-
796
- const ran = runCompact(ctx, messages);
797
- if (ran.skipped) return;
798
-
799
- // DROP the compacted range from the outgoing context, honoring the anchor
800
- // floor + tool-pair boundary guards (PREVENT-PI-001/002).
801
- const kept = dropCompactedRange(messages, ran.keepFrom!, config.anchorUserMessages);
802
- if (kept.length < messages.length) {
803
- return { messages: kept };
804
- }
805
- });
806
-
807
- // ---- Cancel native compaction once we've persisted our own -------------
808
- pi.on("session_before_compact", async (_event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
809
- resetRuntime(ctx.sessionManager.getSessionId());
810
- if (rt.persistedThisSession) {
811
- // We already persisted a checkpoint for this session (via the context
812
- // hook drop) — cancel pi's own compaction to avoid double-compacting.
813
- // Our context-hook drop already trimmed the window.
814
- return { cancel: true };
815
- }
816
- // We haven't persisted yet this session: let pi run its native compaction.
817
- // (Our auto-trigger only fires again past the threshold, and will then
818
- // capture a checkpoint next time around.)
819
- return {};
820
- });
821
-
822
- // ---- Commands ----------------------------------------------------------
823
- pi.registerCommand("mega-compact", {
824
- description: "Compress current session context into the local vector store.",
825
- handler: async (args: string, ctx: ExtensionContext) => {
826
- const sessionEntries = ctx.sessionManager.getEntries();
827
- // Project entries (branch-aware) into the message view.
828
- const messages: AgentMessage[] = sessionEntries.flatMap((e) => sessionEntryToContextMessages(e));
829
- const summaryArg = args.trim();
830
- const ran = runCompact(ctx, messages, summaryArg ? { summary: summaryArg } : {});
831
- if ("skipped" in ran && ran.skipped) {
832
- ctx.ui.notify("[mega-compact] Nothing to compact (session too small).");
833
- return;
834
- }
835
- const r = (ran as { result: { deduped: boolean; checkpointId?: string; tokenEstimate: number } }).result;
836
- ctx.ui.notify(
837
- `[mega-compact] ${r.deduped ? "region already compacted (deduped)" : `persisted ${r.checkpointId}`} · ` +
838
- `${r.tokenEstimate} tok · ${currentStateDir}`,
839
- );
840
- },
841
- });
842
-
843
- pi.registerCommand("mega-recall", {
844
- description: "Recall relevant compacted context from the vector store and inline it.",
845
- handler: async (args: string, ctx: ExtensionContext) => {
846
- const query = args.trim() || recentUserQuery(ctx);
847
- if (!query) {
848
- ctx.ui.notify("[mega-compact] /mega-recall needs a query or a prior user message.");
849
- return;
850
- }
851
- const r = doRecall(ctx, query, "command");
852
- if (r.empty) {
853
- logger.info("recall-empty", { query });
854
- ctx.ui.notify(`[mega-compact] recall found nothing new for "${query}".`);
855
- return;
856
- }
857
- // Stage the block so the next before_agent_start prepends it (actual
858
- // injection). Report what was selected now for immediate feedback.
859
- pendingRecallBlock = r.block;
860
- const list = r.report.map((l) => l).join("\n");
861
- logger.info("recall", { query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
862
- setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
863
- ctx.ui.notify(
864
- `[mega-compact] recall staged ${r.toInject.length} checkpoint(s) for "${query}":\n${list}\n` +
865
- `(injected at the next turn via system prompt)`,
866
- );
867
- },
868
- });
869
-
870
- pi.registerCommand("mega-status", {
871
- description: "Show mega-compact config, context usage, and the data-safety invariant.",
872
- handler: async (_args: string, ctx: ExtensionContext) => {
873
- bindRepo(ctx.cwd);
874
- const usage = ctx.getContextUsage();
875
- const pct = usage?.percent != null ? `${usage.percent}%` : "n/a";
876
- const tokens = usage?.tokens != null ? `${usage.tokens} tok` : "n/a";
877
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
878
- const st = store.stats(sid);
879
- const repo = store.repoStats();
880
- const di = store.dataInvariant();
881
- const fmtB = (b: number) =>
882
- b >= 1_048_576 ? `${(b / 1_048_576).toFixed(1)} MiB` :
883
- b >= 1024 ? `${(b / 1024).toFixed(1)} KiB` : `${b} B`;
884
- // Tangible cost: turn "tokens saved" into a dollar figure + context-days
885
- // extended, so the counter is concrete rather than opaque. ~$3 / 1M tok
886
- // (rough blended rate); contextWindow ÷ savedRate = days of context bought.
887
- const usd = (repo.tokensSaved / 1_000_000 * 3).toFixed(2);
888
- const ctxWindow = usage?.contextWindow ?? 0;
889
- const daysExtended = ctxWindow > 0 && repo.tokensSaved > 0
890
- ? (repo.tokensSaved / ctxWindow).toFixed(1)
891
- : "0";
892
- const costStr = `≈ $${usd} saved · ${daysExtended} context-windows extended`;
893
- // Recall-quality badge (Phase 4): trust score from monitoring metrics.
894
- const m = loadMetrics(currentStateDir);
895
- const fp = fpRate(m, "L2");
896
- const p95L2 = p95(m.latency.L2 ?? []);
897
- const relPct = (st.dedupHitRate * 100).toFixed(0);
898
- const qualityStr = `recall ${relPct}% relevant · FP ${(fp * 100).toFixed(1)}% · L2 p95 ${p95L2.toFixed(0)}ms`;
899
- ctx.ui.notify(
900
- `[mega-compact] pct=${pct} tokens=${tokens} tier=${config.tier} fastGate=${config.fastGatePct}% ` +
901
- `threshold=${config.thresholdTokens} auto=${config.auto} autoInline=${config.autoInline}\n` +
902
- `[mega-compact] store: ${st.checkpointCount} chkpt · ` +
903
- `${st.totalTokenEstimate} tok · last=${st.lastCheckpointId ?? "—"} · ` +
904
- `injected=${st.injectedCount} · dedup=${(st.dedupHitRate * 100).toFixed(0)}%\n` +
905
- `[mega-compact] anchor=${config.anchorUserMessages} preserveRecent=${config.preserveRecent} ` +
906
- `autoInlineK=${config.autoInlineK} dedupSim=${config.dedupSim} debug=${config.debug}\n` +
907
- `[mega-compact] 🛡 data-safe: ${di.regionsRetained} regions retained ` +
908
- `(${fmtB(di.compressedOriginalBytes)} compressed-original) · ` +
909
- `${di.duplicatesCollapsed} dedup-duplicates collapsed · ` +
910
- `${C.green}0 bytes permanently deleted${C.reset}\n` +
911
- `[mega-compact] 💰 ${costStr}\n` +
912
- `[mega-compact] 🎯 ${qualityStr}\n` +
913
- `[mega-compact] stateDir=${currentStateDir}`,
914
- );
915
- },
916
- });
917
-
918
- // ---- Phase 4: cheap standout commands (data is already persisted) -------
919
-
920
- /** Resolve a checkpoint by id (or "recent"/"last") from this session's store. */
921
- function findCheckpoint(sid: string, ref: string) {
922
- const all = listCheckpoints(sid, currentStateDir);
923
- if (all.length === 0) return undefined;
924
- if (!ref || ref === "recent" || ref === "last") return all[all.length - 1];
925
- return all.find((c) => c.checkpointId === ref) ?? all.find((c) => c.checkpointId.endsWith(ref));
926
- }
927
-
928
- pi.registerCommand("mega-restore", {
929
- description: "Re-inject a checkpoint's verbatim original region into context. Usage: /mega-restore <chkpt|recent>",
930
- handler: async (args: string, ctx: ExtensionContext) => {
931
- bindRepo(ctx.cwd);
932
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
933
- const cp = findCheckpoint(sid, args.trim());
934
- if (!cp) {
935
- ctx.ui.notify(`[mega-compact] no checkpoint found${args.trim() ? ` for "${args.trim()}"` : ""} in this session. Try /mega-history.`);
936
- return;
937
- }
938
- if (!cp.compressedOriginal) {
939
- ctx.ui.notify(`[mega-compact] ${cp.checkpointId} has no recoverable original (pre-blob or direct add). Cannot restore verbatim.`);
940
- return;
941
- }
942
- const original = decompressSmart(cp.compressedOriginal).toString("utf-8");
943
- // Re-inject verbatim via before_agent_start (PREVENT-PI-003) — never
944
- // touches live messages, only prepends the restored region to systemPrompt.
945
- pendingRecallBlock = `The following compacted context was RESTORED from checkpoint ${cp.checkpointId} (verbatim original region):\n\n${original}`;
946
- const files = cp.filesModified?.length ? cp.filesModified.join(", ") : "(no files captured)";
947
- ctx.ui.notify(
948
- `[mega-compact] ♻ restored ${cp.checkpointId} — ${original.length} chars re-injected on next turn.\n` +
949
- `[mega-compact] files: ${files}`,
950
- );
951
- dashboard.event("restore", { checkpointId: cp.checkpointId, chars: original.length });
952
- },
953
- });
954
-
955
- pi.registerCommand("mega-history", {
956
- description: "List this session's checkpoints (id, date, files, tokens). Usage: /mega-history",
957
- handler: async (_args: string, ctx: ExtensionContext) => {
958
- bindRepo(ctx.cwd);
959
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
960
- const all = listCheckpoints(sid, currentStateDir);
961
- if (all.length === 0) {
962
- ctx.ui.notify("[mega-compact] no checkpoints in this session yet.");
963
- return;
964
- }
965
- const rows = all.map((c) => {
966
- const when = c.timestamp ? new Date(c.timestamp).toISOString().slice(0, 16).replace("T", " ") : "—";
967
- const files = c.filesModified?.length ? c.filesModified.map((f) => f.split("/").pop()).join(", ") : "—";
968
- const orig = c.originalTokenEstimate ?? 0;
969
- const stored = c.tokenEstimate ?? 0;
970
- const saved = Math.max(0, orig - stored);
971
- return ` ${c.checkpointId} ${when} ${C.cyan}${saved}t saved${C.reset} ${files}`;
972
- });
973
- ctx.ui.notify(
974
- `[mega-compact] ${all.length} checkpoint(s) in this session:\n` + rows.join("\n") +
975
- `\n[mega-compact] /mega-view <chkpt> to see the original region · /mega-restore <chkpt> to re-inject it`,
976
- );
977
- },
978
- });
979
-
980
- pi.registerCommand("mega-view", {
981
- description: "Show a checkpoint's verbatim original region. Usage: /mega-view <chkpt|recent>",
982
- handler: async (args: string, ctx: ExtensionContext) => {
983
- bindRepo(ctx.cwd);
984
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
985
- const cp = findCheckpoint(sid, args.trim());
986
- if (!cp) {
987
- ctx.ui.notify(`[mega-compact] no checkpoint found${args.trim() ? ` for "${args.trim()}"` : ""}. Try /mega-history.`);
988
- return;
989
- }
990
- if (!cp.compressedOriginal) {
991
- ctx.ui.notify(`[mega-compact] ${cp.checkpointId} summary:\n${cp.summary.slice(0, 500)}${cp.summary.length > 500 ? "…" : ""}\n(no verbatim original stored)`);
992
- return;
993
- }
994
- const original = decompressSmart(cp.compressedOriginal).toString("utf-8");
995
- ctx.ui.notify(
996
- `[mega-compact] ${cp.checkpointId} — original region (${original.length} chars):\n` +
997
- `${original.slice(0, 1500)}${original.length > 1500 ? "\n…(truncated)" : ""}`,
998
- );
999
- },
1000
- });
1001
-
1002
- pi.registerCommand("mega-help", {
1003
- description: "Plain-language glossary of what mega-compact's stats mean.",
1004
- handler: async (_args: string, ctx: ExtensionContext) => {
1005
- ctx.ui.notify(
1006
- `[mega-compact] glossary — what the numbers mean:\n` +
1007
- `• token — a chunk of text (~4 chars). Context window = how much text fits in memory at once.\n` +
1008
- `• space freed — how much conversation we've compressed away to make room (the win).\n` +
1009
- `• memory held — how much compact summary we're currently keeping as your 'notes'.\n` +
1010
- `• saved checkpoint — a compact summary of an old conversation chunk we stored.\n` +
1011
- `• repeat-skipped — how often new text matched something we already had, so we didn't store a duplicate.\n` +
1012
- `• injected — times we pasted an old saved note back into the chat because it was relevant.\n` +
1013
- `• recall relevance — of those, how often the note was actually on-topic.\n` +
1014
- `• data safety — every compressed region is kept verbatim; nothing is permanently deleted. /mega-restore brings any of it back.`,
1015
- );
1016
- },
1017
- });
1018
-
1019
- pi.registerCommand("mega-tier", {
1020
- description: "Show or change the compaction tier at runtime. Usage: /mega-tier [low|medium|high|ultra|mega]",
1021
- handler: async (args: string, ctx: ExtensionContext) => {
1022
- const arg = args.trim().toLowerCase();
1023
- if (!arg) {
1024
- // Show current tier and available options.
1025
- ctx.ui.notify(
1026
- `[mega-compact] current tier: ${config.tier} (${config.thresholdTokens} tok)\n` +
1027
- `[mega-compact] available tiers: ${Object.entries(COMPACT_TIERS).map(([k, v]) => `${k}=${v}`).join(", ")}`,
1028
- );
1029
- return;
1030
- }
1031
- if (!(arg in COMPACT_TIERS)) {
1032
- ctx.ui.notify(`[mega-compact] unknown tier "${arg}". Available: ${Object.keys(COMPACT_TIERS).join(", ")}`);
1033
- return;
1034
- }
1035
- const newTier = arg as CompactTier;
1036
- config.tier = newTier;
1037
- config.thresholdTokens = COMPACT_TIERS[newTier];
1038
- setStatus(ctx, `mega-compact: tier → ${newTier} (${config.thresholdTokens} tok)`);
1039
- ctx.ui.notify(`[mega-compact] tier changed to ${newTier} (threshold: ${config.thresholdTokens} tokens)`);
1040
- snapshot(ctx);
1041
- },
1042
- });
1043
-
1044
- // ---- Dashboard server commands ----------------------------------------
1045
-
1046
- const portFile = join(currentStateDir, "port.pid");
1047
- const runnerFile = join(currentStateDir, "_dashboard-runner.mjs");
1048
- const launchLog = join(currentStateDir, "_dashboard-launch.log");
1049
- // Whether the runner must be spawned with --experimental-strip-types (true only
1050
- // when we fall back to the .ts source outside node_modules; false when using
1051
- // the shipped compiled dist/extensions/dashboard-server.js).
1052
- let dashboardNeedsStrip = false;
1053
-
1054
- // The dashboard server binds 9320–9329 (TARGET_PORT..TARGET_PORT+PORT_RANGE-1
1055
- // in dashboard-server.js). Probe each for a live /api/snapshot so we can detect
1056
- // readiness even when port.pid landed in a different state dir than we poll.
1057
- async function findLivePort(): Promise<number | null> {
1058
- for (let port = 9320; port <= 9329; port++) {
1059
- try {
1060
- const res = await fetch(`http://localhost:${port}/api/snapshot`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: localhost liveness probe of the dashboard server this extension spawned
1061
- if (res.ok) return port;
1062
- } catch { /* not on this port — try next */ }
1063
- }
1064
- return null;
1065
- }
1066
-
1067
- /** Try to reach a running dashboard server. Returns { port, url } or null. */
1068
- async function isServerRunning(): Promise<{ port: number; url: string } | null> {
1069
- const port = await findLivePort();
1070
- if (!port) {
1071
- // Stale marker with no live server behind it — clean up.
1072
- if (existsSync(portFile)) {
1073
- try { unlinkSync(portFile); } catch { /* ignore */ }
1074
- }
1075
- return null;
1076
- }
1077
- return { port, url: `http://localhost:${port}` }; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
1078
- }
1079
-
1080
- /**
1081
- * Resolve the launchable dashboard-server module.
1082
- *
1083
- * CRITICAL: Node's `--experimental-strip-types` REFUSES to strip .ts files that
1084
- * live under `node_modules` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Since
1085
- * the published package installs under node_modules, importing the .ts source
1086
- * fails in every real install (it only worked from a source checkout). So we
1087
- * prefer the COMPILED dist/extensions/dashboard-server.js (which the package
1088
- * ships from v0.4.6 — it imports only Node built-ins, so it runs standalone),
1089
- * and only fall back to the .ts source (with strip-types) when the compiled
1090
- * file is absent AND we're not under node_modules (dev checkout without a build).
1091
- *
1092
- * Returns { entry, needsStripTypes }.
1093
- */
1094
- function resolveDashboardEntry(): { entry: string; needsStripTypes: boolean } | null {
1095
- const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
1096
- const candidates = [
1097
- // 1. Compiled sibling when running from dist/ (import.meta is dist/extensions/…js)
1098
- { entry: join(here, "dashboard-server.js"), strip: false },
1099
- // 2. Compiled under the package's dist/ when running from source extensions/…ts
1100
- { entry: join(here, "..", "dist", "extensions", "dashboard-server.js"), strip: false },
1101
- // 3. Last resort: the .ts source (only strippable OUTSIDE node_modules)
1102
- { entry: join(here, "dashboard-server.ts"), strip: true },
1103
- ];
1104
- for (const c of candidates) {
1105
- if (!existsSync(c.entry)) continue;
1106
- if (c.strip && c.entry.includes(`${sep}node_modules${sep}`)) continue; // unstrippable
1107
- return { entry: c.entry, needsStripTypes: c.strip };
1108
- }
1109
- return null;
1110
- }
1111
-
1112
- /** Write a small ESM runner script that imports and launches the dashboard server. */
1113
- function writeRunnerScript(): boolean {
1114
- const resolved = resolveDashboardEntry();
1115
- if (!resolved) return false;
1116
- dashboardNeedsStrip = resolved.needsStripTypes;
1117
- const script = [
1118
- `import { appendFileSync } from "node:fs";`,
1119
- `const __log = ${JSON.stringify(launchLog)};`,
1120
- `function __fail(err) {`,
1121
- ` const msg = "[mega-compact] dashboard failed: " + (err && err.stack ? err.stack : String(err));`,
1122
- ` try { appendFileSync(__log, msg + "\\n"); } catch { /* ignore */ }`,
1123
- ` console.error(msg);`,
1124
- ` process.exit(1);`,
1125
- `}`,
1126
- `import { launchDashboardServer } from ${JSON.stringify(resolved.entry)};`,
1127
- `launchDashboardServer(${JSON.stringify(currentStateDir)}).catch(__fail);`,
1128
- ].join("\n");
1129
- writeFileSync(runnerFile, script);
1130
- return true;
1131
- }
1132
-
1133
- /** Open a URL in the default browser. Platform-aware. Uses spawn (not exec) to avoid shell injection. */
1134
- function openBrowser(url: string): void {
1135
- const cmd =
1136
- process.platform === "darwin" ? "open" :
1137
- process.platform === "win32" ? "start" :
1138
- "xdg-open";
1139
- try {
1140
- spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
1141
- } catch {
1142
- /* non-fatal — user can open manually */
1143
- }
1144
- }
1145
-
1146
- pi.registerCommand("mega-dashboard", {
1147
- description: "Start the local web dashboard and optionally open it in the default browser.",
1148
- handler: async (_args: string, ctx: ExtensionContext) => {
1149
- bindRepo(ctx.cwd);
1150
- let info = await isServerRunning();
1151
-
1152
- if (info) {
1153
- ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
1154
- const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
1155
- if (open) openBrowser(info.url);
1156
- return;
1157
- }
1158
-
1159
- // Start the server
1160
- ctx.ui.notify("[mega-compact] starting dashboard server…");
1161
- if (!writeRunnerScript()) {
1162
- ctx.ui.notify("[mega-compact] dashboard entry not found — check logs.");
1163
- return;
1164
- }
1165
-
1166
- const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
1167
- const child = spawn(process.execPath, args, {
1168
- detached: true,
1169
- stdio: "ignore",
1170
- });
1171
- child.unref();
1172
-
1173
- // Poll for a live server (port 9320–9329) instead of relying solely on the
1174
- // port.pid marker, which can land in a different state dir than the one we
1175
- // poll when a prior compact left currentStateDir pointing elsewhere.
1176
- const deadline = Date.now() + 6_000;
1177
- let port: number | null = null;
1178
- while (Date.now() < deadline) {
1179
- await new Promise((resolve) => setTimeout(resolve, 300));
1180
- port = await findLivePort();
1181
- if (port) break;
1182
- }
1183
-
1184
- if (!port) {
1185
- let detail = "";
1186
- try {
1187
- const log = readFileSync(launchLog, "utf-8").trim();
1188
- if (log) detail = ` — ${log.split("\n").slice(-3).join("; ")}`;
1189
- } catch { /* no log yet */ }
1190
- ctx.ui.notify(`[mega-compact] dashboard server failed to start${detail}. See ${launchLog}`);
1191
- return;
1192
- }
1193
-
1194
- const url = `http://localhost:${port}`; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
1195
- ctx.ui.notify(`[mega-compact] dashboard running at ${url}`);
1196
- const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${url} in browser?`);
1197
- if (open) openBrowser(url);
1198
- },
1199
- });
1200
-
1201
- pi.registerCommand("mega-dashboard-stop", {
1202
- description: "Stop the local dashboard server.",
1203
- handler: async (_args: string, ctx: ExtensionContext) => {
1204
- if (!existsSync(portFile)) {
1205
- ctx.ui.notify("[mega-compact] no dashboard server running.");
1206
- return;
1207
- }
1208
- try {
1209
- const info = JSON.parse(readFileSync(portFile, "utf-8"));
1210
- // Verify the server is actually ours by probing the port before killing
1211
- try {
1212
- await fetch(`http://localhost:${info.port}/api/snapshot`, { signal: AbortSignal.timeout(1000) }); // guardrails-allow PREVENT-PI-004: localhost probe to verify the dashboard server is ours before stopping it
1213
- } catch {
1214
- // Not responding — just clean up stale pid file
1215
- try { unlinkSync(portFile); } catch { /* ok */ }
1216
- ctx.ui.notify("[mega-compact] dashboard was not running (stale pid file cleaned up).");
1217
- return;
1218
- }
1219
- if (info?.pid) process.kill(info.pid, "SIGTERM");
1220
- } catch { /* already dead */ }
1221
- try { unlinkSync(portFile); } catch { /* ok */ }
1222
- ctx.ui.notify("[mega-compact] dashboard stopped.");
1223
- },
1224
- });
1225
-
1226
- pi.registerCommand("mega-dashboard-status", {
1227
- description: "Check if the dashboard server is running.",
1228
- handler: async (_args: string, ctx: ExtensionContext) => {
1229
- const info = await isServerRunning();
1230
- if (info) {
1231
- ctx.ui.notify(`[mega-compact] dashboard running at ${info.url}`);
1232
- } else {
1233
- ctx.ui.notify("[mega-compact] dashboard is not running. Use /dashboard to start it.");
1234
- }
1235
- },
1236
- });
1237
- }
1238
-
1239
- /** Latest user message text — used as the auto-inline recall query. */
1240
- function recentUserQuery(ctx: ExtensionContext): string {
1241
- try {
1242
- const entries = ctx.sessionManager.getEntries();
1243
- for (let i = entries.length - 1; i >= 0; i--) {
1244
- const msgs = sessionEntryToContextMessages(entries[i]);
1245
- for (let j = msgs.length - 1; j >= 0; j--) {
1246
- if (msgs[j].role === "user") {
1247
- const c = (msgs[j] as { content: unknown }).content;
1248
- if (typeof c === "string") return c;
1249
- if (Array.isArray(c)) return c.map((b: any) => b.text).join(" ");
1250
- }
1251
- }
1252
- }
1253
- } catch {
1254
- /* best-effort */
1255
- }
1256
- return "";
37
+ const runtime = new MegaRuntime(config);
38
+ registerEventHandlers(pi, runtime, config);
39
+ registerCommands(pi, runtime, config);
40
+ registerDashboardCommands(pi, runtime);
1257
41
  }