pi-mega-compact 0.4.14 → 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.
@@ -192,6 +192,34 @@ test("/megacompact-status reports live store stats", async () => {
192
192
  await h.commands["mega-status"].handler("", ctx);
193
193
  assert.ok(h.notifies.some((n) => n.includes("store:") && n.includes("chkpt")), "status shows checkpoint count");
194
194
  });
195
+ // ---- Model/provider capture (Phase 5b model_snapshots) ----------------------
196
+ test("model_select captures model + provider into SQL", async () => {
197
+ const h = harness();
198
+ const modelCtx = h.ctx({
199
+ model: { id: "claude-opus-4-8", name: "Claude Opus 4.8", provider: "anthropic", contextWindow: 200000, maxTokens: 32000, reasoning: false, cost: { input: 0.000015, output: 0.000075 } },
200
+ modelRegistry: { getProviderDisplayName: (p) => (p === "anthropic" ? "Anthropic" : p) },
201
+ });
202
+ await h.fire("model_select", {}, modelCtx);
203
+ const { latestModelSnapshot } = await import("../src/store/sqlite.js");
204
+ const snap = latestModelSnapshot(h.stateDir);
205
+ assert.ok(snap, "model_snapshots row persisted");
206
+ assert.equal(snap.modelId, "claude-opus-4-8", "correct model id captured");
207
+ assert.equal(snap.provider, "anthropic", "correct provider captured");
208
+ assert.equal(snap.providerName, "Anthropic", "provider display name resolved");
209
+ assert.equal(snap.inputRate, 0.000015, "input rate captured");
210
+ });
211
+ test("/mega-status surfaces the captured model + provider", async () => {
212
+ const h = harness();
213
+ const modelCtx = h.ctx({
214
+ model: { id: "claude-opus-4-8", name: "Claude Opus 4.8", provider: "anthropic", contextWindow: 200000, maxTokens: 32000, reasoning: false, cost: { input: 0.000015, output: 0.000075 } },
215
+ modelRegistry: { getProviderDisplayName: () => "Anthropic" },
216
+ });
217
+ await h.fire("model_select", {}, modelCtx);
218
+ await h.fire("context", { type: "context", messages: h.session }, h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) }));
219
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 50000, contextWindow: 200000, percent: 25 }) });
220
+ await h.commands["mega-status"].handler("", ctx);
221
+ assert.ok(h.notifies.some((n) => n.includes("🤖 model:") && n.includes("Claude Opus 4.8") && n.includes("Anthropic")), "status surfaces captured model + provider");
222
+ });
195
223
  // ---- Named compaction tiers -------------------------------------------------
196
224
  // low=50k, medium=100k, high=200k, ultra=1M, mega=10M. Driven through the REAL
197
225
  // loadConfig()/status path by setting MEGACOMPACT_TIER before loading the ext.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * mega-config.ts — extension config: named compaction tiers, env helpers,
