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