pi-mega-compact 0.6.3 → 0.6.5
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/conflict-scan.js +133 -60
- package/dist/extensions/dashboard-server.js +161 -35
- package/dist/extensions/dashboard-server.test.js +2 -2
- package/dist/extensions/mega-runtime.js +51 -23
- package/dist/src/compact.js +7 -1
- package/dist/src/supersede.js +3 -0
- package/extensions/conflict-scan.test.ts +129 -0
- package/extensions/conflict-scan.ts +243 -158
- package/extensions/dashboard-server.test.ts +2 -2
- package/extensions/dashboard-server.ts +166 -36
- package/extensions/mega-dashboard.ts +16 -0
- package/extensions/mega-runtime.ts +52 -23
- package/package.json +1 -1
- package/src/compact.ts +7 -2
- package/src/supersede.ts +3 -0
|
@@ -255,6 +255,25 @@ export class MegaRuntime {
|
|
|
255
255
|
trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.config.thresholdTokens, fastGatePct: this.config.fastGatePct },
|
|
256
256
|
crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
|
|
257
257
|
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 },
|
|
258
|
+
// Reconciled token accounting (single canonical formula, session + repo).
|
|
259
|
+
// Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
|
|
260
|
+
// deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
|
|
261
|
+
compression: {
|
|
262
|
+
session: {
|
|
263
|
+
tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
|
|
264
|
+
tokensOut: st.totalTokenEstimate,
|
|
265
|
+
tokensFreed: this.rt.tokensSaved,
|
|
266
|
+
compressionPct: (this.rt.tokensSaved + st.totalTokenEstimate) > 0 ? this.rt.tokensSaved / (this.rt.tokensSaved + st.totalTokenEstimate) : 0,
|
|
267
|
+
dedupPct: st.storageDedupRate,
|
|
268
|
+
},
|
|
269
|
+
repo: {
|
|
270
|
+
tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
|
|
271
|
+
tokensOut: repo.totalTokenEstimate,
|
|
272
|
+
tokensFreed: repo.tokensSaved,
|
|
273
|
+
compressionPct: (repo.tokensSaved + repo.totalTokenEstimate) > 0 ? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate) : 0,
|
|
274
|
+
dedupPct: repo.storageDedupRate,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
258
277
|
repo: {
|
|
259
278
|
checkpointCount: repo.checkpointCount,
|
|
260
279
|
totalTokenEstimate: repo.totalTokenEstimate,
|
|
@@ -291,33 +310,41 @@ export class MegaRuntime {
|
|
|
291
310
|
const dedupStr = storageRate * 100 >= 10
|
|
292
311
|
? `${Math.round(storageRate * 100)}%`
|
|
293
312
|
: `${(storageRate * 100).toFixed(1)}%`;
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const usedStr = `${C.cyan}${fmt(st.totalTokenEstimate)} sess${C.reset} / ${C.blue}${fmt(repo.totalTokenEstimate)} repo${C.reset}`;
|
|
313
|
+
// Reconciled token accounting — ONE canonical formula for session + repo,
|
|
314
|
+
// matching the dashboard so the two never disagree. unit format: M at/above
|
|
315
|
+
// 1e6, k at/above 1e3, raw below — so 5,472,700 → "5.5M", 24,100 → "24.1k",
|
|
316
|
+
// 142 → "142". Dropped (in) = Freed + Kept; Freed = rt.tokensSaved (session)
|
|
317
|
+
// / repo.tokensSaved meta (repo); Kept = totalTokenEstimate (stored).
|
|
318
|
+
const fmt = (x) => x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}M`
|
|
319
|
+
: x >= 1000 ? `${(x / 1000).toFixed(1)}k`
|
|
320
|
+
: `${Math.round(x)}`;
|
|
303
321
|
const agentStr = this.activeAgents > 0 ? ` │ 🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}` : "";
|
|
304
322
|
const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
|
|
305
323
|
// Phase 3 — pulsing status glyph while a compaction is in flight.
|
|
306
324
|
const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
|
|
325
|
+
// --- reconciled in/out view (session + repo) ---------------------------
|
|
326
|
+
const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
|
|
327
|
+
const sessKept = st.totalTokenEstimate;
|
|
328
|
+
const sessFreed = this.rt.tokensSaved;
|
|
329
|
+
const sessPct = sessIn > 0 ? sessFreed / sessIn : 0;
|
|
330
|
+
const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
|
|
331
|
+
const repoKept = repo.totalTokenEstimate;
|
|
332
|
+
const repoFreed = repo.tokensSaved;
|
|
333
|
+
const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
|
|
307
334
|
const lines = [
|
|
308
335
|
` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
|
|
309
|
-
` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset}
|
|
336
|
+
` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset}`,
|
|
337
|
+
` ${C.gray}dropped ${fmt(sessIn)} → kept ${fmt(sessKept)} sess / ${fmt(repoKept)} repo · freed ${fmt(sessFreed)} sess / ${fmt(repoFreed)} repo${C.reset}`,
|
|
310
338
|
];
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
const filled = Math.
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)} ${C.gray}│${C.reset} ${C.blue}${fmt(this.rt.tokensSaved)}${C.reset}/${C.blue}${fmt(totalHeld)}${C.reset} tok held`);
|
|
339
|
+
// Compression meter — the single headline "% tokens saved" (Freed / In),
|
|
340
|
+
// same formula as the dashboard. Higher = better, so it reads green.
|
|
341
|
+
{
|
|
342
|
+
const w = 10;
|
|
343
|
+
const filled = Math.max(0, Math.min(w, Math.round(sessPct * w)));
|
|
344
|
+
const cbar = C.green + "▓".repeat(filled) + C.dim + "░".repeat(w - filled) + C.reset;
|
|
345
|
+
const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
|
|
346
|
+
const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
|
|
347
|
+
lines.push(` ${cbar} ${sTxt}% tokens saved (sess) · ${rTxt}% repo${C.reset}`);
|
|
321
348
|
}
|
|
322
349
|
// Live "now processing" line + why + recent deduped/compacted events,
|
|
323
350
|
// collapsed to ONE rotating line (fresh only). The ticker ring buffer
|
|
@@ -340,10 +367,11 @@ export class MegaRuntime {
|
|
|
340
367
|
else if (this.pulsing) {
|
|
341
368
|
lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
|
|
342
369
|
}
|
|
343
|
-
//
|
|
344
|
-
//
|
|
370
|
+
// Token accounting summary, always last + dimmed. Shows the total
|
|
371
|
+
// tokens dropped (in) + kept (out) for this session, then the freed
|
|
372
|
+
// (saved) tokens for both this session and all-time across the repo.
|
|
345
373
|
if (lines.length < 10) {
|
|
346
|
-
lines.push(` ${C.dim}
|
|
374
|
+
lines.push(` ${C.dim}session ↑${fmt(sessIn)} in ↓${fmt(sessKept)} out · saved ${fmt(sessFreed)} session / ${fmt(repoFreed)} all-time${C.reset}`);
|
|
347
375
|
}
|
|
348
376
|
ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
|
|
349
377
|
}
|
package/dist/src/compact.js
CHANGED
|
@@ -27,8 +27,14 @@ export function isChatty(text) {
|
|
|
27
27
|
}
|
|
28
28
|
return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
|
|
29
29
|
}
|
|
30
|
-
/** Extract plausible file paths (contain '/' + an interesting extension).
|
|
30
|
+
/** Extract plausible file paths (contain '/' + an interesting extension).
|
|
31
|
+
* Defensive against a missing/empty payload: pi's adapter can hand the engine
|
|
32
|
+
* a message whose `text`/`input`/`output` is undefined (e.g. a pure tool-call
|
|
33
|
+
* or tool-result message), and `.split` on undefined throws and takes down the
|
|
34
|
+
* whole compaction. Guard once at the source so every caller is safe. */
|
|
31
35
|
export function extractFileCandidates(content) {
|
|
36
|
+
if (!content)
|
|
37
|
+
return [];
|
|
32
38
|
const out = [];
|
|
33
39
|
for (const raw of content.split(/\s+/)) {
|
|
34
40
|
// Trim surrounding punctuation only — do NOT strip internal dots, or we
|
package/dist/src/supersede.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
import { extractFileCandidates } from "./compact.js";
|
|
10
10
|
/** Classify a message's relationship to a file path. */
|
|
11
11
|
function fileOps(msg) {
|
|
12
|
+
// msg.text may be undefined for pure tool-call/result messages; the guard
|
|
13
|
+
// lives in extractFileCandidates, but the early return short-circuits the
|
|
14
|
+
// write-detection regex too so we never classify an empty message.
|
|
12
15
|
const paths = extractFileCandidates(msg.text);
|
|
13
16
|
if (paths.length === 0)
|
|
14
17
|
return [];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conflict-scan.test.ts — unit tests for the extension-conflict scanner.
|
|
3
|
+
*
|
|
4
|
+
* Fixture trees are written under a temp dir and scanned via
|
|
5
|
+
* MEGACOMPACT_EXT_SCAN_DIR (which makes collectScanRoots() return that
|
|
6
|
+
* single root). This covers the S24 follow-up fix:
|
|
7
|
+
*
|
|
8
|
+
* 1. node_modules-style code extensions (package.json + pi.extensions) are
|
|
9
|
+
* still detected by source-marker grep (regression).
|
|
10
|
+
* 2. USER-LEVEL extensions installed outside npm (e.g. pi-hermes-memory)
|
|
11
|
+
* now get scanned too — previously only `node_modules` was walked, so a
|
|
12
|
+
* data-only memory store (MEMORY.md + sessions.db, no package.json)
|
|
13
|
+
* was never flagged (the 5000-char file-buffer error slipped through).
|
|
14
|
+
* 3. The data-only memory-store signature is detected even with no source.
|
|
15
|
+
* 4. pi-mega-compact (selfName) is always skipped.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { test, after } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { detectConflicts, collectScanRoots } from "./conflict-scan.js";
|
|
24
|
+
|
|
25
|
+
const base = mkdtempSync(join(tmpdir(), "mc-scan-"));
|
|
26
|
+
let n = 0;
|
|
27
|
+
|
|
28
|
+
/** Make a fixture root containing one or more fake extensions, return its path. */
|
|
29
|
+
function fixture(build: (root: string) => void): string {
|
|
30
|
+
const root = join(base, `case-${n++}`);
|
|
31
|
+
mkdirSync(root, { recursive: true });
|
|
32
|
+
build(root);
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
after(() => {
|
|
37
|
+
rmSync(base, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("scans a user-level, data-only memory store (no package.json)", () => {
|
|
41
|
+
const root = fixture((r) => {
|
|
42
|
+
const ext = join(r, "pi-hermes-memory");
|
|
43
|
+
mkdirSync(ext, { recursive: true });
|
|
44
|
+
// No package.json, no source — just pi's memory-store signature.
|
|
45
|
+
writeFileSync(join(ext, "MEMORY.md"), "# memory\n");
|
|
46
|
+
writeFileSync(join(ext, "sessions.db"), "");
|
|
47
|
+
});
|
|
48
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
49
|
+
try {
|
|
50
|
+
const { conflicts } = detectConflicts();
|
|
51
|
+
assert.ok(conflicts.length >= 1, "expected a memory conflict");
|
|
52
|
+
const hit = conflicts.find((c) => c.kind === "memory");
|
|
53
|
+
assert.ok(hit, "expected a memory-kind conflict");
|
|
54
|
+
assert.equal(hit!.severity, "high");
|
|
55
|
+
assert.ok(
|
|
56
|
+
hit!.evidence.includes("MEMORY.md") ||
|
|
57
|
+
hit!.evidence.includes("sessions.db"),
|
|
58
|
+
"evidence should name the on-disk memory signature",
|
|
59
|
+
);
|
|
60
|
+
} finally {
|
|
61
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("still detects a code extension by source marker (regression)", () => {
|
|
66
|
+
const root = fixture((r) => {
|
|
67
|
+
// A code extension is a DIRECT child of the scan root (mirrors the
|
|
68
|
+
// node_modules layout: packages live one level under the root).
|
|
69
|
+
const ext = join(r, "some-memory-ext");
|
|
70
|
+
mkdirSync(ext, { recursive: true });
|
|
71
|
+
writeFileSync(
|
|
72
|
+
join(ext, "package.json"),
|
|
73
|
+
JSON.stringify({ name: "some-memory-ext", pi: { extensions: ["x.ts"] } }),
|
|
74
|
+
);
|
|
75
|
+
writeFileSync(join(ext, "index.ts"), "export const MEMORY_TOOL = true;");
|
|
76
|
+
});
|
|
77
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
78
|
+
try {
|
|
79
|
+
const { conflicts } = detectConflicts();
|
|
80
|
+
const hit = conflicts.find((c) => c.package === "some-memory-ext");
|
|
81
|
+
assert.ok(hit, "expected some-memory-ext to be flagged");
|
|
82
|
+
assert.equal(hit!.kind, "memory");
|
|
83
|
+
} finally {
|
|
84
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("skips pi-mega-compact (selfName) and non-extension dirs", () => {
|
|
89
|
+
const root = fixture((r) => {
|
|
90
|
+
// selfName dir with a memory signature — must be ignored.
|
|
91
|
+
const me = join(r, "node_modules", "pi-mega-compact");
|
|
92
|
+
mkdirSync(me, { recursive: true });
|
|
93
|
+
writeFileSync(join(me, "sessions.db"), "");
|
|
94
|
+
// unrelated dir with no pi.extensions and no memory signature.
|
|
95
|
+
mkdirSync(join(r, "node_modules", "totally-fine"), { recursive: true });
|
|
96
|
+
});
|
|
97
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
98
|
+
try {
|
|
99
|
+
const { scanned, conflicts } = detectConflicts();
|
|
100
|
+
assert.equal(conflicts.length, 0, "no conflicts expected");
|
|
101
|
+
assert.ok(
|
|
102
|
+
!scanned.some((s) => s.includes("pi-mega-compact")),
|
|
103
|
+
"selfName should not appear in scanned",
|
|
104
|
+
);
|
|
105
|
+
} finally {
|
|
106
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("collectScanRoots honors MEGACOMPACT_EXT_SCAN_DIR override", () => {
|
|
111
|
+
const root = fixture(() => {});
|
|
112
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
113
|
+
try {
|
|
114
|
+
const roots = collectScanRoots();
|
|
115
|
+
assert.deepEqual(roots, [root], "override replaces the whole root list");
|
|
116
|
+
} finally {
|
|
117
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("collectScanRoots falls back to node_modules + user dir when no override", () => {
|
|
122
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
123
|
+
delete process.env.MEGACOMPACT_EXT_USER_DIR;
|
|
124
|
+
// No override set and this test file lives under extensions/, so node_modules
|
|
125
|
+
// resolution walks up from here; the user dir (~/.pi/agent) may or may
|
|
126
|
+
// not exist in CI. We only assert the call returns a non-throwing array.
|
|
127
|
+
const roots = collectScanRoots();
|
|
128
|
+
assert.ok(Array.isArray(roots), "collectScanRoots must return an array");
|
|
129
|
+
});
|