pi-mega-compact 0.6.3 → 0.6.4

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.
@@ -12,10 +12,18 @@
12
12
  *
13
13
  * Pi-agnostic: reads package.json + greps source. No pi runtime types, so it is
14
14
  * unit-testable against a fixture node_modules tree.
15
+ *
16
+ * SCAN-SCOPE FIX (S24 follow-up): the original scanner only walked the npm
17
+ * `node_modules` tree, so user-level extensions installed outside npm (e.g.
18
+ * `pi-hermes-memory`, a data-only `MEMORY.md` + `sessions.db` memory store)
19
+ * were never inspected — that gap let the 5000-char file-buffer error slip
20
+ * through undetected. We now also scan the user-level extension dir and detect
21
+ * memory stores that ship with no package.json / source to grep.
15
22
  */
16
23
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
17
24
  import { join, dirname } from "node:path";
18
25
  import { fileURLToPath } from "node:url";
26
+ import { homedir } from "node:os";
19
27
  // Marker sets. A package is flagged when its source matches a marker in a
20
28
  // category. File-grep (not AST) keeps this dependency-free and fast.
21
29
  const MARKERS = {
@@ -38,10 +46,7 @@ const MARKERS = {
38
46
  "memoryTool",
39
47
  ],
40
48
  // Tool-output shaping (compact/summarize tool results) — overlap, not a rival.
41
- toolOutput: [
42
- "tool_result",
43
- "ToolResult",
44
- ],
49
+ toolOutput: ["tool_result", "ToolResult"],
45
50
  };
46
51
  /** Resolve the node_modules dir that contains this package (or env override). */
