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.
@@ -59,6 +59,7 @@ function getIndexDir(): string {
59
59
  interface IndexRepo {
60
60
  repoRoot: string;
61
61
  displayName: string;
62
+ stateDir: string;
62
63
  checkpointCount: number;
63
64
  tokensSaved: number;
64
65
  compressedOriginalBytes: number;
@@ -71,8 +72,17 @@ interface IndexRepo {
71
72
  lastSeen: number;
72
73
  }
73
74
 
75
+ interface IndexSummary {
76
+ totalRepos: number;
77
+ totalCheckpoints: number;
78
+ totalTokensSaved: number;
79
+ totalCompressedOriginalBytes: number;
80
+ }
81
+
82
+ type IndexIndex = { updatedAt: string; summary: IndexSummary | null; repos: IndexRepo[] };
83
+
74
84
  /** Read the machine-wide repo registry from SQLite (read-only, single shot). */
75
- function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] } | null {
85
+ function readIndex(): IndexIndex | null {
76
86
  const indexPath = join(getIndexDir(), "index.sqlite");
77
87
  if (!existsSync(indexPath)) return null;
78
88
  let db: DatabaseSync | undefined;
@@ -86,6 +96,7 @@ function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] }
86
96
  const mapped: IndexRepo[] = rows.map((r) => ({
87
97
  repoRoot: String(r.repo_root ?? ""),
88
98
  displayName: String(r.display_name ?? ""),
99
+ stateDir: String(r.state_dir ?? ""),
89
100
  checkpointCount: Number(r.checkpoint_count ?? 0),
90
101
  tokensSaved: Number(r.tokens_saved ?? 0),
91
102
  compressedOriginalBytes: Number(r.compressed_original_bytes ?? 0),
@@ -197,6 +208,10 @@ interface Snapshot {
197
208
  duplicatesCollapsed: number;
198
209
  bytesPermanentlyDeleted: number;
199
210
  };
211
+ compression: {
212
+ session: { tokensIn: number; tokensOut: number; tokensFreed: number; compressionPct: number; dedupPct: number };
213
+ repo: { tokensIn: number; tokensOut: number; tokensFreed: number; compressionPct: number; dedupPct: number };
214
+ };
200
215
  model?: {
201
216
  name: string;
202
217
  provider: string;
@@ -374,27 +389,31 @@ function dashboardHtml(tierName: string): string {
374
389
  <h2>Vector Store</h2>
375
390
  <div class="stat-grid">
376
391
  <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>
377
- <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>
378
- <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>
379
- <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>
392
+ <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>
393
+ <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>
394
+ <span class="label" title="Conversation space freed = dropped kept (the 'saved').">Freed (dropped kept)</span><span class="value" id="st-freed">0</span>
380
395
  <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>
381
396
  <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>
382
397
  <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>
383
398
  <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>
384
399
  <span class="label" title="The ID of the most recent saved checkpoint.">Last ID</span><span class="value" id="st-lastid">—</span>
385
400
  </div>
401
+ <div class="meter-track" style="margin-top:10px"><div class="meter-fill" id="st-compress-bar" style="width:0%"></div></div>
402
+ <div class="meter-sub" id="st-compress-sub">waiting for compaction…</div>
386
403
  </div>
387
404
  <div class="card">
388
405
  <h2>Repo (all sessions)</h2>
389
406
  <div class="stat-grid">
390
407
  <span class="label">Checkpoints</span><span class="value" id="rp-count">0</span>
391
- <span class="label">Tokens Stored</span><span class="value" id="rp-tokens">0</span>
392
- <span class="label">Original Tokens</span><span class="value" id="rp-orig">0</span>
393
- <span class="label">Tokens Saved</span><span class="value" id="rp-saved">0</span>
408
+ <span class="label">Original (dropped)</span><span class="value" id="rp-in">0</span>
409
+ <span class="label">Kept (summaries)</span><span class="value" id="rp-kept">0</span>
410
+ <span class="label">Freed (dropped − kept)</span><span class="value" id="rp-freed">0</span>
394
411
  <span class="label">Sessions</span><span class="value" id="rp-sessions">0</span>
395
412
  <span class="label">Collapsed</span><span class="value" id="rp-collapsed">0</span>
396
413
  <span class="label">Storage Dedup</span><span class="value" id="rp-sdedup">0%</span>
397
414
  </div>
415
+ <div class="meter-track" style="margin-top:10px"><div class="meter-fill" id="rp-compress-bar" style="width:0%"></div></div>
416
+ <div class="meter-sub" id="rp-compress-sub">waiting for compaction…</div>
398
417
  </div>
399
418
  <div class="card safe">
400
419
  <h2>🛡 Data Safety</h2>
@@ -440,11 +459,11 @@ function dashboardHtml(tierName: string): string {
440
459
  <div class="card legend">
441
460
  <h2>What these numbers mean</h2>
442
461
  <ul class="legend-list">
443
- <li><b>Tokens saved</b> — conversation space this extension has freed up for you (it compacted old text into short summaries).</li>
444
- <li><b>Tokens stored</b> — how much "memory" (compact summaries) the extension is currently holding for this repo.</li>
445
- <li><b>Injected</b> times old context was automatically pasted back in because it was relevant to your current task.</li>
446
- <li><b>Recall relevance</b> — of those, how often the recalled context was actually on-topic.</li>
447
- <li><b>Storage dedup</b> — how often new content matched something already saved, so a duplicate copy was skipped (saves space).</li>
462
+ <li><b>Original (dropped)</b> — everything compacted away (including duplicates caught by dedup). The "in."</li>
463
+ <li><b>Kept (summaries)</b> — compact summaries still held as "memory" (the "out").</li>
464
+ <li><b>Freed</b> = dropped kept tokens saved so far (higher = better).</li>
465
+ <li><b>Compression %</b> — Freed ÷ Dropped the headline efficiency number. Higher = more space reclaimed.</li>
466
+ <li><b>Storage dedup %</b> — how often new content matched something already saved, so no duplicate copy was written.</li>
448
467
  <li><b>Data safety</b> — every compacted region is kept verbatim (compressed). Nothing is permanently deleted; you can restore any of it.</li>
449
468
  </ul>
450
469
  <p class="legend-note">Hover any label above for a quick explanation.</p>
@@ -515,6 +534,23 @@ function dashboardHtml(tierName: string): string {
515
534
  <div class="summary-card"><div class="num" id="sm-saved">0</div><div class="lbl">Total Tokens Saved</div></div>
516
535
  <div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
517
536
  </div>
537
+
538
+ <h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">Savings by Model</h2>
539
+ <p class="legend-note" style="margin-bottom:10px">How much context &amp; cost mega-compact has reclaimed, grouped by the model you were running. Compression ratio reflects workload/content, not model quality.</p>
540
+ <table class="repos">
541
+ <thead>
542
+ <tr>
543
+ <th>Model</th><th>Provider</th>
544
+ <th style="text-align:right">Repos</th>
545
+ <th style="text-align:right">Checkpoints</th>
546
+ <th style="text-align:right">Tokens Saved</th>
547
+ <th style="text-align:right">$ Saved</th>
548
+ <th style="text-align:right">Last Used</th>
549
+ </tr>
550
+ </thead>
551
+ <tbody id="bm-rows"><tr><td colspan="7" class="repo-none">loading…</td></tr></tbody>
552
+ </table>
553
+
518
554
  <div class="updated" id="sm-updated"></div>
519
555
  </div>
520
556
 
@@ -548,10 +584,20 @@ function dashboardHtml(tierName: string): string {
548
584
  d.trigger.armed ? 'past fast gate — monitoring token count' : 'idle — below fast gate';
549
585
  document.getElementById('tr-state').textContent = state;
550
586
 
587
+ // ---- Vector Store — reconciled token accounting (same formula as widget) -
551
588
  document.getElementById('st-count').textContent = d.store.checkpointCount;
552
- document.getElementById('st-tokens').textContent = d.store.totalTokenEstimate.toLocaleString();
553
- document.getElementById('st-orig').textContent = (d.store.originalTokens || 0).toLocaleString();
554
- document.getElementById('st-saved').textContent = (d.store.tokensSaved || 0).toLocaleString();
589
+ // Compression block from the snapshot (Freed = In − Out, single formula).
590
+ var c = d.compression || {};
591
+ var sess = c.session || { tokensIn:0, tokensOut:0, tokensFreed:0, compressionPct:0, dedupPct:0 };
592
+ var cRepo = c.repo || { tokensIn:0, tokensOut:0, tokensFreed:0, compressionPct:0, dedupPct:0 };
593
+ document.getElementById('st-in').textContent = sess.tokensIn.toLocaleString();
594
+ document.getElementById('st-kept').textContent = sess.tokensOut.toLocaleString();
595
+ document.getElementById('st-freed').textContent = sess.tokensFreed.toLocaleString();
596
+ var sp = sess.compressionPct || 0;
597
+ document.getElementById('st-compress-bar').style.width = Math.max(sp * 100, 0.5) + '%';
598
+ document.getElementById('st-compress-bar').className = 'meter-fill ' + (sp >= 0.9 ? 'meter-green' : sp >= 0.6 ? 'meter-yellow' : 'meter-red');
599
+ 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)) + '%';
600
+ // ------
555
601
  document.getElementById('st-injected').textContent = d.store.injectedCount;
556
602
  document.getElementById('st-dedup').textContent = Math.round(d.store.dedupHitRate * 100) + '%';
557
603
  var sdr = d.store.storageDedupRate || 0;
@@ -559,16 +605,19 @@ function dashboardHtml(tierName: string): string {
559
605
  document.getElementById('st-collapsed').textContent = d.store.dedupCollapsed || 0;
560
606
  document.getElementById('st-lastid').textContent = d.session.lastCheckpointId || '—';
561
607
 
562
- // Repo-wide (all sessions in this repo's SQLite store).
563
- var repo = d.repo || { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupCollapsed: 0, storageDedupRate: 0 };
564
- document.getElementById('rp-count').textContent = repo.checkpointCount;
565
- document.getElementById('rp-tokens').textContent = repo.totalTokenEstimate.toLocaleString();
566
- document.getElementById('rp-orig').textContent = (repo.originalTokens || 0).toLocaleString();
567
- document.getElementById('rp-saved').textContent = (repo.tokensSaved || 0).toLocaleString();
568
- document.getElementById('rp-sessions').textContent = repo.sessionCount || 0;
569
- document.getElementById('rp-collapsed').textContent = repo.dedupCollapsed || 0;
570
- var rsdr = repo.storageDedupRate || 0;
571
- document.getElementById('rp-sdedup').textContent = (rsdr * 100 >= 10 ? Math.round(rsdr * 100) : (rsdr * 100).toFixed(1)) + '%';
608
+ // ---- Repo (all sessions) same compression fields, repo scope ----------
609
+ document.getElementById('rp-count').textContent = (d.repo && d.repo.checkpointCount || 0).toLocaleString();
610
+ document.getElementById('rp-in').textContent = cRepo.tokensIn.toLocaleString();
611
+ document.getElementById('rp-kept').textContent = cRepo.tokensOut.toLocaleString();
612
+ document.getElementById('rp-freed').textContent = cRepo.tokensFreed.toLocaleString();
613
+ document.getElementById('rp-sessions').textContent = (d.repo && d.repo.sessionCount || 0).toLocaleString();
614
+ document.getElementById('rp-collapsed').textContent = (d.repo && d.repo.dedupCollapsed || 0).toLocaleString();
615
+ var rdr = d.repo && d.repo.storageDedupRate || 0;
616
+ document.getElementById('rp-sdedup').textContent = (rdr * 100 >= 10 ? Math.round(rdr * 100) : (rdr * 100).toFixed(1)) + '%';
617
+ var rp = cRepo.compressionPct || 0;
618
+ document.getElementById('rp-compress-bar').style.width = Math.max(rp * 100, 0.5) + '%';
619
+ document.getElementById('rp-compress-bar').className = 'meter-fill ' + (rp >= 0.9 ? 'meter-green' : rp >= 0.6 ? 'meter-yellow' : 'meter-red');
620
+ 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)) + '%';
572
621
 
573
622
  // Data-safety invariant (Phase 0 — trust foundation).
574
623
  var ig = d.integrity || { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 };
@@ -608,10 +657,11 @@ function dashboardHtml(tierName: string): string {
608
657
  document.getElementById('md-provider').textContent = model && model.providerName ? model.providerName : (model && model.provider ? model.provider : '—');
609
658
  document.getElementById('md-input').textContent = model && model.inputRate ? '$' + (model.inputRate).toFixed(6) : '—';
610
659
  document.getElementById('md-output').textContent = model && model.outputRate ? '$' + (model.outputRate).toFixed(6) : '—';
611
- if (model && model.inputRate && repo.tokensSaved > 0) {
612
- var usd = (repo.tokensSaved * model.inputRate);
660
+ var repoSaved = cRepo.tokensFreed || 0;
661
+ if (model && model.inputRate && repoSaved > 0) {
662
+ var usd = (repoSaved * model.inputRate);
613
663
  var win = d.context.contextWindow || 0;
614
- var windows = win > 0 ? (repo.tokensSaved / win).toFixed(1) : '0';
664
+ var windows = win > 0 ? (repoSaved / win).toFixed(1) : '0';
615
665
  document.getElementById('cost-usd').textContent = '≈ $' + usd.toFixed(4) + ' saved';
616
666
  document.getElementById('cost-windows').textContent = windows + ' context-windows extended';
617
667
  } else {
@@ -709,6 +759,49 @@ function dashboardHtml(tierName: string): string {
709
759
  document.getElementById('cur-updated').textContent = stamp;
710
760
  document.getElementById('all-updated').textContent = stamp;
711
761
  document.getElementById('sm-updated').textContent = stamp;
762
+ renderByModel(repos);
763
+ }
764
+
765
+ // Savings-by-model aggregation for the Summary tab — groups the machine-
766
+ // wide repo registry by (modelName || '(unknown)') so the user can see how
767
+ // much context + cost mega-compact has reclaimed, broken down by which model
768
+ // they were running. $ Saved = Σ(tokensSaved × inputRate) per model. Sorted
769
+ // by tokens saved descending so the biggest-reclaim model wins the top row.
770
+ function renderByModel(repos) {
771
+ var rows = document.getElementById('bm-rows');
772
+ if (!rows) return;
773
+ if (!repos || !repos.length) {
774
+ rows.innerHTML = '<tr><td colspan="7" class="repo-none">No repositories registered yet.</td></tr>';
775
+ return;
776
+ }
777
+ var groups = {};
778
+ for (var i = 0; i < repos.length; i++) {
779
+ var r = repos[i];
780
+ var key = (r.modelName && String(r.modelName).trim()) || '(unknown)';
781
+ if (!groups[key]) groups[key] = { model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0, tokensSaved: 0, usd: 0, lastAt: 0, rates: [] };
782
+ var g = groups[key];
783
+ g.repos++;
784
+ g.checkpoints += (r.checkpointCount || 0);
785
+ g.tokensSaved += (r.tokensSaved || 0);
786
+ if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.rates.push(r.inputRate); }
787
+ if (r.lastCompactedAt && r.lastCompactedAt > g.lastAt) g.lastAt = r.lastCompactedAt;
788
+ }
789
+ var arr = [];
790
+ for (var k in groups) { if (Object.prototype.hasOwnProperty.call(groups, k)) arr.push(groups[k]); }
791
+ arr.sort(function(a, b) { return b.tokensSaved - a.tokensSaved; });
792
+ rows.innerHTML = arr.map(function(g) {
793
+ var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
794
+ var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
795
+ return '<tr>' +
796
+ '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
797
+ '<td>' + sanitize(g.provider) + '</td>' +
798
+ '<td class="num">' + g.repos.toLocaleString() + '</td>' +
799
+ '<td class="num">' + g.checkpoints.toLocaleString() + '</td>' +
800
+ '<td class="num">' + g.tokensSaved.toLocaleString() + '</td>' +
801
+ '<td class="num">' + sanitize(usd) + '</td>' +
802
+ '<td class="num">' + sanitize(when) + '</td>' +
803
+ '</tr>';
804
+ }).join('');
712
805
  }
713
806
 
714
807
  // Per-repo detail modal ---------------------------------------------------
@@ -838,7 +931,40 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
838
931
 
839
932
  let eventOffset = 0;
840
933
 
934
+ // Overlay the live current-repo snapshot (snapshot.json, rewritten every
935
+ // context event) onto its registry row so the All-repos / Summary views stay
936
+ // in sync with the live menu bar + Current-repo card in real time. The
937
+ // registry (index.sqlite) is only written on repo-switch (bindRepo), so
938
+ // without this the current repo's row freezes between switches. Read-only —
939
+ // no extra writes to index.sqlite. Matched by stateDir, which equals the
940
+ // value this server was launched with (runtime.currentStateDir).
941
+ function overlayCurrentRepo(idx: IndexIndex | null): void {
942
+ if (!idx || !idx.repos.length) return;
943
+ let snap: Snapshot | null = null;
944
+ try { snap = readSnapshot(snapshotPath); } catch { return; }
945
+ if (!snap || !snap.repo) return;
946
+ const cur = idx.repos.find((r) => r.stateDir === stateDir);
947
+ if (!cur) return;
948
+ const prevSaved = cur.tokensSaved;
949
+ const prevCp = cur.checkpointCount;
950
+ const prevBytes = cur.compressedOriginalBytes;
951
+ const comp = snap.compression?.repo;
952
+ const liveSaved = comp ? comp.tokensFreed : (snap.repo.tokensSaved ?? prevSaved);
953
+ const liveCp = snap.repo.checkpointCount ?? prevCp;
954
+ const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
955
+ cur.tokensSaved = liveSaved;
956
+ cur.checkpointCount = liveCp;
957
+ cur.compressedOriginalBytes = liveBytes;
958
+ if (idx.summary) {
959
+ idx.summary.totalTokensSaved += liveSaved - prevSaved;
960
+ idx.summary.totalCheckpoints += liveCp - prevCp;
961
+ idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
962
+ }
963
+ idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
964
+ }
965
+
841
966
  const server = createServer((req: IncomingMessage, res: ServerResponse) => {
967
+ // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS open for local browser access
842
968
  // CORS for local access
843
969
  res.setHeader("Access-Control-Allow-Origin", "*");
844
970
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
@@ -876,8 +1002,10 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
876
1002
  // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
877
1003
  // checkpoints, tokens saved, and active model. Read-only.
878
1004
  if (req.url === "/api/index") {
1005
+ const idx = readIndex();
1006
+ if (idx) overlayCurrentRepo(idx);
879
1007
  res.writeHead(200, { "Content-Type": "application/json" });
880
- res.end(JSON.stringify(readIndex() ?? { updatedAt: null, summary: null, repos: [] }));
1008
+ res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
881
1009
  return;
882
1010
  }
883
1011
 
@@ -887,8 +1015,9 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
887
1015
  if (req.url?.startsWith("/api/repos")) {
888
1016
  const url = new URL(req.url, "http://x");
889
1017
  const activeParam = url.searchParams.get("active");
890
- const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
891
- let repos = (idx.repos ?? []) as IndexRepo[];
1018
+ const idx = readIndex();
1019
+ if (idx) overlayCurrentRepo(idx);
1020
+ let repos = idx?.repos ?? [];
892
1021
  if (activeParam) {
893
1022
  const m = /^(\d+)h$/.exec(activeParam);
894
1023
  if (m) {
@@ -897,7 +1026,7 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
897
1026
  }
898
1027
  }
899
1028
  res.writeHead(200, { "Content-Type": "application/json" });
900
- res.end(JSON.stringify({ updatedAt: idx.updatedAt, repos, count: repos.length }));
1029
+ res.end(JSON.stringify({ updatedAt: idx?.updatedAt ?? null, repos, count: repos.length }));
901
1030
  return;
902
1031
  }
903
1032
 
@@ -905,14 +1034,15 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
905
1034
  // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
906
1035
  // count so the dashboard can render the active badge alongside totals.
907
1036
  if (req.url?.startsWith("/api/summary")) {
908
- const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
909
- const repos = (idx.repos ?? []) as IndexRepo[];
1037
+ const idx = readIndex();
1038
+ if (idx) overlayCurrentRepo(idx);
1039
+ const repos = idx?.repos ?? [];
910
1040
  const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
911
1041
  const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
912
1042
  res.writeHead(200, { "Content-Type": "application/json" });
913
1043
  res.end(JSON.stringify({
914
- updatedAt: idx.updatedAt,
915
- summary: idx.summary,
1044
+ updatedAt: idx?.updatedAt ?? null,
1045
+ summary: idx?.summary ?? null,
916
1046
  activeRepos,
917
1047
  totalRepos: repos.length,
918
1048
  }));
@@ -80,6 +80,22 @@ export interface DashboardSnapshot {
80
80
  dedupCollapsed: number; // cumulative deduped collapses (store-wide)
81
81
  storageDedupRate: number; // deduped / attempts, 0..1
82
82
  };
83
+ /**
84
+ * Reconciled token accounting — ONE canonical formula for both session + repo
85
+ * so the dashboard and widget tell the same story:
86
+ * tokensIn = original conversation dropped into compaction (incl. the
87
+ * redundant regions skipped by dedup) — the "in".
88
+ * tokensOut = compact summaries currently held (stored) — the "out".
89
+ * tokensFreed= tokensIn − tokensOut (the "saved").
90
+ * compressionPct = tokensFreed / tokensIn (0..1) — the headline "% saved".
91
+ * dedupPct = storageDedupRate (0..1) — share of adds that collapsed.
92
+ * session.Freed = rt.tokensSaved (honest net freed this session, incl. deduped-away);
93
+ * repo.Freed = repo.tokensSaved meta (honest net freed repo-wide).
94
+ */
95
+ compression: {
96
+ session: { tokensIn: number; tokensOut: number; tokensFreed: number; compressionPct: number; dedupPct: number };
97
+ repo: { tokensIn: number; tokensOut: number; tokensFreed: number; compressionPct: number; dedupPct: number };
98
+ };
83
99
  /** Phase 0 data-safety invariant (trust foundation). */
84
100
  integrity: {
85
101
  regionsRetained: number; // checkpoints with a recoverable compressed-original
@@ -282,6 +282,25 @@ export class MegaRuntime {
282
282
  trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.config.thresholdTokens, fastGatePct: this.config.fastGatePct },
283
283
  crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
284
284
  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 },
285
+ // Reconciled token accounting (single canonical formula, session + repo).
286
+ // Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
287
+ // deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
288
+ compression: {
289
+ session: {
290
+ tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
291
+ tokensOut: st.totalTokenEstimate,
292
+ tokensFreed: this.rt.tokensSaved,
293
+ compressionPct: (this.rt.tokensSaved + st.totalTokenEstimate) > 0 ? this.rt.tokensSaved / (this.rt.tokensSaved + st.totalTokenEstimate) : 0,
294
+ dedupPct: st.storageDedupRate,
295
+ },
296
+ repo: {
297
+ tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
298
+ tokensOut: repo.totalTokenEstimate,
299
+ tokensFreed: repo.tokensSaved,
300
+ compressionPct: (repo.tokensSaved + repo.totalTokenEstimate) > 0 ? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate) : 0,
301
+ dedupPct: repo.storageDedupRate,
302
+ },
303
+ },
285
304
  repo: {
286
305
  checkpointCount: repo.checkpointCount,
287
306
  totalTokenEstimate: repo.totalTokenEstimate,
@@ -319,33 +338,42 @@ export class MegaRuntime {
319
338
  const dedupStr = storageRate * 100 >= 10
320
339
  ? `${Math.round(storageRate * 100)}%`
321
340
  : `${(storageRate * 100).toFixed(1)}%`;
322
- // saved = tokens removed from context (cumulative original stored).
323
- // Show BOTH this-session (rt.tokensSaved) and repo-wide-total
324
- // (repo.tokensSaved) so the user sees per-session progress vs the running
325
- // repo total. "used" = stored checkpoint tokens (repo.totalTokenEstimate
326
- // vs st.totalTokenEstimate). Use "k" only at/above 1000 so small-but-real
327
- // numbers stay visible (previously Math.round(x/1000) zeroed <1000).
328
- const fmt = (x: number) => (x >= 1000 ? `${(x / 1000).toFixed(1)}k` : `${x}`);
329
- const savedStr = `${C.green}${fmt(this.rt.tokensSaved)} sess${C.reset} / ${C.blue}${fmt(repo.tokensSaved)} repo${C.reset}`;
330
- const usedStr = `${C.cyan}${fmt(st.totalTokenEstimate)} sess${C.reset} / ${C.blue}${fmt(repo.totalTokenEstimate)} repo${C.reset}`;
341
+ // Reconciled token accounting ONE canonical formula for session + repo,
342
+ // matching the dashboard so the two never disagree. unit format: M at/above
343
+ // 1e6, k at/above 1e3, raw below so 5,472,700 → "5.5M", 24,100 → "24.1k",
344
+ // 142 "142". Dropped (in) = Freed + Kept; Freed = rt.tokensSaved (session)
345
+ // / repo.tokensSaved meta (repo); Kept = totalTokenEstimate (stored).
346
+ const fmt = (x: number) =>
347
+ x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}M`
348
+ : x >= 1000 ? `${(x / 1000).toFixed(1)}k`
349
+ : `${Math.round(x)}`;
331
350
  const agentStr = this.activeAgents > 0 ? ` │ 🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}` : "";
332
351
  const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
333
352
  // Phase 3 — pulsing status glyph while a compaction is in flight.
334
353
  const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
354
+ // --- reconciled in/out view (session + repo) ---------------------------
355
+ const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
356
+ const sessKept = st.totalTokenEstimate;
357
+ const sessFreed = this.rt.tokensSaved;
358
+ const sessPct = sessIn > 0 ? sessFreed / sessIn : 0;
359
+ const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
360
+ const repoKept = repo.totalTokenEstimate;
361
+ const repoFreed = repo.tokensSaved;
362
+ const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
335
363
  const lines = [
336
364
  ` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
337
- ` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
365
+ ` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset}`,
366
+ ` ${C.gray}dropped ${fmt(sessIn)} → kept ${fmt(sessKept)} sess / ${fmt(repoKept)} repo · freed ${fmt(sessFreed)} sess / ${fmt(repoFreed)} repo${C.reset}`,
338
367
  ];
339
- // Phase 3compact progress bar: session tokens saved toward the rolling goal.
340
- if (this.rt.tokensSaved > 0) {
341
- const goal = Math.max(this.savedGoal, 1);
342
- const pct = Math.min(100, Math.round((this.rt.tokensSaved / goal) * 100));
343
- const filled = Math.round((pct / 100) * 10);
344
- const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
345
- // Session tokens saved, with the repo-wide total held alongside so the
346
- // bar reads "saved X of goal" and the right side shows saved vs total.
347
- const totalHeld = st.totalTokenEstimate > 0 ? st.totalTokenEstimate : repo.totalTokenEstimate;
348
- 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`);
368
+ // Compression meterthe single headline "% tokens saved" (Freed / In),
369
+ // same formula as the dashboard. Higher = better, so it reads green.
370
+ {
371
+ const w = 10;
372
+ const filled = Math.max(0, Math.min(w, Math.round(sessPct * w)));
373
+ const cbar = C.green + "▓".repeat(filled) + C.dim + "░".repeat(w - filled) + C.reset;
374
+ const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
375
+ const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
376
+ lines.push(` ${cbar} ${sTxt}% tokens saved (sess) · ${rTxt}% repo${C.reset}`);
349
377
  }
350
378
  // Live "now processing" line + why + recent deduped/compacted events,
351
379
  // collapsed to ONE rotating line (fresh only). The ticker ring buffer
@@ -366,10 +394,11 @@ export class MegaRuntime {
366
394
  } else if (this.pulsing) {
367
395
  lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
368
396
  }
369
- // Plain-language hint so first-time users understand the widget. Always
370
- // last, dimmed. "/mega-help explains these terms."
397
+ // Token accounting summary, always last + dimmed. Shows the total
398
+ // tokens dropped (in) + kept (out) for this session, then the freed
399
+ // (saved) tokens for both this session and all-time across the repo.
371
400
  if (lines.length < 10) {
372
- lines.push(` ${C.dim}auto-compresses old context to free space · nothing deleted · /mega-help${C.reset}`);
401
+ lines.push(` ${C.dim}session ↑${fmt(sessIn)} in ↓${fmt(sessKept)} out · saved ${fmt(sessFreed)} session / ${fmt(repoFreed)} all-time${C.reset}`);
373
402
  }
374
403
  ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
375
404
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
package/src/compact.ts CHANGED
@@ -36,8 +36,13 @@ export function isChatty(text: string): boolean {
36
36
  return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
37
37
  }
38
38
 
39
- /** Extract plausible file paths (contain '/' + an interesting extension). */
40
- export function extractFileCandidates(content: string): string[] {
39
+ /** Extract plausible file paths (contain '/' + an interesting extension).
40
+ * Defensive against a missing/empty payload: pi's adapter can hand the engine
41
+ * a message whose `text`/`input`/`output` is undefined (e.g. a pure tool-call
42
+ * or tool-result message), and `.split` on undefined throws and takes down the
43
+ * whole compaction. Guard once at the source so every caller is safe. */
44
+ export function extractFileCandidates(content: string | undefined | null): string[] {
45
+ if (!content) return [];
41
46
  const out: string[] = [];
42
47
  for (const raw of content.split(/\s+/)) {
43
48
  // Trim surrounding punctuation only — do NOT strip internal dots, or we
package/src/supersede.ts CHANGED
@@ -12,6 +12,9 @@ import { extractFileCandidates } from "./compact.js";
12
12
 
13
13
  /** Classify a message's relationship to a file path. */
14
14
  function fileOps(msg: EngineMessage): { path: string; op: "read" | "write" }[] {
15
+ // msg.text may be undefined for pure tool-call/result messages; the guard
16
+ // lives in extractFileCandidates, but the early return short-circuits the
17
+ // write-detection regex too so we never classify an empty message.
15
18
  const paths = extractFileCandidates(msg.text);
16
19
  if (paths.length === 0) return [];
17
20
  const low = msg.text.toLowerCase();