pi-mega-compact 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +375 -0
- package/extensions/DASHBOARD.md +160 -0
- package/extensions/dashboard-server.test.ts +124 -0
- package/extensions/dashboard-server.ts +459 -0
- package/extensions/error-patterns.ts +175 -0
- package/extensions/mega-compact.test.ts +351 -0
- package/extensions/mega-compact.ts +846 -0
- package/extensions/openclaw-mega-compact.ts +370 -0
- package/package.json +61 -0
- package/src/adapt.ts +120 -0
- package/src/boundary.test.ts +61 -0
- package/src/boundary.ts +94 -0
- package/src/canary.ts +126 -0
- package/src/compact.test.ts +99 -0
- package/src/compact.ts +262 -0
- package/src/config/dedup.ts +120 -0
- package/src/config.ts +15 -0
- package/src/dedup/dedup.test.ts +46 -0
- package/src/dedup/digest.ts +40 -0
- package/src/dedup/l1-lsh.ts +67 -0
- package/src/dedup/l1-minhash.ts +90 -0
- package/src/dedup/l1-verify.ts +55 -0
- package/src/dedup/l1.test.ts +57 -0
- package/src/dedup/mmr.ts +54 -0
- package/src/dedup/normalize.ts +41 -0
- package/src/dedup/raptor/guardrails.ts +112 -0
- package/src/dedup/raptor/index.ts +118 -0
- package/src/dedup/raptor/kmeans.ts +156 -0
- package/src/dedup/raptor/raptor.test.ts +238 -0
- package/src/dedup/raptor/retrieval.ts +102 -0
- package/src/dedup/raptor/summarizer.ts +91 -0
- package/src/dedup/raptor/tree.ts +254 -0
- package/src/dedup/sprint12.test.ts +242 -0
- package/src/dedup/topk.ts +61 -0
- package/src/dedup-engine.test.ts +609 -0
- package/src/e2e.test.ts +843 -0
- package/src/embedder.ts +111 -0
- package/src/engine.test.ts +123 -0
- package/src/engine.ts +192 -0
- package/src/extractive.test.ts +156 -0
- package/src/extractive.ts +265 -0
- package/src/httpEmbedder.ts +154 -0
- package/src/log.test.ts +47 -0
- package/src/log.ts +60 -0
- package/src/monitoring.ts +171 -0
- package/src/ratio.bench.test.ts +1316 -0
- package/src/recall.integration.test.ts +96 -0
- package/src/recall.test.ts +59 -0
- package/src/recall.ts +100 -0
- package/src/sprint14.test.ts +245 -0
- package/src/store/backfill.ts +263 -0
- package/src/store/bloom.ts +122 -0
- package/src/store/compression.test.ts +83 -0
- package/src/store/compression.ts +203 -0
- package/src/store/integrity.ts +65 -0
- package/src/store/migrate.test.ts +158 -0
- package/src/store/migrate.ts +108 -0
- package/src/store/sprint10.test.ts +182 -0
- package/src/store/sqlite.ts +519 -0
- package/src/store.test.ts +169 -0
- package/src/store.ts +192 -0
- package/src/supersede.test.ts +42 -0
- package/src/supersede.ts +67 -0
- package/src/tokens.ts +35 -0
- package/src/types.test.ts +10 -0
- package/src/types.ts +49 -0
- package/src/vectorStore.test.ts +480 -0
- package/src/vectorStore.ts +544 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-mega-compact — layered, local, vector-backed context compressor.
|
|
3
|
+
*
|
|
4
|
+
* Extension entry. Wires the pi-agnostic Trident engine (src/) into pi's
|
|
5
|
+
* extension lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* Design constraints (from RESEARCH.md):
|
|
8
|
+
* - No network at runtime (PREVENT-PI-004).
|
|
9
|
+
* - pi Message has no system-role entry (PREVENT-PI-003); inject compacted
|
|
10
|
+
* context via `before_agent_start` systemPrompt (Sprint 4), or a
|
|
11
|
+
* `compactionSummary` message so it renders like native compaction.
|
|
12
|
+
* - Message drops must preserve an anchor floor (PREVENT-PI-001) and never
|
|
13
|
+
* split a toolCall/toolResult pair (PREVENT-PI-002).
|
|
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.
|
|
19
|
+
*
|
|
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).
|
|
25
|
+
*/
|
|
26
|
+
|
|
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 } 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 { Logger } from "../src/log.js";
|
|
41
|
+
import type { EngineMessage } from "../src/types.js";
|
|
42
|
+
import { writeFileSync, appendFileSync, readFileSync } from "node:fs";
|
|
43
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
44
|
+
import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
|
|
45
|
+
import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the store per-repo
|
|
46
|
+
|
|
47
|
+
const STATUS_KEY = "mega-compact";
|
|
48
|
+
const WIDGET_KEY = "mega-compact-stats";
|
|
49
|
+
const MARKER_TYPE = "mega-compact-marker";
|
|
50
|
+
|
|
51
|
+
/** Per-session runtime state kept in the closure (mirrors neuralwatt-mcr). */
|
|
52
|
+
interface SessionRuntime {
|
|
53
|
+
sessionId: string;
|
|
54
|
+
persistedThisSession: boolean;
|
|
55
|
+
lastCheckpointId: string | undefined;
|
|
56
|
+
lastCompactedFrom: number;
|
|
57
|
+
lastCompactedTokens: number;
|
|
58
|
+
dedupSkips: number; // compactions skipped because regionHash already stored
|
|
59
|
+
dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function envFlag(name: string, fallback: number): number {
|
|
63
|
+
const v = process.env[name];
|
|
64
|
+
if (v == null || v === "") return fallback;
|
|
65
|
+
const n = Number(v);
|
|
66
|
+
return Number.isFinite(n) ? n : fallback;
|
|
67
|
+
}
|
|
68
|
+
function envBool(name: string, fallback: boolean): boolean {
|
|
69
|
+
const v = process.env[name];
|
|
70
|
+
if (v == null || v === "") return fallback;
|
|
71
|
+
return v === "true" || v === "1";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Named compaction tiers. A tier sets the token threshold at which the
|
|
76
|
+
* auto-trigger persists a checkpoint; pick by how aggressively you want the
|
|
77
|
+
* session trimmed. Explicit MEGACOMPACT_THRESHOLD_TOKENS always wins.
|
|
78
|
+
*/
|
|
79
|
+
const COMPACT_TIERS = {
|
|
80
|
+
low: 50_000,
|
|
81
|
+
medium: 100_000,
|
|
82
|
+
high: 200_000,
|
|
83
|
+
ultra: 1_000_000,
|
|
84
|
+
mega: 10_000_000,
|
|
85
|
+
} as const;
|
|
86
|
+
export type CompactTier = keyof typeof COMPACT_TIERS;
|
|
87
|
+
|
|
88
|
+
/** Resolve the effective token threshold from TIER (or explicit) env vars. */
|
|
89
|
+
function resolveThreshold(): { tier: CompactTier | "custom"; thresholdTokens: number } {
|
|
90
|
+
const explicit = process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
91
|
+
if (explicit != null && explicit !== "") {
|
|
92
|
+
const n = Number(explicit);
|
|
93
|
+
if (Number.isFinite(n)) return { tier: "custom", thresholdTokens: n };
|
|
94
|
+
}
|
|
95
|
+
const raw = (process.env.MEGACOMPACT_TIER ?? "low").toLowerCase();
|
|
96
|
+
const tier = (raw in COMPACT_TIERS ? raw : "low") as CompactTier;
|
|
97
|
+
return { tier, thresholdTokens: COMPACT_TIERS[tier] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadConfig() {
|
|
101
|
+
const { tier, thresholdTokens } = resolveThreshold();
|
|
102
|
+
return {
|
|
103
|
+
tier,
|
|
104
|
+
// Global default; the live store/dashboard are rebound per-repo at runtime
|
|
105
|
+
// via bindRepo() so each git repo gets its own isolated state dir.
|
|
106
|
+
stateDir: process.env.MEGACOMPACT_STATE_DIR ?? STATE_DIR_DEFAULT,
|
|
107
|
+
fastGatePct: envFlag("MEGACOMPACT_FAST_GATE_PCT", 70),
|
|
108
|
+
thresholdTokens,
|
|
109
|
+
anchorUserMessages: envFlag("MEGACOMPACT_ANCHOR_USER_MESSAGES", 3),
|
|
110
|
+
preserveRecent: envFlag("MEGACOMPACT_PRESERVE_RECENT", 4),
|
|
111
|
+
auto: envBool("MEGACOMPACT_AUTO", true),
|
|
112
|
+
autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
|
|
113
|
+
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
114
|
+
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
115
|
+
debug: envBool("MEGACOMPACT_DEBUG", false),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the current repo's git root from a cwd. Returns undefined for a
|
|
121
|
+
* non-git directory (caller falls back to a global state dir).
|
|
122
|
+
*/
|
|
123
|
+
function resolveRepoRoot(cwd: string): string | undefined {
|
|
124
|
+
try {
|
|
125
|
+
const out = execSync("git rev-parse --show-toplevel", {
|
|
126
|
+
cwd,
|
|
127
|
+
encoding: "utf-8",
|
|
128
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
129
|
+
}).trim();
|
|
130
|
+
return out || undefined;
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Per-repo state dir: <repo>/.pi/mega-compact (tracked, so it travels with the
|
|
138
|
+
* repo across devices — not gitignored). Falls back to `fallback` for non-git
|
|
139
|
+
* cwds (the explicit MEGACOMPACT_STATE_DIR override, if set).
|
|
140
|
+
*/
|
|
141
|
+
function repoStateDir(cwd: string, fallback: string): string {
|
|
142
|
+
const root = resolveRepoRoot(cwd);
|
|
143
|
+
if (!root) return fallback;
|
|
144
|
+
return join(root, ".pi", "mega-compact");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Live dashboard -------------------------------------------------------
|
|
148
|
+
// Writes dashboard.json (full snapshot) and events.log (JSONL tail) to the
|
|
149
|
+
// state dir so any process can inspect the extension's real-time state.
|
|
150
|
+
//
|
|
151
|
+
// Usage:
|
|
152
|
+
// cat ~/.pi/agent/extensions/pi-mega-compact/dashboard.json
|
|
153
|
+
// jq . ~/.pi/agent/extensions/pi-mega-compact/dashboard.json
|
|
154
|
+
// tail -f ~/.pi/agent/extensions/pi-mega-compact/events.log
|
|
155
|
+
|
|
156
|
+
interface DashboardSnapshot {
|
|
157
|
+
version: 1;
|
|
158
|
+
updatedAt: string;
|
|
159
|
+
tier: string;
|
|
160
|
+
config: {
|
|
161
|
+
fastGatePct: number;
|
|
162
|
+
thresholdTokens: number;
|
|
163
|
+
anchorUserMessages: number;
|
|
164
|
+
preserveRecent: number;
|
|
165
|
+
auto: boolean;
|
|
166
|
+
autoInline: boolean;
|
|
167
|
+
};
|
|
168
|
+
session: {
|
|
169
|
+
id: string;
|
|
170
|
+
state: string;
|
|
171
|
+
persistedThisSession: boolean;
|
|
172
|
+
lastCheckpointId: string | null;
|
|
173
|
+
lastCompactedFrom: number;
|
|
174
|
+
lastCompactedTokens: number;
|
|
175
|
+
dedupSkips: number;
|
|
176
|
+
dedupAttempts: number;
|
|
177
|
+
};
|
|
178
|
+
context: {
|
|
179
|
+
tokens: number | null;
|
|
180
|
+
percent: number | null;
|
|
181
|
+
contextWindow: number;
|
|
182
|
+
};
|
|
183
|
+
trigger: {
|
|
184
|
+
armed: boolean; // past fast-gate %
|
|
185
|
+
ready: boolean; // past threshold (would compact next turn)
|
|
186
|
+
currentTokens: number | null;
|
|
187
|
+
thresholdTokens: number;
|
|
188
|
+
fastGatePct: number;
|
|
189
|
+
};
|
|
190
|
+
store: {
|
|
191
|
+
checkpointCount: number;
|
|
192
|
+
totalTokenEstimate: number;
|
|
193
|
+
injectedCount: number;
|
|
194
|
+
dedupHitRate: number;
|
|
195
|
+
storageDedupRate: number;
|
|
196
|
+
dedupAttempts: number;
|
|
197
|
+
dedupCollapsed: number;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class Dashboard {
|
|
202
|
+
private snapshotPath: string;
|
|
203
|
+
private eventsPath: string;
|
|
204
|
+
|
|
205
|
+
constructor(stateDir: string) {
|
|
206
|
+
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
|
207
|
+
this.snapshotPath = join(stateDir, "dashboard.json");
|
|
208
|
+
this.eventsPath = join(stateDir, "events.log");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Write a full state snapshot (atomically replaces previous). */
|
|
212
|
+
snapshot(data: DashboardSnapshot): void {
|
|
213
|
+
writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Append a timestamped JSONL event line. */
|
|
217
|
+
event(type: string, data: Record<string, unknown>): void {
|
|
218
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), type, ...data });
|
|
219
|
+
appendFileSync(this.eventsPath, line + "\n");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Convert the messages pi hands us in the `context` event into the engine view. */
|
|
224
|
+
function engineView(messages: AgentMessage[]): EngineMessage[] {
|
|
225
|
+
return toEngineMessages(messages);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export default function (pi: ExtensionAPI) {
|
|
229
|
+
const config = loadConfig();
|
|
230
|
+
// Store/dashboard/logger are rebound per-repo by bindRepo() (below) so each
|
|
231
|
+
// git repo gets its own isolated state dir. They start bound to the global
|
|
232
|
+
// default until the first handler resolves a cwd.
|
|
233
|
+
let store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
|
|
234
|
+
let logger = new Logger({ enabled: config.debug, path: join(config.stateDir, "mega-compact.log") });
|
|
235
|
+
let dashboard = new Dashboard(config.stateDir);
|
|
236
|
+
let activeRepoRoot: string | null = null;
|
|
237
|
+
let currentStateDir = config.stateDir;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Point store/dashboard/logger at the current repo's state dir. Rebuilds the
|
|
241
|
+
* instances only when the repo root changes, so cross-repo dedup stats, db,
|
|
242
|
+
* and events are fully isolated. Falls back to the global default outside git.
|
|
243
|
+
*/
|
|
244
|
+
function bindRepo(cwd: string | undefined): string {
|
|
245
|
+
const dir = cwd ? repoStateDir(cwd, config.stateDir) : config.stateDir;
|
|
246
|
+
const key = cwd ? resolveRepoRoot(cwd) ?? dir : dir;
|
|
247
|
+
if (key === activeRepoRoot) return dir;
|
|
248
|
+
activeRepoRoot = key;
|
|
249
|
+
currentStateDir = dir;
|
|
250
|
+
store = new VectorStore({ dedupSim: config.dedupSim, stateDir: dir });
|
|
251
|
+
logger = new Logger({ enabled: config.debug, path: join(dir, "mega-compact.log") });
|
|
252
|
+
dashboard = new Dashboard(dir);
|
|
253
|
+
return dir;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- snapshot() helper: collect live state and write it to disk ---
|
|
257
|
+
let lastCtxTokens: number | null = null;
|
|
258
|
+
let lastCtxPercent: number | null = null;
|
|
259
|
+
let lastCtxWindow: number = 0;
|
|
260
|
+
|
|
261
|
+
function snapshot(ctx?: ExtensionContext): void {
|
|
262
|
+
if (ctx) bindRepo(ctx.cwd);
|
|
263
|
+
const st = store.stats(rt.sessionId);
|
|
264
|
+
const armed = lastCtxPercent != null && lastCtxPercent >= config.fastGatePct;
|
|
265
|
+
const ready = armed && (lastCtxTokens ?? 0) >= config.thresholdTokens;
|
|
266
|
+
dashboard.snapshot({
|
|
267
|
+
version: 1,
|
|
268
|
+
updatedAt: new Date().toISOString(),
|
|
269
|
+
tier: config.tier,
|
|
270
|
+
config: {
|
|
271
|
+
fastGatePct: config.fastGatePct,
|
|
272
|
+
thresholdTokens: config.thresholdTokens,
|
|
273
|
+
anchorUserMessages: config.anchorUserMessages,
|
|
274
|
+
preserveRecent: config.preserveRecent,
|
|
275
|
+
auto: config.auto,
|
|
276
|
+
autoInline: config.autoInline,
|
|
277
|
+
},
|
|
278
|
+
session: {
|
|
279
|
+
id: rt.sessionId,
|
|
280
|
+
state: statusKey ?? "idle",
|
|
281
|
+
persistedThisSession: rt.persistedThisSession,
|
|
282
|
+
lastCheckpointId: rt.lastCheckpointId ?? null,
|
|
283
|
+
lastCompactedFrom: rt.lastCompactedFrom,
|
|
284
|
+
lastCompactedTokens: rt.lastCompactedTokens,
|
|
285
|
+
dedupSkips: rt.dedupSkips,
|
|
286
|
+
dedupAttempts: rt.dedupAttempts,
|
|
287
|
+
},
|
|
288
|
+
context: { tokens: lastCtxTokens, percent: lastCtxPercent, contextWindow: lastCtxWindow },
|
|
289
|
+
trigger: { armed, ready, currentTokens: lastCtxTokens, thresholdTokens: config.thresholdTokens, fastGatePct: config.fastGatePct },
|
|
290
|
+
store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Live stats widget above the editor
|
|
294
|
+
if (ctx) {
|
|
295
|
+
const tokStr = lastCtxTokens != null ? `${Math.round(lastCtxTokens / 1000)}k` : "?";
|
|
296
|
+
const maxStr = lastCtxWindow > 0 ? `${Math.round(lastCtxWindow / 1000)}k` : "?";
|
|
297
|
+
const pctStr = lastCtxPercent != null ? `${Math.round(lastCtxPercent * 10) / 10}%` : "?%";
|
|
298
|
+
const triggerLabel = ready ? "● ready" : armed ? "◐ armed" : "○ idle";
|
|
299
|
+
// Storage dedup rate is cumulative (store-wide, per-repo) and survives
|
|
300
|
+
// session resets. Always show a number: 0% before any compaction, a
|
|
301
|
+
// decimal for sub-10% rates so small-but-real dedup isn't rounded away.
|
|
302
|
+
const storageRate = st.storageDedupRate; // 0..1
|
|
303
|
+
const dedupStr = storageRate * 100 >= 10
|
|
304
|
+
? `${Math.round(storageRate * 100)}%`
|
|
305
|
+
: `${(storageRate * 100).toFixed(1)}%`;
|
|
306
|
+
const savedStr = st.totalTokenEstimate > 0 ? `${Math.round(st.totalTokenEstimate / 1000)}k` : "0";
|
|
307
|
+
const agentStr = activeAgents > 0 ? ` │ 🤖 ${activeAgents} agent${activeAgents === 1 ? "" : "s"}` : "";
|
|
308
|
+
const turnStr = currentTurn > 0 ? ` │ turn ${currentTurn}` : "";
|
|
309
|
+
ctx.ui.setWidget(
|
|
310
|
+
WIDGET_KEY,
|
|
311
|
+
[
|
|
312
|
+
` ⚡ ${config.tier} │ ${tokStr}/${maxStr} tokens (${pctStr}) │ ${st.checkpointCount} chkpt${st.checkpointCount === 1 ? "" : "s"}${agentStr}${turnStr}`,
|
|
313
|
+
` ${triggerLabel} │ dedup: ${dedupStr} │ saved: ${savedStr} tok`,
|
|
314
|
+
],
|
|
315
|
+
{ placement: "aboveEditor" },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The only mutable per-session state. Reset on session_start / session_tree.
|
|
321
|
+
let rt: SessionRuntime = {
|
|
322
|
+
sessionId: normalizeSessionId(undefined),
|
|
323
|
+
persistedThisSession: false,
|
|
324
|
+
lastCheckpointId: undefined,
|
|
325
|
+
lastCompactedFrom: 0,
|
|
326
|
+
lastCompactedTokens: 0,
|
|
327
|
+
dedupSkips: 0,
|
|
328
|
+
dedupAttempts: 0,
|
|
329
|
+
};
|
|
330
|
+
let debounceUntil = 0;
|
|
331
|
+
// Agent tracking for real-time widget updates
|
|
332
|
+
let activeAgents = 0;
|
|
333
|
+
let currentTurn = 0;
|
|
334
|
+
// Recall block produced by auto-inline (resume/branch) that the next
|
|
335
|
+
// before_agent_start should prepend to the system prompt. Unset after use.
|
|
336
|
+
let pendingRecallBlock: string | undefined;
|
|
337
|
+
let statusKey: string | undefined; // current status text for dashboard
|
|
338
|
+
|
|
339
|
+
function setStatus(ctx: ExtensionContext, text: string | undefined) {
|
|
340
|
+
statusKey = text;
|
|
341
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function resetRuntime(sessionId: string | undefined) {
|
|
345
|
+
const sid = normalizeSessionId(sessionId);
|
|
346
|
+
if (rt.sessionId === sid && rt.persistedThisSession) return; // same session, keep checkpoint memory
|
|
347
|
+
rt = {
|
|
348
|
+
sessionId: sid,
|
|
349
|
+
persistedThisSession: false,
|
|
350
|
+
lastCheckpointId: undefined,
|
|
351
|
+
lastCompactedFrom: 0,
|
|
352
|
+
lastCompactedTokens: 0,
|
|
353
|
+
dedupSkips: 0,
|
|
354
|
+
dedupAttempts: 0,
|
|
355
|
+
};
|
|
356
|
+
statusKey = undefined;
|
|
357
|
+
activeAgents = 0;
|
|
358
|
+
currentTurn = 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
|
|
362
|
+
function runCompact(
|
|
363
|
+
ctx: ExtensionContext,
|
|
364
|
+
messages: AgentMessage[],
|
|
365
|
+
opts: { keepFrom?: number; summary?: string } = {},
|
|
366
|
+
) {
|
|
367
|
+
bindRepo(ctx.cwd);
|
|
368
|
+
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
369
|
+
resetRuntime(sid);
|
|
370
|
+
rt.sessionId = sid;
|
|
371
|
+
|
|
372
|
+
const view = engineView(messages);
|
|
373
|
+
const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
|
|
374
|
+
if (keepFrom <= 0) return { skipped: true as const };
|
|
375
|
+
|
|
376
|
+
const result = compactSession(
|
|
377
|
+
{
|
|
378
|
+
sessionId: sid,
|
|
379
|
+
messages: view,
|
|
380
|
+
keepFrom,
|
|
381
|
+
summary: opts.summary,
|
|
382
|
+
timestamp: Date.now(),
|
|
383
|
+
},
|
|
384
|
+
store,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
if (result.skipped) return { skipped: true as const };
|
|
388
|
+
if (!result.deduped) {
|
|
389
|
+
rt.persistedThisSession = true;
|
|
390
|
+
rt.lastCheckpointId = result.checkpointId;
|
|
391
|
+
}
|
|
392
|
+
rt.lastCompactedFrom = result.compactedFrom;
|
|
393
|
+
rt.lastCompactedTokens = result.tokenEstimate;
|
|
394
|
+
rt.dedupAttempts++;
|
|
395
|
+
if (result.deduped) rt.dedupSkips++;
|
|
396
|
+
|
|
397
|
+
// Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
|
|
398
|
+
// skip re-vectorizing an already-compacted region (zero token cost).
|
|
399
|
+
pi.appendEntry(MARKER_TYPE, {
|
|
400
|
+
checkpointId: result.checkpointId,
|
|
401
|
+
regionHash: result.regionHash,
|
|
402
|
+
tokenEstimate: result.tokenEstimate,
|
|
403
|
+
deduped: result.deduped,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const saved = result.tokenEstimate;
|
|
407
|
+
setStatus(
|
|
408
|
+
ctx,
|
|
409
|
+
rt.persistedThisSession
|
|
410
|
+
? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
|
|
411
|
+
: `mega-compact: ready`,
|
|
412
|
+
);
|
|
413
|
+
logger.info("compact", {
|
|
414
|
+
sessionId: sid,
|
|
415
|
+
checkpointId: result.checkpointId ?? "(deduped)",
|
|
416
|
+
deduped: result.deduped,
|
|
417
|
+
tokenEstimate: saved,
|
|
418
|
+
compactedFrom: result.compactedFrom,
|
|
419
|
+
});
|
|
420
|
+
dashboard.event("compact", {
|
|
421
|
+
sessionId: sid,
|
|
422
|
+
checkpointId: result.checkpointId ?? "(deduped)",
|
|
423
|
+
deduped: result.deduped,
|
|
424
|
+
tokenEstimate: saved,
|
|
425
|
+
compactedFrom: result.compactedFrom,
|
|
426
|
+
});
|
|
427
|
+
snapshot(ctx);
|
|
428
|
+
return { skipped: false, result, keepFrom, saved };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Unified recall (Layer 5). The ONE path that injects. Returns the recall
|
|
433
|
+
* result; callers decide whether to stage it for before_agent_start (resume)
|
|
434
|
+
* or report it (command).
|
|
435
|
+
*/
|
|
436
|
+
function doRecall(ctx: ExtensionContext, query: string, source: "resume" | "command") {
|
|
437
|
+
bindRepo(ctx.cwd);
|
|
438
|
+
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
439
|
+
const result = recallAndInline(
|
|
440
|
+
{ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true },
|
|
441
|
+
store,
|
|
442
|
+
);
|
|
443
|
+
dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---- Session lifecycle (state reset points) -------------------------------
|
|
448
|
+
pi.on("session_start", async (event, ctx) => {
|
|
449
|
+
resetRuntime(ctx.sessionManager.getSessionId());
|
|
450
|
+
setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
|
|
451
|
+
// Auto-inline on resume/fork/continue: stage the most relevant checkpoints
|
|
452
|
+
// so the next before_agent_start prepends them to the system prompt.
|
|
453
|
+
// Triggered whenever this session already has persisted checkpoints AND a
|
|
454
|
+
// usable query — that covers reason "resume"/"fork" (explicit) and
|
|
455
|
+
// reason "startup" (e.g. `pi --continue`s an existing session, which still
|
|
456
|
+
// emits "startup" but with a populated message window). A brand-new empty
|
|
457
|
+
// session has no checkpoints, so it's naturally excluded.
|
|
458
|
+
if (config.autoInline) {
|
|
459
|
+
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
460
|
+
const query = recentUserQuery(ctx);
|
|
461
|
+
if (query && store.stats(sid).checkpointCount > 0) {
|
|
462
|
+
const r = doRecall(ctx, query, "resume");
|
|
463
|
+
if (!r.empty) {
|
|
464
|
+
pendingRecallBlock = r.block;
|
|
465
|
+
setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
|
|
466
|
+
logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
dashboard.event("session_start", { reason: event.reason, sessionId: rt.sessionId });
|
|
471
|
+
snapshot(ctx);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
475
|
+
// Branch navigation invalidates region indexes — reset checkpoint memory but
|
|
476
|
+
// keep the on-disk store (markers replayed from entries below if needed).
|
|
477
|
+
resetRuntime(ctx.sessionManager.getSessionId());
|
|
478
|
+
setStatus(ctx, "mega-compact: ready (branch)");
|
|
479
|
+
if (config.autoInline) {
|
|
480
|
+
const query = recentUserQuery(ctx);
|
|
481
|
+
if (query) {
|
|
482
|
+
const r = doRecall(ctx, query, "resume");
|
|
483
|
+
if (!r.empty) {
|
|
484
|
+
pendingRecallBlock = r.block;
|
|
485
|
+
logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
dashboard.event("session_tree", { sessionId: rt.sessionId });
|
|
490
|
+
snapshot(ctx);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
|
|
494
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
495
|
+
if (!pendingRecallBlock) return;
|
|
496
|
+
const block = pendingRecallBlock;
|
|
497
|
+
pendingRecallBlock = undefined; // one-shot: consume so we never double-inject
|
|
498
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
502
|
+
setStatus(ctx, undefined);
|
|
503
|
+
activeAgents = 0;
|
|
504
|
+
currentTurn = 0;
|
|
505
|
+
ctx.ui.setWidget(WIDGET_KEY, [], { placement: "aboveEditor" });
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// ---- Agent tracking for real-time widget + status-line updates ---------
|
|
509
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
510
|
+
activeAgents++;
|
|
511
|
+
dashboard.event("agent_start", { activeAgents });
|
|
512
|
+
// Surface live agent activity on the status line (toolbar), not just the
|
|
513
|
+
// above-editor widget — otherwise concurrent agents look frozen.
|
|
514
|
+
setStatus(ctx, `mega-compact: ▶ ${activeAgents} agent${activeAgents === 1 ? "" : "s"}`);
|
|
515
|
+
snapshot(ctx);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
519
|
+
activeAgents = Math.max(0, activeAgents - 1);
|
|
520
|
+
dashboard.event("agent_end", { activeAgents });
|
|
521
|
+
if (activeAgents > 0) {
|
|
522
|
+
setStatus(ctx, `mega-compact: ▶ ${activeAgents} agent${activeAgents === 1 ? "" : "s"}`);
|
|
523
|
+
} else {
|
|
524
|
+
setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
|
|
525
|
+
}
|
|
526
|
+
snapshot(ctx);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
530
|
+
currentTurn = event.turnIndex;
|
|
531
|
+
dashboard.event("turn_start", { turnIndex: event.turnIndex });
|
|
532
|
+
snapshot(ctx);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
536
|
+
dashboard.event("turn_end", { turnIndex: event.turnIndex });
|
|
537
|
+
snapshot(ctx);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ---- Auto-trigger: fast-gate → confirm → Trident+persist → drop --------
|
|
541
|
+
pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
|
|
542
|
+
if (!config.auto) return;
|
|
543
|
+
const usage = ctx.getContextUsage();
|
|
544
|
+
const pct = usage?.percent;
|
|
545
|
+
// Always track context for the dashboard, even if we return early below.
|
|
546
|
+
lastCtxTokens = usage?.tokens ?? null;
|
|
547
|
+
lastCtxPercent = pct ?? null;
|
|
548
|
+
lastCtxWindow = usage?.contextWindow ?? 0;
|
|
549
|
+
snapshot(ctx);
|
|
550
|
+
if (pct == null) return;
|
|
551
|
+
|
|
552
|
+
const messages = event.messages;
|
|
553
|
+
const view = engineView(messages);
|
|
554
|
+
// Prefer the runtime's real token estimate; fall back to our heuristic
|
|
555
|
+
// (and to a percent-of-window proxy when tokens is unknown).
|
|
556
|
+
const currentTokens =
|
|
557
|
+
usage?.tokens ?? estimateSessionTokens(view) ??
|
|
558
|
+
Math.round((pct / 100) * (usage?.contextWindow ?? 0));
|
|
559
|
+
|
|
560
|
+
// FAST GATE: token-based (tier threshold), not percentage-based.
|
|
561
|
+
// A 20% gate on a 2M window = 400k, which is way above the 50k low-tier
|
|
562
|
+
// threshold. Gate on the actual token count instead.
|
|
563
|
+
if (currentTokens < config.thresholdTokens) return;
|
|
564
|
+
|
|
565
|
+
const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
|
|
566
|
+
if (!check.shouldCompact) return;
|
|
567
|
+
|
|
568
|
+
// Debounce so we don't fire on every context event past threshold.
|
|
569
|
+
const now = Date.now();
|
|
570
|
+
if (now < debounceUntil) return;
|
|
571
|
+
debounceUntil = now + 2000;
|
|
572
|
+
|
|
573
|
+
const ran = runCompact(ctx, messages);
|
|
574
|
+
if (ran.skipped) return;
|
|
575
|
+
|
|
576
|
+
// DROP the compacted range from the outgoing context, honoring the anchor
|
|
577
|
+
// floor + tool-pair boundary guards (PREVENT-PI-001/002).
|
|
578
|
+
const kept = dropCompactedRange(messages, ran.keepFrom!, config.anchorUserMessages);
|
|
579
|
+
if (kept.length < messages.length) {
|
|
580
|
+
return { messages: kept };
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// ---- Cancel native compaction once we've persisted our own -------------
|
|
585
|
+
pi.on("session_before_compact", async (_event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
586
|
+
resetRuntime(ctx.sessionManager.getSessionId());
|
|
587
|
+
if (rt.persistedThisSession) {
|
|
588
|
+
// We already persisted a checkpoint for this session (via the context
|
|
589
|
+
// hook drop) — cancel pi's own compaction to avoid double-compacting.
|
|
590
|
+
// Our context-hook drop already trimmed the window.
|
|
591
|
+
return { cancel: true };
|
|
592
|
+
}
|
|
593
|
+
// We haven't persisted yet this session: let pi run its native compaction.
|
|
594
|
+
// (Our auto-trigger only fires again past the threshold, and will then
|
|
595
|
+
// capture a checkpoint next time around.)
|
|
596
|
+
return {};
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ---- Commands ----------------------------------------------------------
|
|
600
|
+
pi.registerCommand("mega-compact", {
|
|
601
|
+
description: "Compress current session context into the local vector store.",
|
|
602
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
603
|
+
const sessionEntries = ctx.sessionManager.getEntries();
|
|
604
|
+
// Project entries (branch-aware) into the message view.
|
|
605
|
+
const messages: AgentMessage[] = sessionEntries.flatMap((e) => sessionEntryToContextMessages(e));
|
|
606
|
+
const summaryArg = args.trim();
|
|
607
|
+
const ran = runCompact(ctx, messages, summaryArg ? { summary: summaryArg } : {});
|
|
608
|
+
if ("skipped" in ran && ran.skipped) {
|
|
609
|
+
ctx.ui.notify("[mega-compact] Nothing to compact (session too small).");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const r = (ran as { result: { deduped: boolean; checkpointId?: string; tokenEstimate: number } }).result;
|
|
613
|
+
ctx.ui.notify(
|
|
614
|
+
`[mega-compact] ${r.deduped ? "region already compacted (deduped)" : `persisted ${r.checkpointId}`} · ` +
|
|
615
|
+
`${r.tokenEstimate} tok · ${currentStateDir}`,
|
|
616
|
+
);
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
pi.registerCommand("mega-recall", {
|
|
621
|
+
description: "Recall relevant compacted context from the vector store and inline it.",
|
|
622
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
623
|
+
const query = args.trim() || recentUserQuery(ctx);
|
|
624
|
+
if (!query) {
|
|
625
|
+
ctx.ui.notify("[mega-compact] /mega-recall needs a query or a prior user message.");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const r = doRecall(ctx, query, "command");
|
|
629
|
+
if (r.empty) {
|
|
630
|
+
logger.info("recall-empty", { query });
|
|
631
|
+
ctx.ui.notify(`[mega-compact] recall found nothing new for "${query}".`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// Stage the block so the next before_agent_start prepends it (actual
|
|
635
|
+
// injection). Report what was selected now for immediate feedback.
|
|
636
|
+
pendingRecallBlock = r.block;
|
|
637
|
+
const list = r.report.map((l) => l).join("\n");
|
|
638
|
+
logger.info("recall", { query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
|
|
639
|
+
setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
|
|
640
|
+
ctx.ui.notify(
|
|
641
|
+
`[mega-compact] recall staged ${r.toInject.length} checkpoint(s) for "${query}":\n${list}\n` +
|
|
642
|
+
`(injected at the next turn via system prompt)`,
|
|
643
|
+
);
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
pi.registerCommand("mega-status", {
|
|
648
|
+
description: "Show mega-compact config and current context usage.",
|
|
649
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
650
|
+
bindRepo(ctx.cwd);
|
|
651
|
+
const usage = ctx.getContextUsage();
|
|
652
|
+
const pct = usage?.percent != null ? `${usage.percent}%` : "n/a";
|
|
653
|
+
const tokens = usage?.tokens != null ? `${usage.tokens} tok` : "n/a";
|
|
654
|
+
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
655
|
+
const st = store.stats(sid);
|
|
656
|
+
ctx.ui.notify(
|
|
657
|
+
`[mega-compact] pct=${pct} tokens=${tokens} tier=${config.tier} fastGate=${config.fastGatePct}% ` +
|
|
658
|
+
`threshold=${config.thresholdTokens} auto=${config.auto} autoInline=${config.autoInline}\n` +
|
|
659
|
+
`[mega-compact] store: ${st.checkpointCount} chkpt · ` +
|
|
660
|
+
`${st.totalTokenEstimate} tok · last=${st.lastCheckpointId ?? "—"} · ` +
|
|
661
|
+
`injected=${st.injectedCount} · dedup=${(st.dedupHitRate * 100).toFixed(0)}%\n` +
|
|
662
|
+
`[mega-compact] anchor=${config.anchorUserMessages} preserveRecent=${config.preserveRecent} ` +
|
|
663
|
+
`autoInlineK=${config.autoInlineK} dedupSim=${config.dedupSim} debug=${config.debug}\n` +
|
|
664
|
+
`[mega-compact] stateDir=${currentStateDir}`,
|
|
665
|
+
);
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
pi.registerCommand("mega-tier", {
|
|
670
|
+
description: "Show or change the compaction tier at runtime. Usage: /mega-tier [low|medium|high|ultra|mega]",
|
|
671
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
672
|
+
const arg = args.trim().toLowerCase();
|
|
673
|
+
if (!arg) {
|
|
674
|
+
// Show current tier and available options.
|
|
675
|
+
ctx.ui.notify(
|
|
676
|
+
`[mega-compact] current tier: ${config.tier} (${config.thresholdTokens} tok)\n` +
|
|
677
|
+
`[mega-compact] available tiers: ${Object.entries(COMPACT_TIERS).map(([k, v]) => `${k}=${v}`).join(", ")}`,
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (!(arg in COMPACT_TIERS)) {
|
|
682
|
+
ctx.ui.notify(`[mega-compact] unknown tier "${arg}". Available: ${Object.keys(COMPACT_TIERS).join(", ")}`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const newTier = arg as CompactTier;
|
|
686
|
+
config.tier = newTier;
|
|
687
|
+
config.thresholdTokens = COMPACT_TIERS[newTier];
|
|
688
|
+
setStatus(ctx, `mega-compact: tier → ${newTier} (${config.thresholdTokens} tok)`);
|
|
689
|
+
ctx.ui.notify(`[mega-compact] tier changed to ${newTier} (threshold: ${config.thresholdTokens} tokens)`);
|
|
690
|
+
snapshot(ctx);
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// ---- Dashboard server commands ----------------------------------------
|
|
695
|
+
|
|
696
|
+
const portFile = join(currentStateDir, "port.pid");
|
|
697
|
+
const runnerFile = join(currentStateDir, "_dashboard-runner.mjs");
|
|
698
|
+
|
|
699
|
+
/** Try to reach a running dashboard server. Returns { port, url } or null. */
|
|
700
|
+
async function isServerRunning(): Promise<{ port: number; url: string } | null> {
|
|
701
|
+
if (!existsSync(portFile)) return null;
|
|
702
|
+
try {
|
|
703
|
+
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
704
|
+
if (!info?.port) return null;
|
|
705
|
+
const url = `http://localhost:${info.port}`; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
|
|
706
|
+
// Quick liveness probe
|
|
707
|
+
const res = await fetch(`${url}/api/snapshot`, { signal: AbortSignal.timeout(1500) }); // guardrails-allow PREVENT-PI-004: localhost probe to the dashboard server this extension spawned
|
|
708
|
+
if (res.ok) return { port: info.port, url };
|
|
709
|
+
} catch {
|
|
710
|
+
// stale or unreachable — clean up
|
|
711
|
+
try { unlinkSync(portFile); } catch { /* ignore */ }
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Write a small ESM runner script that imports and launches the dashboard server. */
|
|
717
|
+
function writeRunnerScript(): void {
|
|
718
|
+
const compiledServer = join(dirname(fileURLToPath(import.meta.url)), "dashboard-server.js");
|
|
719
|
+
const script = [
|
|
720
|
+
`import { launchDashboardServer } from ${JSON.stringify(compiledServer)};`,
|
|
721
|
+
`launchDashboardServer(${JSON.stringify(currentStateDir)}).catch(err => {`,
|
|
722
|
+
` console.error("[mega-compact] dashboard failed:", err);`,
|
|
723
|
+
` process.exit(1);`,
|
|
724
|
+
`});`,
|
|
725
|
+
].join("\n");
|
|
726
|
+
writeFileSync(runnerFile, script);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Open a URL in the default browser. Platform-aware. Uses spawn (not exec) to avoid shell injection. */
|
|
730
|
+
function openBrowser(url: string): void {
|
|
731
|
+
const cmd =
|
|
732
|
+
process.platform === "darwin" ? "open" :
|
|
733
|
+
process.platform === "win32" ? "start" :
|
|
734
|
+
"xdg-open";
|
|
735
|
+
try {
|
|
736
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
737
|
+
} catch {
|
|
738
|
+
/* non-fatal — user can open manually */
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
pi.registerCommand("mega-dashboard", {
|
|
743
|
+
description: "Start the local web dashboard and optionally open it in the default browser.",
|
|
744
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
745
|
+
bindRepo(ctx.cwd);
|
|
746
|
+
let info = await isServerRunning();
|
|
747
|
+
|
|
748
|
+
if (info) {
|
|
749
|
+
ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
|
|
750
|
+
const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
|
|
751
|
+
if (open) openBrowser(info.url);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Start the server
|
|
756
|
+
ctx.ui.notify("[mega-compact] starting dashboard server…");
|
|
757
|
+
writeRunnerScript();
|
|
758
|
+
|
|
759
|
+
const child = spawn(process.execPath, [runnerFile], {
|
|
760
|
+
detached: true,
|
|
761
|
+
stdio: "ignore",
|
|
762
|
+
});
|
|
763
|
+
child.unref();
|
|
764
|
+
|
|
765
|
+
// Poll for port.pid (up to 5 seconds)
|
|
766
|
+
const deadline = Date.now() + 5_000;
|
|
767
|
+
let port: number | undefined;
|
|
768
|
+
while (Date.now() < deadline) {
|
|
769
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
770
|
+
if (existsSync(portFile)) {
|
|
771
|
+
try {
|
|
772
|
+
const raw = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
773
|
+
if (raw?.port) { port = raw.port; break; }
|
|
774
|
+
} catch { /* keep polling */ }
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (!port) {
|
|
779
|
+
ctx.ui.notify("[mega-compact] dashboard server failed to start — check logs.");
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const url = `http://localhost:${port}`; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
|
|
784
|
+
ctx.ui.notify(`[mega-compact] dashboard running at ${url}`);
|
|
785
|
+
const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${url} in browser?`);
|
|
786
|
+
if (open) openBrowser(url);
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
pi.registerCommand("mega-dashboard-stop", {
|
|
791
|
+
description: "Stop the local dashboard server.",
|
|
792
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
793
|
+
if (!existsSync(portFile)) {
|
|
794
|
+
ctx.ui.notify("[mega-compact] no dashboard server running.");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
799
|
+
// Verify the server is actually ours by probing the port before killing
|
|
800
|
+
try {
|
|
801
|
+
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
|
|
802
|
+
} catch {
|
|
803
|
+
// Not responding — just clean up stale pid file
|
|
804
|
+
try { unlinkSync(portFile); } catch { /* ok */ }
|
|
805
|
+
ctx.ui.notify("[mega-compact] dashboard was not running (stale pid file cleaned up).");
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (info?.pid) process.kill(info.pid, "SIGTERM");
|
|
809
|
+
} catch { /* already dead */ }
|
|
810
|
+
try { unlinkSync(portFile); } catch { /* ok */ }
|
|
811
|
+
ctx.ui.notify("[mega-compact] dashboard stopped.");
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
pi.registerCommand("mega-dashboard-status", {
|
|
816
|
+
description: "Check if the dashboard server is running.",
|
|
817
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
818
|
+
const info = await isServerRunning();
|
|
819
|
+
if (info) {
|
|
820
|
+
ctx.ui.notify(`[mega-compact] dashboard running at ${info.url}`);
|
|
821
|
+
} else {
|
|
822
|
+
ctx.ui.notify("[mega-compact] dashboard is not running. Use /dashboard to start it.");
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Latest user message text — used as the auto-inline recall query. */
|
|
829
|
+
function recentUserQuery(ctx: ExtensionContext): string {
|
|
830
|
+
try {
|
|
831
|
+
const entries = ctx.sessionManager.getEntries();
|
|
832
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
833
|
+
const msgs = sessionEntryToContextMessages(entries[i]);
|
|
834
|
+
for (let j = msgs.length - 1; j >= 0; j--) {
|
|
835
|
+
if (msgs[j].role === "user") {
|
|
836
|
+
const c = (msgs[j] as { content: unknown }).content;
|
|
837
|
+
if (typeof c === "string") return c;
|
|
838
|
+
if (Array.isArray(c)) return c.map((b: any) => b.text).join(" ");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
} catch {
|
|
843
|
+
/* best-effort */
|
|
844
|
+
}
|
|
845
|
+
return "";
|
|
846
|
+
}
|