47
52
  export function resolveExtensionRoot(selfDir = dirname(fileURLToPath(import.meta.url))) {
@@ -62,6 +67,41 @@ export function resolveExtensionRoot(selfDir = dirname(fileURLToPath(import.meta
62
67
  }
63
68
  return null;
64
69
  }
70
+ /**
71
+ * Resolve every directory that may hold pi extensions to scan.
72
+ *
73
+ * - `MEGACOMPACT_EXT_SCAN_DIR` (if set) replaces the whole list — a single
74
+ * fixture/override root for tests or custom layouts.
75
+ * - Otherwise: the node_modules that holds this package (classic npm layout) AND
76
+ * the user-level extension dir (`~/.pi/agent`), which is where extensions
77
+ * installed outside npm actually live. The original scanner only walked
78
+ * node_modules, so user-level memory extensions were never flagged — that is
79
+ * the gap that let the 5000-char `MEMORY.md` buffer error slip through.
80
+ */
81
+ export function collectScanRoots() {
82
+ const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
83
+ if (override && override.trim() !== "")
84
+ return [override];
85
+ const roots = [];
86
+ const nm = resolveExtensionRoot();
87
+ if (nm && existsSync(nm))
88
+ roots.push(nm);
89
+ const userDir = process.env.MEGACOMPACT_EXT_USER_DIR?.trim() ||
90
+ join(homedir(), ".pi", "agent");
91
+ if (existsSync(userDir))
92
+ roots.push(userDir);
93
+ return roots;
94
+ }
95
+ /**
96
+ * True when a directory is a pi memory-store container rather than a normal code
97
+ * extension. `pi-hermes-memory` ships as exactly this: no package.json, no
98
+ * source — just `MEMORY.md` + `sessions.db`. The marker-grep path misses it,
99
+ * so we also detect the on-disk memory signature.
100
+ */
101
+ function isMemoryStoreDir(pkgDir) {
102
+ return (existsSync(join(pkgDir, "sessions.db")) ||
103
+ existsSync(join(pkgDir, "MEMORY.md")));
104
+ }
65
105
  /** Recursively collect source-ish files under a package, capped to avoid scans. */
66
106
  function collectFiles(root, max = 400) {
67
107
  const out = [];
@@ -131,70 +171,103 @@ function matchMarkers(pkgDir, keys) {
131
171
  * @param selfName package name to skip (defaults to this package's name).
132
172
  */
133
173
  export function detectConflicts(selfName = "pi-mega-compact") {
134
- const root = resolveExtensionRoot();
174
+ const roots = collectScanRoots();
135
175
  const scanned = [];
136
176
  const conflicts = [];
137
- if (!root || !existsSync(root))
138
- return { scanned, conflicts };
139
- let entries;
140
- try {
141
- entries = readdirSync(root);
142
- }
143
- catch {
144
- return { scanned, conflicts };
145
- }
146
- for (const name of entries) {
147
- const pkgDir = join(root, name);
148
- if (!statSync(pkgDir).isDirectory())
149
- continue;
150
- const pkgJson = join(pkgDir, "package.json");
151
- if (!existsSync(pkgJson))
177
+ for (const root of roots) {
178
+ if (!existsSync(root))
152
179
  continue;
153
- let pkg;
180
+ let entries;
154
181
  try {
155
- pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
182
+ entries = readdirSync(root);
156
183
  }
157
184
  catch {
158
185
  continue;
159
186
  }
160
- const pkgName = pkg.name ?? name;
161
- if (pkgName === selfName)
162
- continue;
163
- // Only consider packages that declare pi extensions.
164
- if (!pkg.pi || !Array.isArray(pkg.pi.extensions) || pkg.pi.extensions.length === 0)
165
- continue;
166
- scanned.push(pkgName);
167
- const memHits = matchMarkers(pkgDir, MARKERS.memory);
168
- const compHits = matchMarkers(pkgDir, MARKERS.compaction);
169
- const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
170
- if (compHits.length > 0) {
171
- conflicts.push({
172
- package: pkgName,
173
- severity: "high",
174
- kind: "compaction",
175
- evidence: compHits,
176
- recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
177
- });
178
- continue; // compaction is the dominant conflict; don't double-flag.
179
- }
180
- if (memHits.length > 0) {
181
- conflicts.push({
182
- package: pkgName,
183
- severity: "high",
184
- kind: "memory",
185
- evidence: memHits,
186
- recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
187
- });
188
- continue;
189
- }
190
- if (toolHits.length > 0) {
191
- conflicts.push({
192
- package: pkgName,
193
- severity: "info",
194
- kind: "tool-output",
195
- evidence: toolHits,
196
- recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
197
- });
187
+ for (const name of entries) {
188
+ const pkgDir = join(root, name);
189
+ let st;
190
+ try {
191
+ st = statSync(pkgDir);
192
+ }
193
+ catch {
194
+ continue;
195
+ }
196
+ if (!st.isDirectory())
197
+ continue;
198
+ // A candidate is either a real code extension (declares pi.extensions) or a
199
+ // data-only memory store (MEMORY.md / sessions.db at its root).
200
+ const pkgJson = join(pkgDir, "package.json");
201
+ let pkg = null;
202
+ if (existsSync(pkgJson)) {
203
+ try {
204
+ pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
205
+ }
206
+ catch {
207
+ pkg = null;
208
+ }
209
+ }
210
+ const isCodeExt = !!pkg &&
211
+ !!pkg.pi &&
212
+ Array.isArray(pkg.pi.extensions) &&
213
+ pkg.pi.extensions.length > 0;
214
+ const isMemoryStore = isMemoryStoreDir(pkgDir);
215
+ if (!isCodeExt && !isMemoryStore)
216
+ continue;
217
+ const pkgName = pkg?.name ?? name;
218
+ if (pkgName === selfName)
219
+ continue;
220
+ scanned.push(`${pkgName} (${name})`);
221
+ if (isCodeExt) {
222
+ const memHits = matchMarkers(pkgDir, MARKERS.memory);
223
+ const compHits = matchMarkers(pkgDir, MARKERS.compaction);
224
+ const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
225
+ if (compHits.length > 0) {
226
+ conflicts.push({
227
+ package: pkgName,
228
+ severity: "high",
229
+ kind: "compaction",
230
+ evidence: compHits,
231
+ recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
232
+ });
233
+ continue; // compaction is the dominant conflict; don't double-flag.
234
+ }
235
+ if (memHits.length > 0) {
236
+ conflicts.push({
237
+ package: pkgName,
238
+ severity: "high",
239
+ kind: "memory",
240
+ evidence: memHits,
241
+ recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
242
+ });
243
+ continue;
244
+ }
245
+ if (toolHits.length > 0) {
246
+ conflicts.push({
247
+ package: pkgName,
248
+ severity: "info",
249
+ kind: "tool-output",
250
+ evidence: toolHits,
251
+ recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
252
+ });
253
+ }
254
+ }
255
+ // Data-only memory store: no source to grep, but the on-disk signature
256
+ // (sessions.db / MEMORY.md) is a conflict with our SQLite memory store.
257
+ if (isMemoryStore) {
258
+ const evidence = [];
259
+ if (existsSync(join(pkgDir, "sessions.db")))
260
+ evidence.push("sessions.db");
261
+ if (existsSync(join(pkgDir, "MEMORY.md")))
262
+ evidence.push("MEMORY.md");
263
+ conflicts.push({
264
+ package: pkgName,
265
+ severity: "high",
266
+ kind: "memory",
267
+ evidence,
268
+ recommendation: "pi-mega-compact now owns save-to-memory (its own SQLite). This data-only memory store competes with it — disable to avoid a duplicate / capped memory buffer.",
269
+ });
270
+ }
198
271
  }
199
272
  }
200
273
  return { scanned, conflicts };
@@ -72,6 +72,7 @@ function readIndex() {
72
72
  const mapped = rows.map((r) => ({
73
73
  repoRoot: String(r.repo_root ?? ""),
74
74
  displayName: String(r.display_name ?? ""),
75
+ stateDir: String(r.state_dir ?? ""),
75
76
  checkpointCount: Number(r.checkpoint_count ?? 0),
76
77
  tokensSaved: Number(r.tokens_saved ?? 0),
77
78
  compressedOriginalBytes: Number(r.compressed_original_bytes ?? 0),
@@ -290,27 +291,31 @@ function dashboardHtml(tierName) {
290
291
  <h2>Vector Store</h2>
291
292
  <div class="stat-grid">
292
293
  <span class="label" title="A saved summary of a chunk of your conversation that was compacted to free up space.">Checkpoints</span><span class="value" id="st-count">0</span>
293
- <span class="label" title="How much conversation we are currently holding as compact summaries (the 'memory' this extension keeps). Smaller is better.">Tokens Stored</span><span class="value" id="st-tokens">0</span>
294
- <span class="label" title="Total size of the original conversation text before it was compacted.">Original Tokens</span><span class="value" id="st-orig">0</span>
295
- <span class="label" title="How much conversation space we have freed up for you (original size minus the compact summary we kept).">Tokens Saved</span><span class="value" id="st-saved">0</span>
294
+ <span class="label" title="Total size of the original conversation text dropped into compaction this session, including redundant regions skipped by dedup. This is the 'in'.">Original (dropped)</span><span class="value" id="st-in">0</span>
295
+ <span class="label" title="Compact summaries we are currently holding as 'memory' for this session (the 'out'). Smaller is better.">Kept (summaries)</span><span class="value" id="st-kept">0</span>
296
+ <span class="label" title="Conversation space freed = dropped kept (the 'saved').">Freed (dropped kept)</span><span class="value" id="st-freed">0</span>
296
297
  <span class="label" title="How many times old context was automatically brought back into the conversation because it was relevant to what you were doing.">Injected</span><span class="value" id="st-injected">0</span>
297
298
  <span class="label" title="Of the times we recalled old context, how often it was actually on-topic.">Recall Relevance</span><span class="value" id="st-dedup">0%</span>
298
299
  <span class="label" title="How often new content matched something we already had, so we skipped storing a duplicate copy. Higher = less wasted space.">Storage Dedup</span><span class="value" id="st-sdedup">0%</span>
299
300
  <span class="label" title="How many duplicate chunks we collapsed into one instead of storing separately.">Collapsed</span><span class="value" id="st-collapsed">0</span>
300
301
  <span class="label" title="The ID of the most recent saved checkpoint.">Last ID</span><span class="value" id="st-lastid">—</span>
301
302
  </div>
303
+ <div class="meter-track" style="margin-top:10px"><div class="meter-fill" id="st-compress-bar" style="width:0%"></div></div>
304
+ <div class="meter-sub" id="st-compress-sub">waiting for compaction…</div>
302
305
  </div>
303
306
  <div class="card">
304
307
  <h2>Repo (all sessions)</h2>
305
308
  <div class="stat-grid">
306
309
  <span class="label">Checkpoints</span><span class="value" id="rp-count">0</span>
307
- <span class="label">Tokens Stored</span><span class="value" id="rp-tokens">0</span>
308
- <span class="label">Original Tokens</span><span class="value" id="rp-orig">0</span>
309
- <span class="label">Tokens Saved</span><span class="value" id="rp-saved">0</span>
310
+ <span class="label">Original (dropped)</span><span class="value" id="rp-in">0</span>
311
+ <span class="label">Kept (summaries)</span><span class="value" id="rp-kept">0</span>
312
+ <span class="label">Freed (dropped − kept)</span><span class="value" id="rp-freed">0</span>
310
313
  <span class="label">Sessions</span><span class="value" id="rp-sessions">0</span>
311
314
  <span class="label">Collapsed</span><span class="value" id="rp-collapsed">0</span>
312
315
  <span class="label">Storage Dedup</span><span class="value" id="rp-sdedup">0%</span>
313
316
  </div>
317
+ <div class="meter-track" style="margin-top:10px"><div class="meter-fill" id="rp-compress-bar" style="width:0%"></div></div>
318
+ <div class="meter-sub" id="rp-compress-sub">waiting for compaction…</div>
314
319
  </div>
315
320
  <div class="card safe">
316
321
  <h2>🛡 Data Safety</h2>
@@ -356,11 +361,11 @@ function dashboardHtml(tierName) {
356
361
  <div class="card legend">
357
362
  <h2>What these numbers mean</h2>
358
363
  <ul class="legend-list">
359
- <li><b>Tokens saved</b> — conversation space this extension has freed up for you (it compacted old text into short summaries).</li>
360
- <li><b>Tokens stored</b> — how much "memory" (compact summaries) the extension is currently holding for this repo.</li>
361
- <li><b>Injected</b> times old context was automatically pasted back in because it was relevant to your current task.</li>
362
- <li><b>Recall relevance</b> — of those, how often the recalled context was actually on-topic.</li>
363
- <li><b>Storage dedup</b> — how often new content matched something already saved, so a duplicate copy was skipped (saves space).</li>
364
+ <li><b>Original (dropped)</b> — everything compacted away (including duplicates caught by dedup). The "in."</li>
365
+ <li><b>Kept (summaries)</b> — compact summaries still held as "memory" (the "out").</li>
366
+ <li><b>Freed</b> = dropped kept tokens saved so far (higher = better).</li>
367
+ <li><b>Compression %</b> — Freed ÷ Dropped the headline efficiency number. Higher = more space reclaimed.</li>
368
+ <li><b>Storage dedup %</b> — how often new content matched something already saved, so no duplicate copy was written.</li>
364
369
  <li><b>Data safety</b> — every compacted region is kept verbatim (compressed). Nothing is permanently deleted; you can restore any of it.</li>
365
370
  </ul>
366
371
  <p class="legend-note">Hover any label above for a quick explanation.</p>
@@ -464,10 +469,20 @@ function dashboardHtml(tierName) {
464
469
  d.trigger.armed ? 'past fast gate — monitoring token count' : 'idle — below fast gate';
465
470
  document.getElementById('tr-state').textContent = state;
466
471
 
472
+ // ---- Vector Store — reconciled token accounting (same formula as widget) -
467
473
  document.getElementById('st-count').textContent = d.store.checkpointCount;
468
- document.getElementById('st-tokens').textContent = d.store.totalTokenEstimate.toLocaleString();
469
- document.getElementById('st-orig').textContent = (d.store.originalTokens || 0).toLocaleString();
470
- document.getElementById('st-saved').textContent = (d.store.tokensSaved || 0).toLocaleString();
474
+ // Compression block from the snapshot (Freed = In − Out, single formula).
475
+ var c = d.compression || {};
476
+ var sess = c.session || { tokensIn:0, tokensOut:0, tokensFreed:0, compressionPct:0, dedupPct:0 };
477
+ var cRepo = c.repo || { tokensIn:0, tokensOut:0, tokensFreed:0, compressionPct:0, dedupPct:0 };
478
+ document.getElementById('st-in').textContent = sess.tokensIn.toLocaleString();
479
+ document.getElementById('st-kept').textContent = sess.tokensOut.toLocaleString();
480
+ document.getElementById('st-freed').textContent = sess.tokensFreed.toLocaleString();
481
+ var sp = sess.compressionPct || 0;
482
+ document.getElementById('st-compress-bar').style.width = Math.max(sp * 100, 0.5) + '%';
483
+ document.getElementById('st-compress-bar').className = 'meter-fill ' + (sp >= 0.9 ? 'meter-green' : sp >= 0.6 ? 'meter-yellow' : 'meter-red');
484
+ document.getElementById('st-compress-sub').textContent = (sp * 100 >= 10 ? Math.round(sp * 100) : (sp * 100).toFixed(1)) + '% tokens saved · dedup: ' + (sess.dedupPct * 100 >= 10 ? Math.round(sess.dedupPct * 100) : (sess.dedupPct * 100).toFixed(1)) + '%';
485
+ // ------
471
486
  document.getElementById('st-injected').textContent = d.store.injectedCount;
472
487
  document.getElementById('st-dedup').textContent = Math.round(d.store.dedupHitRate * 100) + '%';
473
488
  var sdr = d.store.storageDedupRate || 0;
@@ -475,16 +490,19 @@ function dashboardHtml(tierName) {
475
490
  document.getElementById('st-collapsed').textContent = d.store.dedupCollapsed || 0;
476
491
  document.getElementById('st-lastid').textContent = d.session.lastCheckpointId || '—';
477
492
 
478
- // Repo-wide (all sessions in this repo's SQLite store).
479
- var repo = d.repo || { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupCollapsed: 0, storageDedupRate: 0 };
480
- document.getElementById('rp-count').textContent = repo.checkpointCount;
481
- document.getElementById('rp-tokens').textContent = repo.totalTokenEstimate.toLocaleString();
482
- document.getElementById('rp-orig').textContent = (repo.originalTokens || 0).toLocaleString();
483
- document.getElementById('rp-saved').textContent = (repo.tokensSaved || 0).toLocaleString();
484
- document.getElementById('rp-sessions').textContent = repo.sessionCount || 0;
485
- document.getElementById('rp-collapsed').textContent = repo.dedupCollapsed || 0;
486
- var rsdr = repo.storageDedupRate || 0;
487
- document.getElementById('rp-sdedup').textContent = (rsdr * 100 >= 10 ? Math.round(rsdr * 100) : (rsdr * 100).toFixed(1)) + '%';
493
+ // ---- Repo (all sessions) same compression fields, repo scope ----------
494
+ document.getElementById('rp-count').textContent = (d.repo && d.repo.checkpointCount || 0).toLocaleString();
495
+ document.getElementById('rp-in').textContent = cRepo.tokensIn.toLocaleString();
496
+ document.getElementById('rp-kept').textContent = cRepo.tokensOut.toLocaleString();
497
+ document.getElementById('rp-freed').textContent = cRepo.tokensFreed.toLocaleString();
498
+ document.getElementById('rp-sessions').textContent = (d.repo && d.repo.sessionCount || 0).toLocaleString();
499
+ document.getElementById('rp-collapsed').textContent = (d.repo && d.repo.dedupCollapsed || 0).toLocaleString();
500
+ var rdr = d.repo && d.repo.storageDedupRate || 0;
501
+ document.getElementById('rp-sdedup').textContent = (rdr * 100 >= 10 ? Math.round(rdr * 100) : (rdr * 100).toFixed(1)) + '%';
502
+ var rp = cRepo.compressionPct || 0;
503
+ document.getElementById('rp-compress-bar').style.width = Math.max(rp * 100, 0.5) + '%';
504
+ document.getElementById('rp-compress-bar').className = 'meter-fill ' + (rp >= 0.9 ? 'meter-green' : rp >= 0.6 ? 'meter-yellow' : 'meter-red');
505
+ document.getElementById('rp-compress-sub').textContent = (rp * 100 >= 10 ? Math.round(rp * 100) : (rp * 100).toFixed(1)) + '% tokens saved · dedup: ' + (cRepo.dedupPct * 100 >= 10 ? Math.round(cRepo.dedupPct * 100) : (cRepo.dedupPct * 100).toFixed(1)) + '%';
488
506
 
489
507
  // Data-safety invariant (Phase 0 — trust foundation).
490
508
  var ig = d.integrity || { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 };
@@ -524,10 +542,11 @@ function dashboardHtml(tierName) {
524
542
  document.getElementById('md-provider').textContent = model && model.providerName ? model.providerName : (model && model.provider ? model.provider : '—');
525
543
  document.getElementById('md-input').textContent = model && model.inputRate ? '$' + (model.inputRate).toFixed(6) : '—';
526
544
  document.getElementById('md-output').textContent = model && model.outputRate ? '$' + (model.outputRate).toFixed(6) : '—';
527
- if (model && model.inputRate && repo.tokensSaved > 0) {
528
- var usd = (repo.tokensSaved * model.inputRate);
545
+ var repoSaved = cRepo.tokensFreed || 0;
546
+ if (model && model.inputRate && repoSaved > 0) {
547
+ var usd = (repoSaved * model.inputRate);
529
548
  var win = d.context.contextWindow || 0;
530
- var windows = win > 0 ? (repo.tokensSaved / win).toFixed(1) : '0';
549
+ var windows = win > 0 ? (repoSaved / win).toFixed(1) : '0';
531
550
  document.getElementById('cost-usd').textContent = '≈ $' + usd.toFixed(4) + ' saved';
532
551
  document.getElementById('cost-windows').textContent = windows + ' context-windows extended';
533
552
  } else {
@@ -757,7 +776,47 @@ export async function launchDashboardServer(stateDir) {
757
776
  // ── New server ────────────────────────────────────────────────────────────
758
777
  mkdirSync(stateDir, { recursive: true });
759
778
  let eventOffset = 0;
779
+ // Overlay the live current-repo snapshot (snapshot.json, rewritten every
780
+ // context event) onto its registry row so the All-repos / Summary views stay
781
+ // in sync with the live menu bar + Current-repo card in real time. The
782
+ // registry (index.sqlite) is only written on repo-switch (bindRepo), so
783
+ // without this the current repo's row freezes between switches. Read-only —
784
+ // no extra writes to index.sqlite. Matched by stateDir, which equals the
785
+ // value this server was launched with (runtime.currentStateDir).
786
+ function overlayCurrentRepo(idx) {
787
+ if (!idx || !idx.repos.length)
788
+ return;
789
+ let snap = null;
790
+ try {
791
+ snap = readSnapshot(snapshotPath);
792
+ }
793
+ catch {
794
+ return;
795
+ }
796
+ if (!snap || !snap.repo)
797
+ return;
798
+ const cur = idx.repos.find((r) => r.stateDir === stateDir);
799
+ if (!cur)
800
+ return;
801
+ const prevSaved = cur.tokensSaved;
802
+ const prevCp = cur.checkpointCount;
803
+ const prevBytes = cur.compressedOriginalBytes;
804
+ const comp = snap.compression?.repo;
805
+ const liveSaved = comp ? comp.tokensFreed : (snap.repo.tokensSaved ?? prevSaved);
806
+ const liveCp = snap.repo.checkpointCount ?? prevCp;
807
+ const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
808
+ cur.tokensSaved = liveSaved;
809
+ cur.checkpointCount = liveCp;
810
+ cur.compressedOriginalBytes = liveBytes;
811
+ if (idx.summary) {
812
+ idx.summary.totalTokensSaved += liveSaved - prevSaved;
813
+ idx.summary.totalCheckpoints += liveCp - prevCp;
814
+ idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
815
+ }
816
+ idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
817
+ }
760
818
  const server = createServer((req, res) => {
819
+ // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS open for local browser access
761
820
  // CORS for local access
762
821
  res.setHeader("Access-Control-Allow-Origin", "*");
763
822
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
@@ -790,8 +849,11 @@ export async function launchDashboardServer(stateDir) {
790
849
  // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
791
850
  // checkpoints, tokens saved, and active model. Read-only.
792
851
  if (req.url === "/api/index") {
852
+ const idx = readIndex();
853
+ if (idx)
854
+ overlayCurrentRepo(idx);
793
855
  res.writeHead(200, { "Content-Type": "application/json" });
794
- res.end(JSON.stringify(readIndex() ?? { updatedAt: null, summary: null, repos: [] }));
856
+ res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
795
857
  return;
796
858
  }
797
859
  // /api/repos — registry list. Optional `?active=24h` filters to repos
@@ -800,8 +862,10 @@ export async function launchDashboardServer(stateDir) {
800
862
  if (req.url?.startsWith("/api/repos")) {
801
863
  const url = new URL(req.url, "http://x");
802
864
  const activeParam = url.searchParams.get("active");
803
- const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
804
- let repos = (idx.repos ?? []);
865
+ const idx = readIndex();
866
+ if (idx)
867
+ overlayCurrentRepo(idx);
868
+ let repos = idx?.repos ?? [];
805
869
  if (activeParam) {
806
870
  const m = /^(\d+)h$/.exec(activeParam);
807
871
  if (m) {
@@ -810,21 +874,23 @@ export async function launchDashboardServer(stateDir) {
810
874
  }
811
875
  }
812
876
  res.writeHead(200, { "Content-Type": "application/json" });
813
- res.end(JSON.stringify({ updatedAt: idx.updatedAt, repos, count: repos.length }));
877
+ res.end(JSON.stringify({ updatedAt: idx?.updatedAt ?? null, repos, count: repos.length }));
814
878
  return;
815
879
  }
816
880
  // /api/summary — header tiles without the full repo list (keeps payload
817
881
  // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
818
882
  // count so the dashboard can render the active badge alongside totals.
819
883
  if (req.url?.startsWith("/api/summary")) {
820
- const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
821
- const repos = (idx.repos ?? []);
884
+ const idx = readIndex();
885
+ if (idx)
886
+ overlayCurrentRepo(idx);
887
+ const repos = idx?.repos ?? [];
822
888
  const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
823
889
  const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
824
890
  res.writeHead(200, { "Content-Type": "application/json" });
825
891
  res.end(JSON.stringify({
826
- updatedAt: idx.updatedAt,
827
- summary: idx.summary,
892
+ updatedAt: idx?.updatedAt ?? null,
893
+ summary: idx?.summary ?? null,
828
894
  activeRepos,
829
895
  totalRepos: repos.length,
830
896
  }));
@@ -123,8 +123,8 @@ describe("multi-repo /api/index (S19)", () => {
123
123
  process.env.MEGACOMPACT_INDEX_DIR = indexDir;
124
124
  process.env.MEGACOMPACT_DASHBOARD_PORT = "19321"; // private base, non-colliding
125
125
  const { upsertRepoRegistry } = await import("../src/store/sqlite.js");
126
- upsertRepoRegistry({ repoRoot: "/home/u/repoA", displayName: "repoA", stateDir: dir, checkpointCount: 3, tokensSaved: 1000, compressedOriginalBytes: 0 }, indexDir);
127
- upsertRepoRegistry({ repoRoot: "/home/u/repoB", displayName: "repoB", stateDir: dir, checkpointCount: 5, tokensSaved: 2000, compressedOriginalBytes: 0 }, indexDir);
126
+ upsertRepoRegistry({ repoRoot: "/home/u/repoA", displayName: "repoA", stateDir: join(dir, "a"), checkpointCount: 3, tokensSaved: 1000, compressedOriginalBytes: 0 }, indexDir);
127
+ upsertRepoRegistry({ repoRoot: "/home/u/repoB", displayName: "repoB", stateDir: join(dir, "b"), checkpointCount: 5, tokensSaved: 2000, compressedOriginalBytes: 0 }, indexDir);
128
128
  const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
129
129
  try {
130
130
  await waitFor(async () => {
@@ -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