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.
- package/dist/extensions/dashboard-server.js +30 -9
- package/dist/extensions/mega-commands.js +223 -0
- package/dist/extensions/mega-compact.js +19 -1073
- package/dist/extensions/mega-compact.test.js +28 -0
- package/dist/extensions/mega-config.js +100 -0
- package/dist/extensions/mega-dashboard-cmds.js +214 -0
- package/dist/extensions/mega-dashboard.js +35 -0
- package/dist/extensions/mega-events.js +167 -0
- package/dist/extensions/mega-pipeline.js +140 -0
- package/dist/extensions/mega-runtime.js +370 -0
- package/dist/src/store/sqlite.js +60 -0
- package/extensions/dashboard-server.ts +30 -9
- package/extensions/mega-commands.ts +250 -0
- package/extensions/mega-compact.test.ts +30 -0
- package/extensions/mega-compact.ts +20 -1214
- package/extensions/mega-config.ts +120 -0
- package/extensions/mega-dashboard-cmds.ts +209 -0
- package/extensions/mega-dashboard.ts +107 -0
- package/extensions/mega-events.ts +180 -0
- package/extensions/mega-pipeline.ts +179 -0
- package/extensions/mega-runtime.ts +386 -0
- package/package.json +1 -1
- package/src/store/sqlite.ts +96 -0
|
@@ -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
|
+
}
|