3
+ * config resolution, and per-repo state-dir scoping.
4
+ *
5
+ * Pure/standalone: depends only on node built-ins + src/config. No shared
6
+ * closure state, so it can be imported by the runtime, commands, and events
7
+ * modules without a cycle.
8
+ */
9
+ import { STATE_DIR_DEFAULT } from "../src/config.js";
10
+ import { join } from "node:path";
11
+ import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the store per-repo
12
+ /**
13
+ * Named compaction tiers. A tier sets the token threshold at which the
14
+ * auto-trigger persists a checkpoint; pick by how aggressively you want the
15
+ * session trimmed. Explicit MEGACOMPACT_THRESHOLD_TOKENS always wins.
16
+ */
17
+ export const COMPACT_TIERS = {
18
+ low: 50_000,
19
+ medium: 100_000,
20
+ high: 200_000,
21
+ ultra: 1_000_000,
22
+ mega: 10_000_000,
23
+ };
24
+ function envFlag(name, fallback) {
25
+ const v = process.env[name];
26
+ if (v == null || v === "")
27
+ return fallback;
28
+ const n = Number(v);
29
+ return Number.isFinite(n) ? n : fallback;
30
+ }
31
+ function envBool(name, fallback) {
32
+ const v = process.env[name];
33
+ if (v == null || v === "")
34
+ return fallback;
35
+ return v === "true" || v === "1";
36
+ }
37
+ /** Resolve the effective token threshold from TIER (or explicit) env vars. */
38
+ function resolveThreshold() {
39
+ const explicit = process.env.MEGACOMPACT_THRESHOLD_TOKENS;
40
+ if (explicit != null && explicit !== "") {
41
+ const n = Number(explicit);
42
+ if (Number.isFinite(n))
43
+ return { tier: "custom", thresholdTokens: n };
44
+ }
45
+ const raw = (process.env.MEGACOMPACT_TIER ?? "low").toLowerCase();
46
+ const tier = (raw in COMPACT_TIERS ? raw : "low");
47
+ return { tier, thresholdTokens: COMPACT_TIERS[tier] };
48
+ }
49
+ /** Build the resolved config from env + defaults. */
50
+ export function loadConfig() {
51
+ const { tier, thresholdTokens } = resolveThreshold();
52
+ return {
53
+ tier,
54
+ // Global default; the live store/dashboard are rebound per-repo at runtime
55
+ // via MegaRuntime.bindRepo() so each git repo gets its own isolated state dir.
56
+ stateDir: process.env.MEGACOMPACT_STATE_DIR ?? STATE_DIR_DEFAULT,
57
+ fastGatePct: envFlag("MEGACOMPACT_FAST_GATE_PCT", 70),
58
+ thresholdTokens,
59
+ anchorUserMessages: envFlag("MEGACOMPACT_ANCHOR_USER_MESSAGES", 3),
60
+ preserveRecent: envFlag("MEGACOMPACT_PRESERVE_RECENT", 4),
61
+ auto: envBool("MEGACOMPACT_AUTO", true),
62
+ autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
63
+ autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
64
+ dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
65
+ debug: envBool("MEGACOMPACT_DEBUG", false),
66
+ };
67
+ }
68
+ /** Mutate tier + threshold in place (used by /mega-tier at runtime). */
69
+ export function setTier(config, tier) {
70
+ config.tier = tier;
71
+ config.thresholdTokens = COMPACT_TIERS[tier];
72
+ }
73
+ /**
74
+ * Resolve the current repo's git root from a cwd. Returns undefined for a
75
+ * non-git directory (caller falls back to a global state dir).
76
+ */
77
+ export function resolveRepoRoot(cwd) {
78
+ try {
79
+ const out = execSync("git rev-parse --show-toplevel", {
80
+ cwd,
81
+ encoding: "utf-8",
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ }).trim();
84
+ return out || undefined;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
90
+ /**
91
+ * Per-repo state dir: <repo>/.pi/mega-compact (tracked, so it travels with the
92
+ * repo across devices — not gitignored). Falls back to `fallback` for non-git
93
+ * cwds (the explicit MEGACOMPACT_STATE_DIR override, if set).
94
+ */
95
+ export function repoStateDir(cwd, fallback) {
96
+ const root = resolveRepoRoot(cwd);
97
+ if (!root)
98
+ return fallback;
99
+ return join(root, ".pi", "mega-compact");
100
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * mega-dashboard-cmds.ts — the local web-dashboard slash commands.
3
+ *
4
+ * Spawns / discovers / stops the optional localhost dashboard server
5
+ * (extensions/dashboard-server.ts) as a detached child process. All network
6
+ * usage here is loopback-only and audited via // guardrails-allow PREVENT-PI-004.
7
+ */
8
+ import { join, dirname, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
11
+ import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
12
+ /** Register the dashboard server lifecycle commands. */
13
+ export function registerDashboardCommands(pi, runtime) {
14
+ const portFile = join(runtime.currentStateDir, "port.pid");
15
+ const runnerFile = join(runtime.currentStateDir, "_dashboard-runner.mjs");
16
+ const launchLog = join(runtime.currentStateDir, "_dashboard-launch.log");
17
+ // Whether the runner must be spawned with --experimental-strip-types (true only
18
+ // when we fall back to the .ts source outside node_modules; false when using
19
+ // the shipped compiled dist/extensions/dashboard-server.js).
20
+ let dashboardNeedsStrip = false;
21
+ // The dashboard server binds 9320–9329 (TARGET_PORT..TARGET_PORT+PORT_RANGE-1
22
+ // in dashboard-server.js). Probe each for a live /api/snapshot so we can detect
23
+ // readiness even when port.pid landed in a different state dir than we poll.
24
+ async function findLivePort() {
25
+ for (let port = 9320; port <= 9329; port++) {
26
+ try {
27
+ 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
28
+ if (res.ok)
29
+ return port;
30
+ }
31
+ catch { /* not on this port — try next */ }
32
+ }
33
+ return null;
34
+ }
35
+ /** Try to reach a running dashboard server. Returns { port, url } or null. */
36
+ async function isServerRunning() {
37
+ const port = await findLivePort();
38
+ if (!port) {
39
+ // Stale marker with no live server behind it — clean up.
40
+ if (existsSync(portFile)) {
41
+ try {
42
+ unlinkSync(portFile);
43
+ }
44
+ catch { /* ignore */ }
45
+ }
46
+ return null;
47
+ }
48
+ return { port, url: `http://localhost:${port}` }; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
49
+ }
50
+ /**
51
+ * Resolve the launchable dashboard-server module.
52
+ *
53
+ * CRITICAL: Node's `--experimental-strip-types` REFUSES to strip .ts files that
54
+ * live under `node_modules` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Since
55
+ * the published package installs under node_modules, importing the .ts source
56
+ * fails in every real install (it only worked from a source checkout). So we
57
+ * prefer the COMPILED dist/extensions/dashboard-server.js (which the package
58
+ * ships from v0.4.6 — it imports only Node built-ins, so it runs standalone),
59
+ * and only fall back to the .ts source (with strip-types) when the compiled
60
+ * file is absent AND we're not under node_modules (dev checkout without a build).
61
+ *
62
+ * Returns { entry, needsStripTypes }.
63
+ */
64
+ function resolveDashboardEntry() {
65
+ const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
66
+ const candidates = [
67
+ // 1. Compiled sibling when running from dist/ (import.meta is dist/extensions/…js)
68
+ { entry: join(here, "dashboard-server.js"), strip: false },
69
+ // 2. Compiled under the package's dist/ when running from source extensions/…ts
70
+ { entry: join(here, "..", "dist", "extensions", "dashboard-server.js"), strip: false },
71
+ // 3. Last resort: the .ts source (only strippable OUTSIDE node_modules)
72
+ { entry: join(here, "dashboard-server.ts"), strip: true },
73
+ ];
74
+ for (const c of candidates) {
75
+ if (!existsSync(c.entry))
76
+ continue;
77
+ if (c.strip && c.entry.includes(`${sep}node_modules${sep}`))
78
+ continue; // unstrippable
79
+ return { entry: c.entry, needsStripTypes: c.strip };
80
+ }
81
+ return null;
82
+ }
83
+ /** Write a small ESM runner script that imports and launches the dashboard server. */
84
+ function writeRunnerScript() {
85
+ const resolved = resolveDashboardEntry();
86
+ if (!resolved)
87
+ return false;
88
+ dashboardNeedsStrip = resolved.needsStripTypes;
89
+ const script = [
90
+ `import { appendFileSync } from "node:fs";`,
91
+ `const __log = ${JSON.stringify(launchLog)};`,
92
+ `function __fail(err) {`,
93
+ ` const msg = "[mega-compact] dashboard failed: " + (err && err.stack ? err.stack : String(err));`,
94
+ ` try { appendFileSync(__log, msg + "\\n"); } catch { /* ignore */ }`,
95
+ ` console.error(msg);`,
96
+ ` process.exit(1);`,
97
+ `}`,
98
+ `import { launchDashboardServer } from ${JSON.stringify(resolved.entry)};`,
99
+ `launchDashboardServer(${JSON.stringify(runtime.currentStateDir)}).catch(__fail);`,
100
+ ].join("\n");
101
+ writeFileSync(runnerFile, script);
102
+ return true;
103
+ }
104
+ /** Open a URL in the default browser. Platform-aware. Uses spawn (not exec) to avoid shell injection. */
105
+ function openBrowser(url) {
106
+ const cmd = process.platform === "darwin" ? "open" :
107
+ process.platform === "win32" ? "start" :
108
+ "xdg-open";
109
+ try {
110
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
111
+ }
112
+ catch {
113
+ /* non-fatal — user can open manually */
114
+ }
115
+ }
116
+ pi.registerCommand("mega-dashboard", {
117
+ description: "Start the local web dashboard and optionally open it in the default browser.",
118
+ handler: async (_args, ctx) => {
119
+ runtime.bindRepo(ctx.cwd);
120
+ let info = await isServerRunning();
121
+ if (info) {
122
+ ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
123
+ const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
124
+ if (open)
125
+ openBrowser(info.url);
126
+ return;
127
+ }
128
+ // Start the server
129
+ ctx.ui.notify("[mega-compact] starting dashboard server…");
130
+ if (!writeRunnerScript()) {
131
+ ctx.ui.notify("[mega-compact] dashboard entry not found — check logs.");
132
+ return;
133
+ }
134
+ const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
135
+ const child = spawn(process.execPath, args, {
136
+ detached: true,
137
+ stdio: "ignore",
138
+ });
139
+ child.unref();
140
+ // Poll for a live server (port 9320–9329) instead of relying solely on the
141
+ // port.pid marker, which can land in a different state dir than the one we
142
+ // poll when a prior compact left currentStateDir pointing elsewhere.
143
+ const deadline = Date.now() + 6_000;
144
+ let port = null;
145
+ while (Date.now() < deadline) {
146
+ await new Promise((resolve) => setTimeout(resolve, 300));
147
+ port = await findLivePort();
148
+ if (port)
149
+ break;
150
+ }
151
+ if (!port) {
152
+ let detail = "";
153
+ try {
154
+ const log = readFileSync(launchLog, "utf-8").trim();
155
+ if (log)
156
+ detail = ` — ${log.split("\n").slice(-3).join("; ")}`;
157
+ }
158
+ catch { /* no log yet */ }
159
+ ctx.ui.notify(`[mega-compact] dashboard server failed to start${detail}. See ${launchLog}`);
160
+ return;
161
+ }
162
+ const url = `http://localhost:${port}`; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
163
+ ctx.ui.notify(`[mega-compact] dashboard running at ${url}`);
164
+ const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${url} in browser?`);
165
+ if (open)
166
+ openBrowser(url);
167
+ },
168
+ });
169
+ pi.registerCommand("mega-dashboard-stop", {
170
+ description: "Stop the local dashboard server.",
171
+ handler: async (_args, ctx) => {
172
+ if (!existsSync(portFile)) {
173
+ ctx.ui.notify("[mega-compact] no dashboard server running.");
174
+ return;
175
+ }
176
+ try {
177
+ const info = JSON.parse(readFileSync(portFile, "utf-8"));
178
+ // Verify the server is actually ours by probing the port before killing
179
+ try {
180
+ 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
181
+ }
182
+ catch {
183
+ // Not responding — just clean up stale pid file
184
+ try {
185
+ unlinkSync(portFile);
186
+ }
187
+ catch { /* ok */ }
188
+ ctx.ui.notify("[mega-compact] dashboard was not running (stale pid file cleaned up).");
189
+ return;
190
+ }
191
+ if (info?.pid)
192
+ process.kill(info.pid, "SIGTERM");
193
+ }
194
+ catch { /* already dead */ }
195
+ try {
196
+ unlinkSync(portFile);
197
+ }
198
+ catch { /* ok */ }
199
+ ctx.ui.notify("[mega-compact] dashboard stopped.");
200
+ },
201
+ });
202
+ pi.registerCommand("mega-dashboard-status", {
203
+ description: "Check if the dashboard server is running.",
204
+ handler: async (_args, ctx) => {
205
+ const info = await isServerRunning();
206
+ if (info) {
207
+ ctx.ui.notify(`[mega-compact] dashboard running at ${info.url}`);
208
+ }
209
+ else {
210
+ ctx.ui.notify("[mega-compact] dashboard is not running. Use /dashboard to start it.");
211
+ }
212
+ },
213
+ });
214
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * mega-dashboard.ts — live dashboard snapshot writer.
3
+ *
4
+ * Writes dashboard.json (full snapshot) and events.log (JSONL tail) to the
5
+ * state dir so any process can inspect the extension's real-time state.
6
+ *
7
+ * Usage:
8
+ * cat ~/.pi/agent/extensions/pi-mega-compact/dashboard.json
9
+ * jq . ~/.pi/agent/extensions/pi-mega-compact/dashboard.json
10
+ * tail -f ~/.pi/agent/extensions/pi-mega-compact/events.log
11
+ *
12
+ * Standalone: the snapshot *shape* is filled in by MegaRuntime.snapshot();
13
+ * this module only owns the on-disk write/append mechanics.
14
+ */
15
+ import { join } from "node:path";
16
+ import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
17
+ export class Dashboard {
18
+ snapshotPath;
19
+ eventsPath;
20
+ constructor(stateDir) {
21
+ if (!existsSync(stateDir))
22
+ mkdirSync(stateDir, { recursive: true });
23
+ this.snapshotPath = join(stateDir, "dashboard.json");
24
+ this.eventsPath = join(stateDir, "events.log");
25
+ }
26
+ /** Write a full state snapshot (atomically replaces previous). */
27
+ snapshot(data) {
28
+ writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
29
+ }
30
+ /** Append a timestamped JSONL event line. */
31
+ event(type, data) {
32
+ const line = JSON.stringify({ ts: new Date().toISOString(), type, ...data });
33
+ appendFileSync(this.eventsPath, line + "\n");
34
+ }
35
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * mega-events.ts — the pi lifecycle event handlers.
3
+ *
4
+ * Wires every pi event the extension listens for: model/provider capture,
5
+ * session lifecycle + state reset, auto-inline injection, agent/turn tracking,
6
+ * and the auto-trigger compaction pipeline. Keeps the shared MegaRuntime in
7
+ * sync and delegates the heavy lifting to the pipeline + command modules.
8
+ */
9
+ import { normalizeSessionId } from "../src/store.js";
10
+ import { autoCompactCheck } from "../src/compact.js";
11
+ import { estimateSessionTokens } from "../src/tokens.js";
12
+ import { dropCompactedRange } from "../src/adapt.js";
13
+ import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
14
+ import { runCompact, doRecall } from "./mega-pipeline.js";
15
+ /** Register all pi lifecycle event handlers. */
16
+ export function registerEventHandlers(pi, runtime, config) {
17
+ // ---- Session lifecycle (state reset points) -------------------------------
18
+ // Capture model/provider whenever it changes (drives real cost estimation).
19
+ pi.on("model_select", async (_event, ctx) => {
20
+ runtime.captureModel(ctx);
21
+ runtime.snapshot(ctx);
22
+ });
23
+ pi.on("session_start", async (event, ctx) => {
24
+ runtime.resetRuntime(ctx.sessionManager.getSessionId());
25
+ runtime.captureModel(ctx); // best-effort: ctx.model may be set by session start
26
+ runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
27
+ // Auto-inline on resume/fork/continue: stage the most relevant checkpoints
28
+ // so the next before_agent_start prepends them to the system prompt.
29
+ // Triggered whenever this session already has persisted checkpoints AND a
30
+ // usable query — that covers reason "resume"/"fork" (explicit) and
31
+ // reason "startup" (e.g. `pi --continue`s an existing session, which still
32
+ // emits "startup" but with a populated message window). A brand-new empty
33
+ // session has no checkpoints, so it's naturally excluded.
34
+ if (config.autoInline) {
35
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
36
+ const query = recentUserQuery(ctx);
37
+ if (query && runtime.store.stats(sid).checkpointCount > 0) {
38
+ const r = doRecall(runtime, config, ctx, query, "resume");
39
+ if (!r.empty) {
40
+ runtime.pendingRecallBlock = r.block;
41
+ runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
42
+ runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
43
+ }
44
+ }
45
+ }
46
+ runtime.dashboard.event("session_start", { reason: event.reason, sessionId: runtime.rt.sessionId });
47
+ runtime.snapshot(ctx);
48
+ });
49
+ pi.on("session_tree", async (_event, ctx) => {
50
+ // Branch navigation invalidates region indexes — reset checkpoint memory but
51
+ // keep the on-disk store (markers replayed from entries below if needed).
52
+ runtime.resetRuntime(ctx.sessionManager.getSessionId());
53
+ runtime.setStatus(ctx, "mega-compact: ready (branch)");
54
+ if (config.autoInline) {
55
+ const query = recentUserQuery(ctx);
56
+ if (query) {
57
+ const r = doRecall(runtime, config, ctx, query, "resume");
58
+ if (!r.empty) {
59
+ runtime.pendingRecallBlock = r.block;
60
+ runtime.logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
61
+ }
62
+ }
63
+ }
64
+ runtime.dashboard.event("session_tree", { sessionId: runtime.rt.sessionId });
65
+ runtime.snapshot(ctx);
66
+ });
67
+ // ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
68
+ pi.on("before_agent_start", async (event, ctx) => {
69
+ runtime.captureModel(ctx); // most reliable point ctx.model is populated
70
+ if (!runtime.pendingRecallBlock)
71
+ return;
72
+ const block = runtime.pendingRecallBlock;
73
+ runtime.pendingRecallBlock = undefined; // one-shot: consume so we never double-inject
74
+ return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
75
+ });
76
+ pi.on("session_shutdown", async (_event, ctx) => {
77
+ runtime.setStatus(ctx, undefined);
78
+ runtime.activeAgents = 0;
79
+ runtime.currentTurn = 0;
80
+ ctx.ui.setWidget(WIDGET_KEY, [], { placement: "aboveEditor" });
81
+ });
82
+ // ---- Agent tracking for real-time widget + status-line updates ---------
83
+ pi.on("agent_start", async (_event, ctx) => {
84
+ runtime.activeAgents++;
85
+ runtime.dashboard.event("agent_start", { activeAgents: runtime.activeAgents });
86
+ // Surface live agent activity on the status line (toolbar), not just the
87
+ // above-editor widget — otherwise concurrent agents look frozen.
88
+ runtime.setStatus(ctx, `mega-compact: ▶ ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`);
89
+ runtime.snapshot(ctx);
90
+ });
91
+ pi.on("agent_end", async (_event, ctx) => {
92
+ runtime.activeAgents = Math.max(0, runtime.activeAgents - 1);
93
+ runtime.dashboard.event("agent_end", { activeAgents: runtime.activeAgents });
94
+ if (runtime.activeAgents > 0) {
95
+ runtime.setStatus(ctx, `mega-compact: ▶ ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`);
96
+ }
97
+ else {
98
+ runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
99
+ }
100
+ runtime.snapshot(ctx);
101
+ });
102
+ pi.on("turn_start", async (event, ctx) => {
103
+ runtime.currentTurn = event.turnIndex;
104
+ runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
105
+ runtime.snapshot(ctx);
106
+ });
107
+ pi.on("turn_end", async (event, ctx) => {
108
+ runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
109
+ runtime.snapshot(ctx);
110
+ });
111
+ // ---- Auto-trigger: fast-gate → confirm → Trident+persist → drop --------
112
+ pi.on("context", async (event, ctx) => {
113
+ if (!config.auto)
114
+ return;
115
+ const usage = ctx.getContextUsage();
116
+ const pct = usage?.percent;
117
+ // Always track context for the dashboard, even if we return early below.
118
+ runtime.lastCtxTokens = usage?.tokens ?? null;
119
+ runtime.lastCtxPercent = pct ?? null;
120
+ runtime.lastCtxWindow = usage?.contextWindow ?? 0;
121
+ runtime.snapshot(ctx);
122
+ if (pct == null)
123
+ return;
124
+ const messages = event.messages;
125
+ const view = runtime.engineView(messages);
126
+ // Prefer the runtime's real token estimate; fall back to our heuristic
127
+ // (and to a percent-of-window proxy when tokens is unknown).
128
+ const currentTokens = usage?.tokens ?? estimateSessionTokens(view) ??
129
+ Math.round((pct / 100) * (usage?.contextWindow ?? 0));
130
+ // FAST GATE: token-based (tier threshold), not percentage-based.
131
+ // A 20% gate on a 2M window = 400k, which is way above the 50k low-tier
132
+ // threshold. Gate on the actual token count instead.
133
+ if (currentTokens < config.thresholdTokens)
134
+ return;
135
+ const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
136
+ if (!check.shouldCompact)
137
+ return;
138
+ // Debounce so we don't fire on every context event past threshold.
139
+ const now = Date.now();
140
+ if (now < runtime.debounceUntil)
141
+ return;
142
+ runtime.debounceUntil = now + 2000;
143
+ const ran = runCompact(pi, runtime, config, ctx, messages);
144
+ if (ran.skipped)
145
+ return;
146
+ // DROP the compacted range from the outgoing context, honoring the anchor
147
+ // floor + tool-pair boundary guards (PREVENT-PI-001/002).
148
+ const kept = dropCompactedRange(messages, ran.keepFrom, config.anchorUserMessages);
149
+ if (kept.length < messages.length) {
150
+ return { messages: kept };
151
+ }
152
+ });
153
+ // ---- Cancel native compaction once we've persisted our own -------------
154
+ pi.on("session_before_compact", async (_event, ctx) => {
155
+ runtime.resetRuntime(ctx.sessionManager.getSessionId());
156
+ if (runtime.rt.persistedThisSession) {
157
+ // We already persisted a checkpoint for this session (via the context
158
+ // hook drop) — cancel pi's own compaction to avoid double-compacting.
159
+ // Our context-hook drop already trimmed the window.
160
+ return { cancel: true };
161
+ }
162
+ // We haven't persisted yet this session: let pi run its native compaction.
163
+ // (Our auto-trigger only fires again past the threshold, and will then
164
+ // capture a checkpoint next time around.)
165
+ return {};
166
+ });
167
+ }