pi-mega-compact 0.4.17 → 0.4.19

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,7 +12,72 @@
12
12
  */
13
13
  import { createServer } from "node:http";
14
14
  import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
15
16
  import { join } from "node:path";
17
+ import Database from "better-sqlite3";
18
+ // --- Multi-repo index (Phase 5b) ------------------------------------------------
19
+ // The extension writes a machine-wide repo registry into a single SQLite DB
20
+ // (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
21
+ // reads that table directly (one read-only connection, opened per request so a
22
+ // concurrent writer's WAL never blocks the request). All registry data lives in
23
+ // SQLite (the project's one-store invariant) — there is no JSON mirror. Same
24
+ // index-dir resolution as src/store/sqlite.ts getIndexDir().
25
+ function getIndexDir() {
26
+ const override = process.env.MEGACOMPACT_INDEX_DIR;
27
+ if (override && override.trim() !== "")
28
+ return override;
29
+ try {
30
+ return join(homedir(), ".mega-compact-index");
31
+ }
32
+ catch {
33
+ return join("/tmp", ".mega-compact-index");
34
+ }
35
+ }
36
+ /** Read the machine-wide repo registry from SQLite (read-only, single shot). */
37
+ function readIndex() {
38
+ const indexPath = join(getIndexDir(), "index.sqlite");
39
+ if (!existsSync(indexPath))
40
+ return null;
41
+ let db;
42
+ try {
43
+ // Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
44
+ db = new Database(indexPath, { readonly: true, fileMustExist: true });
45
+ db.pragma("journal_mode = WAL");
46
+ const rows = db
47
+ .prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
48
+ .all();
49
+ const repos = rows.map((r) => ({
50
+ repoRoot: String(r.repo_root ?? ""),
51
+ displayName: String(r.display_name ?? ""),
52
+ checkpointCount: Number(r.checkpoint_count ?? 0),
53
+ tokensSaved: Number(r.tokens_saved ?? 0),
54
+ compressedOriginalBytes: Number(r.compressed_original_bytes ?? 0),
55
+ lastCompactedAt: r.last_compacted_at ?? null,
56
+ provider: r.provider ?? null,
57
+ providerName: r.provider_name ?? null,
58
+ modelName: r.model_name ?? null,
59
+ inputRate: r.input_rate ?? null,
60
+ outputRate: r.output_rate ?? null,
61
+ lastSeen: Number(r.last_seen ?? 0),
62
+ }));
63
+ const summary = {
64
+ totalRepos: repos.length,
65
+ totalCheckpoints: repos.reduce((a, r) => a + r.checkpointCount, 0),
66
+ totalTokensSaved: repos.reduce((a, r) => a + r.tokensSaved, 0),
67
+ totalCompressedOriginalBytes: repos.reduce((a, r) => a + r.compressedOriginalBytes, 0),
68
+ };
69
+ return { updatedAt: new Date().toISOString(), summary, repos };
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ finally {
75
+ try {
76
+ db?.close();
77
+ }
78
+ catch { /* ignore */ }
79
+ }
80
+ }
16
81
  // ---------------------------------------------------------------------------
17
82
  // Helpers
18
83
  // ---------------------------------------------------------------------------
@@ -34,6 +99,7 @@ function readSnapshot(snapshotPath) {
34
99
  crew: { activeAgents: 0, currentTurn: 0 },
35
100
  repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
36
101
  integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
102
+ model: undefined,
37
103
  };
38
104
  }
39
105
  }
