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.
@@ -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
- // saved = tokens removed from context (cumulative original stored).
295
- // Show BOTH this-session (rt.tokensSaved) and repo-wide-total
296
- // (repo.tokensSaved) so the user sees per-session progress vs the running
297
- // repo total. "used" = stored checkpoint tokens (repo.totalTokenEstimate
298
- // vs st.totalTokenEstimate). Use "k" only at/above 1000 so small-but-real
299
- // numbers stay visible (previously Math.round(x/1000) zeroed <1000).
300
- const fmt = (x) => (x >= 1000 ? `${(x / 1000).toFixed(1)}k` : `${x}`);
301
- const savedStr = `${C.green}${fmt(this.rt.tokensSaved)} sess${C.reset} / ${C.blue}${fmt(repo.tokensSaved)} repo${C.reset}`;
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} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
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
- // Phase 3compact progress bar: session tokens saved toward the rolling goal.
312
- if (this.rt.tokensSaved > 0) {
313
- const goal = Math.max(this.savedGoal, 1);
314
- const pct = Math.min(100, Math.round((this.rt.tokensSaved / goal) * 100));
315
- const filled = Math.round((pct / 100) * 10);
316
- const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
317
- // Session tokens saved, with the repo-wide total held alongside so the
318
- // bar reads "saved X of goal" and the right side shows saved vs total.
319
- const totalHeld = st.totalTokenEstimate > 0 ? st.totalTokenEstimate : repo.totalTokenEstimate;
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 meterthe 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
- // Plain-language hint so first-time users understand the widget. Always
344
- // last, dimmed. "/mega-help explains these terms."
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}auto-compresses old context to free space · nothing deleted · /mega-help${C.reset}`);
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
  }
@@ -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
@@ -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
+ });