@@ -111,14 +177,54 @@ function dashboardHtml(tierName) {
111
177
  .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
112
178
  .empty { color: #484f58; font-style: italic; font-size: 13px; padding: 8px 0; }
113
179
  .offline-banner { background: #f8514922; border: 1px solid #f85149; border-radius: 6px; padding: 10px 16px; margin-bottom: 16px; font-size: 13px; color: #f85149; display: none; }
180
+ .tabs { display: flex; gap: 8px; margin-bottom: 20px; }
181
+ .tab { background: #161b22; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
182
+ .tab:hover { color: #c9d1d9; border-color: #484f58; }
183
+ .tab.active { background: #1f6feb; color: #fff; border-color: #1f6feb; }
184
+ .tab-panel { display: none; }
185
+ .tab-panel.active { display: block; }
186
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
187
+ .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
188
+ .summary-card .num { font-size: 24px; font-weight: 700; color: #f0f6fc; }
189
+ .summary-card .lbl { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
190
+ table.repos { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
191
+ table.repos th, table.repos td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #21262d; }
192
+ table.repos th { color: #8b949e; text-transform: uppercase; letter-spacing: .5px; font-size: 11px; background: #0d1117; }
193
+ table.repos td.num { font-family: monospace; color: #f0f6fc; text-align: right; }
194
+ table.repos tr:last-child td { border-bottom: none; }
195
+ table.repos tr:hover td { background: #1c2128; }
196
+ .repo-model { color: #a371f7; }
197
+ .repo-none { color: #484f58; font-style: italic; }
198
+ .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
199
+ .model-pill { background: #6e40c9; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
200
+ .card.cost h2 { color: #a371f7; }
201
+ .cost-usd { font-size: 22px; font-weight: 700; color: #3fb950; }
202
+ .cost-sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
203
+ .repo-link { cursor: pointer; }
204
+ .repo-link:hover td { color: #58a6ff; }
205
+ .repo-detail { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 50; }
206
+ .repo-detail.open { display: flex; }
207
+ .repo-detail-box { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px; width: 560px; max-width: 92vw; max-height: 86vh; overflow-y: auto; }
208
+ .repo-detail-box h2 { font-size: 14px; color: #f0f6fc; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
209
+ .repo-close { cursor: pointer; color: #8b949e; font-size: 20px; line-height: 1; border: none; background: none; padding: 0 4px; }
210
+ .repo-close:hover { color: #f0f6fc; }
211
+ .repo-path { font-size: 11px; color: #484f58; word-break: break-all; margin: -8px 0 12px; }
114
212
  </style>
115
213
  </head>
116
214
  <body>
117
215
 
118
216
  <div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
119
217
 
120
- <h1><span>mega-compact</span><span class="tier">${tierName}</span></h1>
218
+ <h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="model-pill" id="hdr-model">—</span></h1>
121
219
 
220
+ <nav class="tabs">
221
+ <button class="tab active" data-tab="current">Current repo</button>
222
+ <button class="tab" data-tab="all">All repos</button>
223
+ <button class="tab" data-tab="summary">Summary</button>
224
+ </nav>
225
+
226
+ <!-- Current repo (existing single-repo view) -->
227
+ <div class="tab-panel" id="panel-current">
122
228
  <div class="grid">
123
229
  <div class="card">
124
230
  <h2>Context Window</h2>
@@ -178,6 +284,17 @@ function dashboardHtml(tierName) {
178
284
  <span class="label">Anchor</span><span class="value" id="cf-anchor">—</span>
179
285
  </div>
180
286
  </div>
287
+ <div class="card cost">
288
+ <h2>💰 Model &amp; Cost Savings</h2>
289
+ <div class="cost-usd" id="cost-usd">≈ $0.00 saved</div>
290
+ <div class="cost-sub" id="cost-windows">0 context-windows extended</div>
291
+ <div class="stat-grid" style="margin-top:12px">
292
+ <span class="label" title="The model pi is currently using — its pricing drives the cost figure.">Model</span><span class="value" id="md-name">—</span>
293
+ <span class="label" title="The provider serving the model.">Provider</span><span class="value" id="md-provider">—</span>
294
+ <span class="label" title="USD per input token, from the model's pricing.">Input Rate</span><span class="value" id="md-input">—</span>
295
+ <span class="label" title="USD per output token, from the model's pricing.">Output Rate</span><span class="value" id="md-output">—</span>
296
+ </div>
297
+ </div>
181
298
  <div class="card">
182
299
  <h2>Crew / Agents</h2>
183
300
  <div class="stat-grid">
@@ -205,7 +322,67 @@ function dashboardHtml(tierName) {
205
322
  <div class="events-wrap" id="events"><div class="empty">connecting…</div></div>
206
323
  </div>
207
324
 
325
+ <h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">All Repositories</h2>
326
+ <table class="repos">
327
+ <thead>
328
+ <tr>
329
+ <th>Repo</th><th>Model</th>
330
+ <th style="text-align:right">Checkpoints</th>
331
+ <th style="text-align:right">Tokens Saved</th>
332
+ <th style="text-align:right">Retained</th>
333
+ <th style="text-align:right">Last Compacted</th>
334
+ </tr>
335
+ </thead>
336
+ <tbody id="cur-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
337
+ </table>
338
+ <div class="updated" id="cur-updated"></div>
339
+
208
340
  <div class="updated" id="updated"></div>
341
+ </div><!-- /panel-current -->
342
+
343
+ <!-- Per-repo detail modal -->
344
+ <div class="repo-detail" id="repo-detail">
345
+ <div class="repo-detail-box">
346
+ <h2><span id="rd-name">Repo</span><button class="repo-close" id="rd-close" title="Close">×</button></h2>
347
+ <div class="repo-path" id="rd-path"></div>
348
+ <div class="stat-grid">
349
+ <span class="label">Model</span><span class="value" id="rd-model">—</span>
350
+ <span class="label">Checkpoints</span><span class="value" id="rd-cp">0</span>
351
+ <span class="label">Tokens Saved</span><span class="value" id="rd-saved">0</span>
352
+ <span class="label">Compressed-Original</span><span class="value" id="rd-bytes">0 B</span>
353
+ <span class="label">Last Compacted</span><span class="value" id="rd-when">—</span>
354
+ <span class="label">Provider</span><span class="value" id="rd-provider">—</span>
355
+ </div>
356
+ </div>
357
+ </div>
358
+
359
+ <!-- All repos (machine-wide registry from index.sqlite) -->
360
+ <div class="tab-panel" id="panel-all">
361
+ <table class="repos">
362
+ <thead>
363
+ <tr>
364
+ <th>Repo</th><th>Model</th>
365
+ <th style="text-align:right">Checkpoints</th>
366
+ <th style="text-align:right">Tokens Saved</th>
367
+ <th style="text-align:right">Retained</th>
368
+ <th style="text-align:right">Last Compacted</th>
369
+ </tr>
370
+ </thead>
371
+ <tbody id="all-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
372
+ </table>
373
+ <div class="updated" id="all-updated"></div>
374
+ </div>
375
+
376
+ <!-- Summary (aggregate across all repos) -->
377
+ <div class="tab-panel" id="panel-summary">
378
+ <div class="summary-grid">
379
+ <div class="summary-card"><div class="num" id="sm-repos">0</div><div class="lbl">Repositories</div></div>
380
+ <div class="summary-card"><div class="num" id="sm-checkpoints">0</div><div class="lbl">Total Checkpoints</div></div>
381
+ <div class="summary-card"><div class="num" id="sm-saved">0</div><div class="lbl">Total Tokens Saved</div></div>
382
+ <div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
383
+ </div>
384
+ <div class="updated" id="sm-updated"></div>
385
+ </div>
209
386
 
210
387
  <script>
211
388
  (function() {
@@ -285,6 +462,24 @@ function dashboardHtml(tierName) {
285
462
  document.getElementById('cf-auto').textContent = d.config.auto ? 'enabled' : 'disabled';
286
463
  document.getElementById('cf-anchor').textContent = d.config.anchorUserMessages;
287
464
 
465
+ // --- Active model + cost savings (same calc as /mega-status) ---------------
466
+ var model = d.model;
467
+ document.getElementById('hdr-model').textContent = model && model.name ? model.name : '—';
468
+ document.getElementById('md-name').textContent = model && model.name ? model.name : '—';
469
+ document.getElementById('md-provider').textContent = model && model.providerName ? model.providerName : (model && model.provider ? model.provider : '—');
470
+ document.getElementById('md-input').textContent = model && model.inputRate ? '$' + (model.inputRate).toFixed(6) : '—';
471
+ document.getElementById('md-output').textContent = model && model.outputRate ? '$' + (model.outputRate).toFixed(6) : '—';
472
+ if (model && model.inputRate && repo.tokensSaved > 0) {
473
+ var usd = (repo.tokensSaved * model.inputRate);
474
+ var win = d.context.contextWindow || 0;
475
+ var windows = win > 0 ? (repo.tokensSaved / win).toFixed(1) : '0';
476
+ document.getElementById('cost-usd').textContent = '≈ $' + usd.toFixed(4) + ' saved';
477
+ document.getElementById('cost-windows').textContent = windows + ' context-windows extended';
478
+ } else {
479
+ document.getElementById('cost-usd').textContent = '≈ $0.00 saved';
480
+ document.getElementById('cost-windows').textContent = '0 context-windows extended';
481
+ }
482
+
288
483
  document.getElementById('updated').textContent = 'Updated ' + new Date(d.updatedAt).toLocaleTimeString();
289
484
  }
290
485
 
@@ -331,6 +526,105 @@ function dashboardHtml(tierName) {
331
526
  };
332
527
  }
333
528
  connectSSE();
529
+
530
+ // --- Multi-repo (index.sqlite via /api/index) ---------------------------
531
+ function fmtBytesTop(b) {
532
+ b = b || 0;
533
+ if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MiB';
534
+ if (b >= 1024) return (b / 1024).toFixed(1) + ' KiB';
535
+ return b + ' B';
536
+ }
537
+ function renderIndex(d) {
538
+ d = d || { updatedAt: null, summary: null, repos: [] };
539
+ var repos = d.repos || [];
540
+ var s = d.summary || { totalRepos: 0, totalCheckpoints: 0, totalTokensSaved: 0, totalCompressedOriginalBytes: 0 };
541
+ document.getElementById('sm-repos').textContent = (s.totalRepos || 0).toLocaleString();
542
+ document.getElementById('sm-checkpoints').textContent = (s.totalCheckpoints || 0).toLocaleString();
543
+ document.getElementById('sm-saved').textContent = (s.totalTokensSaved || 0).toLocaleString();
544
+ document.getElementById('sm-bytes').textContent = fmtBytesTop(s.totalCompressedOriginalBytes);
545
+
546
+ // Shared clickable-row renderer for both the in-current table and the
547
+ // All-repos tab — each row opens the per-repo detail modal.
548
+ function rowsHtml() {
549
+ if (!repos.length) return '<tr><td colspan="6" class="repo-none">No repositories registered yet.</td></tr>';
550
+ return repos.map(function(r) {
551
+ var model = r.modelName
552
+ ? '<span class="repo-model">' + sanitize(r.modelName) + '</span>'
553
+ : '<span class="repo-none">—</span>';
554
+ var when = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
555
+ return '<tr class="repo-link" data-repo="' + sanitize(r.repoRoot) + '">' +
556
+ '<td title="' + sanitize(r.repoRoot) + '">' + sanitize(r.displayName || r.repoRoot) + '</td>' +
557
+ '<td>' + model + '</td>' +
558
+ '<td class="num">' + (r.checkpointCount || 0).toLocaleString() + '</td>' +
559
+ '<td class="num">' + (r.tokensSaved || 0).toLocaleString() + '</td>' +
560
+ '<td class="num">' + fmtBytesTop(r.compressedOriginalBytes) + '</td>' +
561
+ '<td class="num">' + sanitize(when) + '</td>' +
562
+ '</tr>';
563
+ }).join('');
564
+ }
565
+ document.getElementById('cur-rows').innerHTML = rowsHtml();
566
+ document.getElementById('all-rows').innerHTML = rowsHtml();
567
+ bindRepoRows();
568
+
569
+ var stamp = d.updatedAt ? 'Updated ' + new Date(d.updatedAt).toLocaleTimeString() : '';
570
+ document.getElementById('cur-updated').textContent = stamp;
571
+ document.getElementById('all-updated').textContent = stamp;
572
+ document.getElementById('sm-updated').textContent = stamp;
573
+ }
574
+
575
+ // Per-repo detail modal ---------------------------------------------------
576
+ var detailEl = document.getElementById('repo-detail');
577
+ var indexCache = { repos: [] };
578
+ function openRepoDetail(root) {
579
+ var r = null;
580
+ for (var i = 0; i < indexCache.repos.length; i++) {
581
+ if (indexCache.repos[i].repoRoot === root) { r = indexCache.repos[i]; break; }
582
+ }
583
+ if (!r) return;
584
+ document.getElementById('rd-name').textContent = r.displayName || r.repoRoot;
585
+ document.getElementById('rd-path').textContent = r.repoRoot;
586
+ document.getElementById('rd-model').textContent = r.modelName || '—';
587
+ document.getElementById('rd-provider').textContent = r.providerName || (r.provider || '—');
588
+ document.getElementById('rd-cp').textContent = (r.checkpointCount || 0).toLocaleString();
589
+ document.getElementById('rd-saved').textContent = (r.tokensSaved || 0).toLocaleString();
590
+ document.getElementById('rd-bytes').textContent = fmtBytesTop(r.compressedOriginalBytes);
591
+ document.getElementById('rd-when').textContent = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
592
+ detailEl.classList.add('open');
593
+ }
594
+ document.getElementById('rd-close').addEventListener('click', function() { detailEl.classList.remove('open'); });
595
+ detailEl.addEventListener('click', function(e) { if (e.target === detailEl) detailEl.classList.remove('open'); });
596
+ function bindRepoRows() {
597
+ var rows = document.querySelectorAll('.repo-link');
598
+ for (var i = 0; i < rows.length; i++) {
599
+ rows[i].addEventListener('click', function() { openRepoDetail(this.getAttribute('data-repo')); });
600
+ }
601
+ }
602
+ function pollIndex() {
603
+ fetch('/api/index').then(function(r) { return r.json(); }).then(function(d) {
604
+ indexCache = d && d.repos ? d : indexCache;
605
+ renderIndex(d);
606
+ }).catch(function() {});
607
+ }
608
+ pollIndex();
609
+ setInterval(pollIndex, 5000);
610
+
611
+ // --- Tab switching ------------------------------------------------------
612
+ var tabs = document.querySelectorAll('.tab');
613
+ var panels = { current: 'panel-current', all: 'panel-all', summary: 'panel-summary' };
614
+ for (var i = 0; i < tabs.length; i++) {
615
+ tabs[i].addEventListener('click', function() {
616
+ var name = this.getAttribute('data-tab');
617
+ for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active');
618
+ this.classList.add('active');
619
+ for (var k in panels) {
620
+ if (Object.prototype.hasOwnProperty.call(panels, k)) {
621
+ var el = document.getElementById(panels[k]);
622
+ if (el) el.classList.toggle('active', k === name);
623
+ }
624
+ }
625
+ if (name === 'all' || name === 'summary') pollIndex();
626
+ });
627
+ }
334
628
  })();
335
629
  </script>
336
630
  </body>
@@ -380,6 +674,14 @@ export function launchDashboardServer(stateDir) {
380
674
  res.end(JSON.stringify(snap));
381
675
  return;
382
676
  }
677
+ // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
678
+ // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
679
+ // checkpoints, tokens saved, and active model. Read-only.
680
+ if (req.url === "/api/index") {
681
+ res.writeHead(200, { "Content-Type": "application/json" });
682
+ res.end(JSON.stringify(readIndex() ?? { updatedAt: null, summary: null, repos: [] }));
683
+ return;
684
+ }
383
685
  if (req.url === "/api/events") {
384
686
  res.writeHead(200, {
385
687
  "Content-Type": "text/event-stream",
@@ -14,7 +14,7 @@ import { VectorStore } from "../src/vectorStore.js";
14
14
  import { toEngineMessages } from "../src/adapt.js";
15
15
  import { normalizeSessionId } from "../src/store.js";
16
16
  import { Logger } from "../src/log.js";
17
- import { recordModelSnapshot } from "../src/store/sqlite.js";
17
+ import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel } from "../src/store/sqlite.js";
18
18
  import { repoStateDir, resolveRepoRoot } from "./mega-config.js";
19
19
  import { Dashboard } from "./mega-dashboard.js";
20
20
  export const STATUS_KEY = "mega-compact";
@@ -117,6 +117,27 @@ export class MegaRuntime {
117
117
  this.store = new VectorStore({ dedupSim: this.config.dedupSim, stateDir: dir });
118
118
  this.logger = new Logger({ enabled: this.config.debug, path: join(dir, "mega-compact.log") });
119
119
  this.dashboard = new Dashboard(dir);
120
+ // Aggregate this repo into the machine-wide index so the multi-repo
121
+ // dashboard (Summary / All-repos tabs) can show it alongside every other
122
+ // repo. Best-effort + non-fatal: a read-only index dir or contention must
123
+ // never break the per-repo compaction path. Runs only on repo-switch
124
+ // (this branch), so it's infrequent — not per-context-event.
125
+ try {
126
+ const repo = this.store.repoStats();
127
+ const di = this.store.dataInvariant();
128
+ const root = key !== dir ? key : resolveRepoRoot(cwd ?? dir) ?? dir;
129
+ upsertRepoRegistry({
130
+ repoRoot: root,
131
+ displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
132
+ stateDir: dir,
133
+ checkpointCount: repo.checkpointCount,
134
+ tokensSaved: repo.tokensSaved,
135
+ compressedOriginalBytes: di.compressedOriginalBytes,
136
+ });
137
+ }
138
+ catch {
139
+ /* non-fatal: index aggregation must not block compaction */
140
+ }
120
141
  return dir;
121
142
  }
122
143
  // ---- dashboard snapshot + widget ------------------------------------------
@@ -127,6 +148,17 @@ export class MegaRuntime {
127
148
  const st = this.store.stats(this.rt.sessionId);
128
149
  const repo = this.store.repoStats();
129
150
  const di = this.store.dataInvariant();
151
+ // Active model/provider for the current-repo card + the multi-repo table.
152
+ const modelSnap = latestModelSnapshot(this.currentStateDir);
153
+ const model = modelSnap
154
+ ? {
155
+ name: modelSnap.modelName ?? modelSnap.modelId,
156
+ provider: modelSnap.provider,
157
+ providerName: modelSnap.providerName ?? "",
158
+ inputRate: modelSnap.inputRate,
159
+ outputRate: modelSnap.outputRate,
160
+ }
161
+ : undefined;
130
162
  const armed = this.lastCtxPercent != null && this.lastCtxPercent >= this.config.fastGatePct;
131
163
  const ready = armed && (this.lastCtxTokens ?? 0) >= this.config.thresholdTokens;
132
164
  this.dashboard.snapshot({
@@ -171,6 +203,7 @@ export class MegaRuntime {
171
203
  duplicatesCollapsed: di.duplicatesCollapsed,
172
204
  bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
173
205
  },
206
+ model,
174
207
  });
175
208
  // Live stats widget above the editor
176
209
  if (ctx) {
@@ -301,6 +334,18 @@ export class MegaRuntime {
301
334
  try {
302
335
  const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
303
336
  recordModelSnapshot(repo, snap, this.currentStateDir);
337
+ // Denormalize the active model into the machine-wide index so the
338
+ // All-repos dashboard table can show provider/model per repo without
339
+ // opening every repo's DB. Best-effort + non-fatal.
340
+ recordRepoModel(repo, {
341
+ provider: snap.provider,
342
+ providerName: snap.providerName,
343
+ modelName: snap.modelName,
344
+ inputRate: snap.inputRate,
345
+ outputRate: snap.outputRate,
346
+ stateDir: this.currentStateDir,
347
+ displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
348
+ });
304
349
  }
305
350
  catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
306
351
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import Database from "better-sqlite3";
18
18
  import { existsSync, mkdirSync } from "node:fs";
19
+ import { homedir, tmpdir } from "node:os";
19
20
  import { join } from "node:path";
20
21
  import { getStateDir } from "../store.js";
21
22
  import { normalizeSessionId } from "../store.js";
@@ -58,6 +59,167 @@ export function openStore(stateDir = getStateDir()) {
58
59
  cache.set(stateDir, db);
59
60
  return db;
60
61
  }
62
+ // ---------------------------------------------------------------------------
63
+ // Global machine-wide index (Phase 5b): a single SQLite DB, separate from every
64
+ // per-repo store, that aggregates one row per repo this machine has run on. The
65
+ // multi-repo dashboard (Summary / All-repos tabs) reads it so ONE dashboard can
66
+ // show every repo's checkpoints, tokens saved, and active model — instead of a
67
+ // per-repo dashboard that only ever sees the repo it was launched from.
68
+ //
69
+ // Written by every pi process on repo-switch (bindRepo) + model capture; read by
70
+ // the dashboard server. Concurrency across 10+ pi processes is handled by WAL +
71
+ // infrequent idempotent upserts (ON CONFLICT). Fully local (PREVENT-PI-004).
72
+ // ---------------------------------------------------------------------------
73
+ /** Resolve the machine-wide index directory (env-overridable). */
74
+ export function getIndexDir() {
75
+ const override = process.env.MEGACOMPACT_INDEX_DIR;
76
+ if (override && override.trim() !== "")
77
+ return override;
78
+ // homedir() can throw in exotic sandboxes; fall back to tmpdir.
79
+ try {
80
+ return join(homedir(), ".mega-compact-index");
81
+ }
82
+ catch {
83
+ return join(tmpdir(), ".mega-compact-index");
84
+ }
85
+ }
86
+ let indexCache;
87
+ let indexCacheDir;
88
+ /** Open (or reuse) the machine-wide index DB. WAL for concurrent writers. */
89
+ export function openIndexStore(indexDir = getIndexDir()) {
90
+ if (indexCache && indexCacheDir === indexDir)
91
+ return indexCache;
92
+ if (!existsSync(indexDir))
93
+ mkdirSync(indexDir, { recursive: true });
94
+ const db = new Database(join(indexDir, "index.sqlite"));
95
+ db.pragma("journal_mode = WAL");
96
+ db.pragma("busy_timeout = 3000"); // tolerate brief cross-process write contention
97
+ db.exec(`
98
+ CREATE TABLE IF NOT EXISTS repo_registry (
99
+ repo_root TEXT PRIMARY KEY,
100
+ display_name TEXT,
101
+ state_dir TEXT NOT NULL,
102
+ first_seen INTEGER,
103
+ last_seen INTEGER,
104
+ last_compacted_at INTEGER,
105
+ checkpoint_count INTEGER DEFAULT 0,
106
+ tokens_saved INTEGER DEFAULT 0,
107
+ compressed_original_bytes INTEGER DEFAULT 0,
108
+ provider TEXT,
109
+ provider_name TEXT,
110
+ model_name TEXT,
111
+ input_rate REAL,
112
+ output_rate REAL,
113
+ model_captured_at INTEGER
114
+ );
115
+ CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
116
+ `);
117
+ indexCache = db;
118
+ indexCacheDir = indexDir;
119
+ return db;
120
+ }
121
+ /**
122
+ * Upsert a repo's aggregate stats into the global index. Called on repo-switch
123
+ * (infrequent). Preserves first_seen + the model columns on update (model is
124
+ * written separately by recordRepoModel so we never clobber it here with nulls).
125
+ */
126
+ export function upsertRepoRegistry(row, indexDir = getIndexDir()) {
127
+ const db = openIndexStore(indexDir);
128
+ const now = Date.now();
129
+ db.prepare(`INSERT INTO repo_registry
130
+ (repo_root, display_name, state_dir, first_seen, last_seen, last_compacted_at,
131
+ checkpoint_count, tokens_saved, compressed_original_bytes)
132
+ VALUES (@repo_root, @display_name, @state_dir, @now, @now, @last_compacted_at,
133
+ @checkpoint_count, @tokens_saved, @compressed_original_bytes)
134
+ ON CONFLICT(repo_root) DO UPDATE SET
135
+ display_name = excluded.display_name,
136
+ state_dir = excluded.state_dir,
137
+ last_seen = excluded.last_seen,
138
+ last_compacted_at = COALESCE(excluded.last_compacted_at, repo_registry.last_compacted_at),
139
+ checkpoint_count = excluded.checkpoint_count,
140
+ tokens_saved = excluded.tokens_saved,
141
+ compressed_original_bytes = excluded.compressed_original_bytes`).run({
142
+ repo_root: row.repoRoot,
143
+ display_name: row.displayName,
144
+ state_dir: row.stateDir,
145
+ now,
146
+ last_compacted_at: row.lastCompactedAt ?? null,
147
+ checkpoint_count: row.checkpointCount,
148
+ tokens_saved: row.tokensSaved,
149
+ compressed_original_bytes: row.compressedOriginalBytes,
150
+ });
151
+ }
152
+ /**
153
+ * Record the active model/provider for a repo in the global index (denormalized
154
+ * so the All-repos table shows model without opening each repo's DB). Upserts a
155
+ * bare registry row if the repo isn't registered yet.
156
+ */
157
+ export function recordRepoModel(repoRoot, model, indexDir = getIndexDir()) {
158
+ const db = openIndexStore(indexDir);
159
+ const now = Date.now();
160
+ db.prepare(`INSERT INTO repo_registry
161
+ (repo_root, display_name, state_dir, first_seen, last_seen,
162
+ provider, provider_name, model_name, input_rate, output_rate, model_captured_at)
163
+ VALUES (@repo_root, @display_name, @state_dir, @now, @now,
164
+ @provider, @provider_name, @model_name, @input_rate, @output_rate, @now)
165
+ ON CONFLICT(repo_root) DO UPDATE SET
166
+ last_seen = excluded.last_seen,
167
+ provider = excluded.provider,
168
+ provider_name = excluded.provider_name,
169
+ model_name = excluded.model_name,
170
+ input_rate = excluded.input_rate,
171
+ output_rate = excluded.output_rate,
172
+ model_captured_at = excluded.model_captured_at`).run({
173
+ repo_root: repoRoot,
174
+ display_name: model.displayName,
175
+ state_dir: model.stateDir,
176
+ now,
177
+ provider: model.provider,
178
+ provider_name: model.providerName,
179
+ model_name: model.modelName,
180
+ input_rate: model.inputRate,
181
+ output_rate: model.outputRate,
182
+ });
183
+ }
184
+ function mapRegistryRow(row) {
185
+ return {
186
+ repoRoot: row.repo_root,
187
+ displayName: row.display_name ?? "",
188
+ stateDir: row.state_dir,
189
+ firstSeen: row.first_seen ?? 0,
190
+ lastSeen: row.last_seen ?? 0,
191
+ lastCompactedAt: row.last_compacted_at ?? null,
192
+ checkpointCount: row.checkpoint_count ?? 0,
193
+ tokensSaved: row.tokens_saved ?? 0,
194
+ compressedOriginalBytes: row.compressed_original_bytes ?? 0,
195
+ provider: row.provider ?? null,
196
+ providerName: row.provider_name ?? null,
197
+ modelName: row.model_name ?? null,
198
+ inputRate: row.input_rate ?? null,
199
+ outputRate: row.output_rate ?? null,
200
+ modelCapturedAt: row.model_captured_at ?? null,
201
+ };
202
+ }
203
+ /** All registered repos, most-recently-seen first. */
204
+ export function listRepoRegistry(indexDir = getIndexDir()) {
205
+ const db = openIndexStore(indexDir);
206
+ const rows = db.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC").all();
207
+ return rows.map(mapRegistryRow);
208
+ }
209
+ /** A single repo's registry row, or undefined. */
210
+ export function getRepoRegistry(repoRoot, indexDir = getIndexDir()) {
211
+ const db = openIndexStore(indexDir);
212
+ const row = db.prepare("SELECT * FROM repo_registry WHERE repo_root = ?").get(repoRoot);
213
+ return row ? mapRegistryRow(row) : undefined;
214
+ }
215
+ /** Close the cached index connection (test teardown only). */
216
+ export function closeIndexStore() {
217
+ if (indexCache) {
218
+ indexCache.close();
219
+ indexCache = undefined;
220
+ indexCacheDir = undefined;
221
+ }
222
+ }
61
223
  function initSchema(db) {
62
224
  db.exec(`
63
225
  CREATE TABLE IF NOT EXISTS context_chunks (
@@ -2,7 +2,8 @@
2
2
 
3
3
  A lightweight local web dashboard for monitoring mega-compact's live state — compactions, context usage, checkpoints, and recall hits.
4
4
 
5
- Zero npm dependencies. Uses only Node built-in modules (`http`, `fs`, `path`).
5
+ Uses Node built-in modules (`http`, `fs`, `path`) plus `better-sqlite3` (the
6
+ project's one-store DB backend) to read the machine-wide multi-repo index.
6
7
 
7
8
  ## Quick Start
8
9
 
@@ -72,8 +73,8 @@ All files are written to the extension's state directory
72
73
  The server runs as a detached child process, independent of the pi session. It:
73
74
  - Auto-discovers the state directory from the `port.pid` file
74
75
  - Cleans up stale `port.pid` files from dead processes
75
- - Supports `SIGHUP` for graceful shutdown
76
- - Serves static HTML with no external dependencies
76
+ - Supports `SIGTERM`/`SIGINT` for graceful shutdown
77
+ - Serves static HTML; reads the multi-repo index from SQLite (`better-sqlite3`)
77
78
 
78
79
  ## Browser UI
79
80
 
@@ -147,11 +148,23 @@ The extension tracks active sub-agents in real-time:
147
148
  - The server only listens on `127.0.0.1` (localhost)
148
149
  - No authentication (local-only, not exposed to network)
149
150
  - No write endpoints — all APIs are read-only
150
- - No npm dependencies — only Node.js built-ins
151
+
152
+ ## Multi-Repo Index (Phase 5b)
153
+
154
+ The dashboard shows every repo that has run mega-compact on this machine, not
155
+ just the one it was launched from. The extension writes a machine-wide
156
+ `repo_registry` into a single SQLite DB (`<indexDir>/index.sqlite`, where
157
+ `indexDir` is `$MEGACOMPACT_INDEX_DIR` or `~/.mega-compact-index`), one row per
158
+ repo with checkpoint count, tokens saved, compressed-original bytes, and the
159
+ active model/provider (denormalized from `model_snapshots`). The dashboard
160
+ server opens that table read-only (`GET /api/index`) and renders the **All
161
+ repos** (per-repo table) and **Summary** (machine-wide aggregate) tabs. All
162
+ registry data lives in SQLite — there is no JSON mirror; the "one store"
163
+ invariant is preserved end-to-end.
151
164
 
152
165
  ## Troubleshooting
153
166
 
154
- **Port already in use**: The server picks a random port in 30003999. If all are taken, it will retry. Check `/dashboard-status` for the current port.
167
+ **Port already in use**: The server picks a port in 93209329. If all are taken, it will retry. Check `/dashboard-status` for the current port.
155
168
 
156
169
  **Server won't start**: Check for stale `port.pid` files. Run `/dashboard-stop` to clean up, then try `/dashboard` again.
157
170