pi-mega-compact 0.4.17 → 0.4.18

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
  // ---------------------------------------------------------------------------
@@ -111,6 +176,25 @@ function dashboardHtml(tierName) {
111
176
  .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
112
177
  .empty { color: #484f58; font-style: italic; font-size: 13px; padding: 8px 0; }
113
178
  .offline-banner { background: #f8514922; border: 1px solid #f85149; border-radius: 6px; padding: 10px 16px; margin-bottom: 16px; font-size: 13px; color: #f85149; display: none; }
179
+ .tabs { display: flex; gap: 8px; margin-bottom: 20px; }
180
+ .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; }
181
+ .tab:hover { color: #c9d1d9; border-color: #484f58; }
182
+ .tab.active { background: #1f6feb; color: #fff; border-color: #1f6feb; }
183
+ .tab-panel { display: none; }
184
+ .tab-panel.active { display: block; }
185
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
186
+ .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
187
+ .summary-card .num { font-size: 24px; font-weight: 700; color: #f0f6fc; }
188
+ .summary-card .lbl { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
189
+ table.repos { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
190
+ table.repos th, table.repos td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #21262d; }
191
+ table.repos th { color: #8b949e; text-transform: uppercase; letter-spacing: .5px; font-size: 11px; background: #0d1117; }
192
+ table.repos td.num { font-family: monospace; color: #f0f6fc; text-align: right; }
193
+ table.repos tr:last-child td { border-bottom: none; }
194
+ table.repos tr:hover td { background: #1c2128; }
195
+ .repo-model { color: #a371f7; }
196
+ .repo-none { color: #484f58; font-style: italic; }
197
+ .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
114
198
  </style>
115
199
  </head>
116
200
  <body>
@@ -119,6 +203,14 @@ function dashboardHtml(tierName) {
119
203
 
120
204
  <h1><span>mega-compact</span><span class="tier">${tierName}</span></h1>
121
205
 
206
+ <nav class="tabs">
207
+ <button class="tab active" data-tab="current">Current repo</button>
208
+ <button class="tab" data-tab="all">All repos</button>
209
+ <button class="tab" data-tab="summary">Summary</button>
210
+ </nav>
211
+
212
+ <!-- Current repo (existing single-repo view) -->
213
+ <div class="tab-panel" id="panel-current">
122
214
  <div class="grid">
123
215
  <div class="card">
124
216
  <h2>Context Window</h2>
@@ -206,6 +298,35 @@ function dashboardHtml(tierName) {
206
298
  </div>
207
299
 
208
300
  <div class="updated" id="updated"></div>
301
+ </div><!-- /panel-current -->
302
+
303
+ <!-- All repos (machine-wide registry from index.sqlite) -->
304
+ <div class="tab-panel" id="panel-all">
305
+ <table class="repos">
306
+ <thead>
307
+ <tr>
308
+ <th>Repo</th><th>Model</th>
309
+ <th style="text-align:right">Checkpoints</th>
310
+ <th style="text-align:right">Tokens Saved</th>
311
+ <th style="text-align:right">Retained</th>
312
+ <th style="text-align:right">Last Compacted</th>
313
+ </tr>
314
+ </thead>
315
+ <tbody id="all-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
316
+ </table>
317
+ <div class="updated" id="all-updated"></div>
318
+ </div>
319
+
320
+ <!-- Summary (aggregate across all repos) -->
321
+ <div class="tab-panel" id="panel-summary">
322
+ <div class="summary-grid">
323
+ <div class="summary-card"><div class="num" id="sm-repos">0</div><div class="lbl">Repositories</div></div>
324
+ <div class="summary-card"><div class="num" id="sm-checkpoints">0</div><div class="lbl">Total Checkpoints</div></div>
325
+ <div class="summary-card"><div class="num" id="sm-saved">0</div><div class="lbl">Total Tokens Saved</div></div>
326
+ <div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
327
+ </div>
328
+ <div class="updated" id="sm-updated"></div>
329
+ </div>
209
330
 
210
331
  <script>
211
332
  (function() {
@@ -331,6 +452,69 @@ function dashboardHtml(tierName) {
331
452
  };
332
453
  }
333
454
  connectSSE();
455
+
456
+ // --- Multi-repo (index.sqlite via /api/index) ---------------------------
457
+ function fmtBytesTop(b) {
458
+ b = b || 0;
459
+ if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MiB';
460
+ if (b >= 1024) return (b / 1024).toFixed(1) + ' KiB';
461
+ return b + ' B';
462
+ }
463
+ function renderIndex(d) {
464
+ d = d || { updatedAt: null, summary: null, repos: [] };
465
+ var repos = d.repos || [];
466
+ var s = d.summary || { totalRepos: 0, totalCheckpoints: 0, totalTokensSaved: 0, totalCompressedOriginalBytes: 0 };
467
+ document.getElementById('sm-repos').textContent = (s.totalRepos || 0).toLocaleString();
468
+ document.getElementById('sm-checkpoints').textContent = (s.totalCheckpoints || 0).toLocaleString();
469
+ document.getElementById('sm-saved').textContent = (s.totalTokensSaved || 0).toLocaleString();
470
+ document.getElementById('sm-bytes').textContent = fmtBytesTop(s.totalCompressedOriginalBytes);
471
+
472
+ var body = document.getElementById('all-rows');
473
+ if (!repos.length) {
474
+ body.innerHTML = '<tr><td colspan="6" class="repo-none">No repositories registered yet.</td></tr>';
475
+ } else {
476
+ body.innerHTML = repos.map(function(r) {
477
+ var model = r.modelName
478
+ ? '<span class="repo-model">' + sanitize(r.modelName) + '</span>'
479
+ : '<span class="repo-none">—</span>';
480
+ var when = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
481
+ return '<tr>' +
482
+ '<td title="' + sanitize(r.repoRoot) + '">' + sanitize(r.displayName || r.repoRoot) + '</td>' +
483
+ '<td>' + model + '</td>' +
484
+ '<td class="num">' + (r.checkpointCount || 0).toLocaleString() + '</td>' +
485
+ '<td class="num">' + (r.tokensSaved || 0).toLocaleString() + '</td>' +
486
+ '<td class="num">' + fmtBytesTop(r.compressedOriginalBytes) + '</td>' +
487
+ '<td class="num">' + sanitize(when) + '</td>' +
488
+ '</tr>';
489
+ }).join('');
490
+ }
491
+ var stamp = d.updatedAt ? 'Updated ' + new Date(d.updatedAt).toLocaleTimeString() : '';
492
+ document.getElementById('all-updated').textContent = stamp;
493
+ document.getElementById('sm-updated').textContent = stamp;
494
+ }
495
+ function pollIndex() {
496
+ fetch('/api/index').then(function(r) { return r.json(); }).then(renderIndex).catch(function() {});
497
+ }
498
+ pollIndex();
499
+ setInterval(pollIndex, 5000);
500
+
501
+ // --- Tab switching ------------------------------------------------------
502
+ var tabs = document.querySelectorAll('.tab');
503
+ var panels = { current: 'panel-current', all: 'panel-all', summary: 'panel-summary' };
504
+ for (var i = 0; i < tabs.length; i++) {
505
+ tabs[i].addEventListener('click', function() {
506
+ var name = this.getAttribute('data-tab');
507
+ for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active');
508
+ this.classList.add('active');
509
+ for (var k in panels) {
510
+ if (Object.prototype.hasOwnProperty.call(panels, k)) {
511
+ var el = document.getElementById(panels[k]);
512
+ if (el) el.classList.toggle('active', k === name);
513
+ }
514
+ }
515
+ if (name === 'all' || name === 'summary') pollIndex();
516
+ });
517
+ }
334
518
  })();
335
519
  </script>
336
520
  </body>
@@ -380,6 +564,14 @@ export function launchDashboardServer(stateDir) {
380
564
  res.end(JSON.stringify(snap));
381
565
  return;
382
566
  }
567
+ // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
568
+ // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
569
+ // checkpoints, tokens saved, and active model. Read-only.
570
+ if (req.url === "/api/index") {
571
+ res.writeHead(200, { "Content-Type": "application/json" });
572
+ res.end(JSON.stringify(readIndex() ?? { updatedAt: null, summary: null, repos: [] }));
573
+ return;
574
+ }
383
575
  if (req.url === "/api/events") {
384
576
  res.writeHead(200, {
385
577
  "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
 
@@ -13,7 +13,81 @@
13
13
 
14
14
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
15
  import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
16
17
  import { join } from "node:path";
18
+ import Database from "better-sqlite3";
19
+
20
+ // --- Multi-repo index (Phase 5b) ------------------------------------------------
21
+ // The extension writes a machine-wide repo registry into a single SQLite DB
22
+ // (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
23
+ // reads that table directly (one read-only connection, opened per request so a
24
+ // concurrent writer's WAL never blocks the request). All registry data lives in
25
+ // SQLite (the project's one-store invariant) — there is no JSON mirror. Same
26
+ // index-dir resolution as src/store/sqlite.ts getIndexDir().
27
+ function getIndexDir(): string {
28
+ const override = process.env.MEGACOMPACT_INDEX_DIR;
29
+ if (override && override.trim() !== "") return override;
30
+ try {
31
+ return join(homedir(), ".mega-compact-index");
32
+ } catch {
33
+ return join("/tmp", ".mega-compact-index");
34
+ }
35
+ }
36
+
37
+ interface IndexRepo {
38
+ repoRoot: string;
39
+ displayName: string;
40
+ checkpointCount: number;
41
+ tokensSaved: number;
42
+ compressedOriginalBytes: number;
43
+ lastCompactedAt: number | null;
44
+ provider: string | null;
45
+ providerName: string | null;
46
+ modelName: string | null;
47
+ inputRate: number | null;
48
+ outputRate: number | null;
49
+ lastSeen: number;
50
+ }
51
+
52
+ /** Read the machine-wide repo registry from SQLite (read-only, single shot). */
53
+ function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] } | null {
54
+ const indexPath = join(getIndexDir(), "index.sqlite");
55
+ if (!existsSync(indexPath)) return null;
56
+ let db: Database.Database | undefined;
57
+ try {
58
+ // Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
59
+ db = new Database(indexPath, { readonly: true, fileMustExist: true });
60
+ db.pragma("journal_mode = WAL");
61
+ const rows = db
62
+ .prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
63
+ .all() as Record<string, unknown>[];
64
+ const repos: IndexRepo[] = rows.map((r) => ({
65
+ repoRoot: String(r.repo_root ?? ""),
66
+ displayName: String(r.display_name ?? ""),
67
+ checkpointCount: Number(r.checkpoint_count ?? 0),
68
+ tokensSaved: Number(r.tokens_saved ?? 0),
69
+ compressedOriginalBytes: Number(r.compressed_original_bytes ?? 0),
70
+ lastCompactedAt: (r.last_compacted_at as number | null) ?? null,
71
+ provider: (r.provider as string | null) ?? null,
72
+ providerName: (r.provider_name as string | null) ?? null,
73
+ modelName: (r.model_name as string | null) ?? null,
74
+ inputRate: (r.input_rate as number | null) ?? null,
75
+ outputRate: (r.output_rate as number | null) ?? null,
76
+ lastSeen: Number(r.last_seen ?? 0),
77
+ }));
78
+ const summary = {
79
+ totalRepos: repos.length,
80
+ totalCheckpoints: repos.reduce((a, r) => a + r.checkpointCount, 0),
81
+ totalTokensSaved: repos.reduce((a, r) => a + r.tokensSaved, 0),
82
+ totalCompressedOriginalBytes: repos.reduce((a, r) => a + r.compressedOriginalBytes, 0),
83
+ };
84
+ return { updatedAt: new Date().toISOString(), summary, repos };
85
+ } catch {
86
+ return null;
87
+ } finally {
88
+ try { db?.close(); } catch { /* ignore */ }
89
+ }
90
+ }
17
91
 
18
92
  // ---------------------------------------------------------------------------
19
93
  // Types
@@ -175,6 +249,25 @@ function dashboardHtml(tierName: string): string {
175
249
  .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
176
250
  .empty { color: #484f58; font-style: italic; font-size: 13px; padding: 8px 0; }
177
251
  .offline-banner { background: #f8514922; border: 1px solid #f85149; border-radius: 6px; padding: 10px 16px; margin-bottom: 16px; font-size: 13px; color: #f85149; display: none; }
252
+ .tabs { display: flex; gap: 8px; margin-bottom: 20px; }
253
+ .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; }
254
+ .tab:hover { color: #c9d1d9; border-color: #484f58; }
255
+ .tab.active { background: #1f6feb; color: #fff; border-color: #1f6feb; }
256
+ .tab-panel { display: none; }
257
+ .tab-panel.active { display: block; }
258
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
259
+ .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
260
+ .summary-card .num { font-size: 24px; font-weight: 700; color: #f0f6fc; }
261
+ .summary-card .lbl { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
262
+ table.repos { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
263
+ table.repos th, table.repos td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #21262d; }
264
+ table.repos th { color: #8b949e; text-transform: uppercase; letter-spacing: .5px; font-size: 11px; background: #0d1117; }
265
+ table.repos td.num { font-family: monospace; color: #f0f6fc; text-align: right; }
266
+ table.repos tr:last-child td { border-bottom: none; }
267
+ table.repos tr:hover td { background: #1c2128; }
268
+ .repo-model { color: #a371f7; }
269
+ .repo-none { color: #484f58; font-style: italic; }
270
+ .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
178
271
  </style>
179
272
  </head>
180
273
  <body>
@@ -183,6 +276,14 @@ function dashboardHtml(tierName: string): string {
183
276
 
184
277
  <h1><span>mega-compact</span><span class="tier">${tierName}</span></h1>
185
278
 
279
+ <nav class="tabs">
280
+ <button class="tab active" data-tab="current">Current repo</button>
281
+ <button class="tab" data-tab="all">All repos</button>
282
+ <button class="tab" data-tab="summary">Summary</button>
283
+ </nav>
284
+
285
+ <!-- Current repo (existing single-repo view) -->
286
+ <div class="tab-panel" id="panel-current">
186
287
  <div class="grid">
187
288
  <div class="card">
188
289
  <h2>Context Window</h2>
@@ -270,6 +371,35 @@ function dashboardHtml(tierName: string): string {
270
371
  </div>
271
372
 
272
373
  <div class="updated" id="updated"></div>
374
+ </div><!-- /panel-current -->
375
+
376
+ <!-- All repos (machine-wide registry from index.sqlite) -->
377
+ <div class="tab-panel" id="panel-all">
378
+ <table class="repos">
379
+ <thead>
380
+ <tr>
381
+ <th>Repo</th><th>Model</th>
382
+ <th style="text-align:right">Checkpoints</th>
383
+ <th style="text-align:right">Tokens Saved</th>
384
+ <th style="text-align:right">Retained</th>
385
+ <th style="text-align:right">Last Compacted</th>
386
+ </tr>
387
+ </thead>
388
+ <tbody id="all-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
389
+ </table>
390
+ <div class="updated" id="all-updated"></div>
391
+ </div>
392
+
393
+ <!-- Summary (aggregate across all repos) -->
394
+ <div class="tab-panel" id="panel-summary">
395
+ <div class="summary-grid">
396
+ <div class="summary-card"><div class="num" id="sm-repos">0</div><div class="lbl">Repositories</div></div>
397
+ <div class="summary-card"><div class="num" id="sm-checkpoints">0</div><div class="lbl">Total Checkpoints</div></div>
398
+ <div class="summary-card"><div class="num" id="sm-saved">0</div><div class="lbl">Total Tokens Saved</div></div>
399
+ <div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
400
+ </div>
401
+ <div class="updated" id="sm-updated"></div>
402
+ </div>
273
403
 
274
404
  <script>
275
405
  (function() {
@@ -395,6 +525,69 @@ function dashboardHtml(tierName: string): string {
395
525
  };
396
526
  }
397
527
  connectSSE();
528
+
529
+ // --- Multi-repo (index.sqlite via /api/index) ---------------------------
530
+ function fmtBytesTop(b) {
531
+ b = b || 0;
532
+ if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MiB';
533
+ if (b >= 1024) return (b / 1024).toFixed(1) + ' KiB';
534
+ return b + ' B';
535
+ }
536
+ function renderIndex(d) {
537
+ d = d || { updatedAt: null, summary: null, repos: [] };
538
+ var repos = d.repos || [];
539
+ var s = d.summary || { totalRepos: 0, totalCheckpoints: 0, totalTokensSaved: 0, totalCompressedOriginalBytes: 0 };
540
+ document.getElementById('sm-repos').textContent = (s.totalRepos || 0).toLocaleString();
541
+ document.getElementById('sm-checkpoints').textContent = (s.totalCheckpoints || 0).toLocaleString();
542
+ document.getElementById('sm-saved').textContent = (s.totalTokensSaved || 0).toLocaleString();
543
+ document.getElementById('sm-bytes').textContent = fmtBytesTop(s.totalCompressedOriginalBytes);
544
+
545
+ var body = document.getElementById('all-rows');
546
+ if (!repos.length) {
547
+ body.innerHTML = '<tr><td colspan="6" class="repo-none">No repositories registered yet.</td></tr>';
548
+ } else {
549
+ body.innerHTML = repos.map(function(r) {
550
+ var model = r.modelName
551
+ ? '<span class="repo-model">' + sanitize(r.modelName) + '</span>'
552
+ : '<span class="repo-none">—</span>';
553
+ var when = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
554
+ return '<tr>' +
555
+ '<td title="' + sanitize(r.repoRoot) + '">' + sanitize(r.displayName || r.repoRoot) + '</td>' +
556
+ '<td>' + model + '</td>' +
557
+ '<td class="num">' + (r.checkpointCount || 0).toLocaleString() + '</td>' +
558
+ '<td class="num">' + (r.tokensSaved || 0).toLocaleString() + '</td>' +
559
+ '<td class="num">' + fmtBytesTop(r.compressedOriginalBytes) + '</td>' +
560
+ '<td class="num">' + sanitize(when) + '</td>' +
561
+ '</tr>';
562
+ }).join('');
563
+ }
564
+ var stamp = d.updatedAt ? 'Updated ' + new Date(d.updatedAt).toLocaleTimeString() : '';
565
+ document.getElementById('all-updated').textContent = stamp;
566
+ document.getElementById('sm-updated').textContent = stamp;
567
+ }
568
+ function pollIndex() {
569
+ fetch('/api/index').then(function(r) { return r.json(); }).then(renderIndex).catch(function() {});
570
+ }
571
+ pollIndex();
572
+ setInterval(pollIndex, 5000);
573
+
574
+ // --- Tab switching ------------------------------------------------------
575
+ var tabs = document.querySelectorAll('.tab');
576
+ var panels = { current: 'panel-current', all: 'panel-all', summary: 'panel-summary' };
577
+ for (var i = 0; i < tabs.length; i++) {
578
+ tabs[i].addEventListener('click', function() {
579
+ var name = this.getAttribute('data-tab');
580
+ for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active');
581
+ this.classList.add('active');
582
+ for (var k in panels) {
583
+ if (Object.prototype.hasOwnProperty.call(panels, k)) {
584
+ var el = document.getElementById(panels[k]);
585
+ if (el) el.classList.toggle('active', k === name);
586
+ }
587
+ }
588
+ if (name === 'all' || name === 'summary') pollIndex();
589
+ });
590
+ }
398
591
  })();
399
592
  </script>
400
593
  </body>
@@ -453,6 +646,15 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
453
646
  return;
454
647
  }
455
648
 
649
+ // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
650
+ // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
651
+ // checkpoints, tokens saved, and active model. Read-only.
652
+ if (req.url === "/api/index") {
653
+ res.writeHead(200, { "Content-Type": "application/json" });
654
+ res.end(JSON.stringify(readIndex() ?? { updatedAt: null, summary: null, repos: [] }));
655
+ return;
656
+ }
657
+
456
658
  if (req.url === "/api/events") {
457
659
  res.writeHead(200, {
458
660
  "Content-Type": "text/event-stream",
@@ -82,6 +82,14 @@ export interface DashboardSnapshot {
82
82
  duplicatesCollapsed: number; // dedup duplicates (original kept on survivor)
83
83
  bytesPermanentlyDeleted: number; // ALWAYS 0 — the invariant
84
84
  };
85
+ /** Active model/provider (captured live) — shown on the current-repo card. */
86
+ model?: {
87
+ name: string; // Model.name or Model.id
88
+ provider: string; // ProviderId (Model.provider)
89
+ providerName: string; // human display name (e.g. "OpenAI")
90
+ inputRate: number; // USD per input token (Model.cost)
91
+ outputRate: number; // USD per output token (Model.cost)
92
+ };
85
93
  }
86
94
 
87
95
  export class Dashboard {
@@ -17,7 +17,7 @@ import { VectorStore } from "../src/vectorStore.js";
17
17
  import { toEngineMessages } from "../src/adapt.js";
18
18
  import { normalizeSessionId } from "../src/store.js";
19
19
  import { Logger } from "../src/log.js";
20
- import { recordModelSnapshot, type ModelSnapshot } from "../src/store/sqlite.js";
20
+ import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, type ModelSnapshot } from "../src/store/sqlite.js";
21
21
  import { repoStateDir, resolveRepoRoot, type MegaConfig } from "./mega-config.js";
22
22
  import { Dashboard, type DashboardSnapshot } from "./mega-dashboard.js";
23
23
 
@@ -142,6 +142,26 @@ export class MegaRuntime {
142
142
  this.store = new VectorStore({ dedupSim: this.config.dedupSim, stateDir: dir });
143
143
  this.logger = new Logger({ enabled: this.config.debug, path: join(dir, "mega-compact.log") });
144
144
  this.dashboard = new Dashboard(dir);
145
+ // Aggregate this repo into the machine-wide index so the multi-repo
146
+ // dashboard (Summary / All-repos tabs) can show it alongside every other
147
+ // repo. Best-effort + non-fatal: a read-only index dir or contention must
148
+ // never break the per-repo compaction path. Runs only on repo-switch
149
+ // (this branch), so it's infrequent — not per-context-event.
150
+ try {
151
+ const repo = this.store.repoStats();
152
+ const di = this.store.dataInvariant();
153
+ const root = key !== dir ? key : resolveRepoRoot(cwd ?? dir) ?? dir;
154
+ upsertRepoRegistry({
155
+ repoRoot: root,
156
+ displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
157
+ stateDir: dir,
158
+ checkpointCount: repo.checkpointCount,
159
+ tokensSaved: repo.tokensSaved,
160
+ compressedOriginalBytes: di.compressedOriginalBytes,
161
+ });
162
+ } catch {
163
+ /* non-fatal: index aggregation must not block compaction */
164
+ }
145
165
  return dir;
146
166
  }
147
167
 
@@ -153,6 +173,17 @@ export class MegaRuntime {
153
173
  const st = this.store.stats(this.rt.sessionId);
154
174
  const repo = this.store.repoStats();
155
175
  const di = this.store.dataInvariant();
176
+ // Active model/provider for the current-repo card + the multi-repo table.
177
+ const modelSnap = latestModelSnapshot(this.currentStateDir);
178
+ const model = modelSnap
179
+ ? {
180
+ name: modelSnap.modelName ?? modelSnap.modelId,
181
+ provider: modelSnap.provider,
182
+ providerName: modelSnap.providerName ?? "",
183
+ inputRate: modelSnap.inputRate,
184
+ outputRate: modelSnap.outputRate,
185
+ }
186
+ : undefined;
156
187
  const armed = this.lastCtxPercent != null && this.lastCtxPercent >= this.config.fastGatePct;
157
188
  const ready = armed && (this.lastCtxTokens ?? 0) >= this.config.thresholdTokens;
158
189
  this.dashboard.snapshot({
@@ -197,6 +228,7 @@ export class MegaRuntime {
197
228
  duplicatesCollapsed: di.duplicatesCollapsed,
198
229
  bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
199
230
  },
231
+ model,
200
232
  } as DashboardSnapshot);
201
233
 
202
234
  // Live stats widget above the editor
@@ -321,6 +353,18 @@ export class MegaRuntime {
321
353
  try {
322
354
  const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
323
355
  recordModelSnapshot(repo, snap, this.currentStateDir);
356
+ // Denormalize the active model into the machine-wide index so the
357
+ // All-repos dashboard table can show provider/model per repo without
358
+ // opening every repo's DB. Best-effort + non-fatal.
359
+ recordRepoModel(repo, {
360
+ provider: snap.provider,
361
+ providerName: snap.providerName,
362
+ modelName: snap.modelName,
363
+ inputRate: snap.inputRate,
364
+ outputRate: snap.outputRate,
365
+ stateDir: this.currentStateDir,
366
+ displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
367
+ });
324
368
  } catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
325
369
  }
326
370
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
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",
@@ -17,6 +17,7 @@
17
17
 
18
18
  import Database from "better-sqlite3";
19
19
  import { existsSync, mkdirSync } from "node:fs";
20
+ import { homedir, tmpdir } from "node:os";
20
21
  import { join } from "node:path";
21
22
  import { getStateDir } from "../store.js";
22
23
  import type { StoredCheckpoint, SessionState } from "../store.js";
@@ -62,6 +63,219 @@ export function openStore(stateDir: string = getStateDir()): Database.Database {
62
63
  return db;
63
64
  }
64
65
 
66
+ // ---------------------------------------------------------------------------
67
+ // Global machine-wide index (Phase 5b): a single SQLite DB, separate from every
68
+ // per-repo store, that aggregates one row per repo this machine has run on. The
69
+ // multi-repo dashboard (Summary / All-repos tabs) reads it so ONE dashboard can
70
+ // show every repo's checkpoints, tokens saved, and active model — instead of a
71
+ // per-repo dashboard that only ever sees the repo it was launched from.
72
+ //
73
+ // Written by every pi process on repo-switch (bindRepo) + model capture; read by
74
+ // the dashboard server. Concurrency across 10+ pi processes is handled by WAL +
75
+ // infrequent idempotent upserts (ON CONFLICT). Fully local (PREVENT-PI-004).
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** Resolve the machine-wide index directory (env-overridable). */
79
+ export function getIndexDir(): string {
80
+ const override = process.env.MEGACOMPACT_INDEX_DIR;
81
+ if (override && override.trim() !== "") return override;
82
+ // homedir() can throw in exotic sandboxes; fall back to tmpdir.
83
+ try {
84
+ return join(homedir(), ".mega-compact-index");
85
+ } catch {
86
+ return join(tmpdir(), ".mega-compact-index");
87
+ }
88
+ }
89
+
90
+ let indexCache: Database.Database | undefined;
91
+ let indexCacheDir: string | undefined;
92
+
93
+ /** Open (or reuse) the machine-wide index DB. WAL for concurrent writers. */
94
+ export function openIndexStore(indexDir: string = getIndexDir()): Database.Database {
95
+ if (indexCache && indexCacheDir === indexDir) return indexCache;
96
+ if (!existsSync(indexDir)) mkdirSync(indexDir, { recursive: true });
97
+ const db = new Database(join(indexDir, "index.sqlite"));
98
+ db.pragma("journal_mode = WAL");
99
+ db.pragma("busy_timeout = 3000"); // tolerate brief cross-process write contention
100
+ db.exec(`
101
+ CREATE TABLE IF NOT EXISTS repo_registry (
102
+ repo_root TEXT PRIMARY KEY,
103
+ display_name TEXT,
104
+ state_dir TEXT NOT NULL,
105
+ first_seen INTEGER,
106
+ last_seen INTEGER,
107
+ last_compacted_at INTEGER,
108
+ checkpoint_count INTEGER DEFAULT 0,
109
+ tokens_saved INTEGER DEFAULT 0,
110
+ compressed_original_bytes INTEGER DEFAULT 0,
111
+ provider TEXT,
112
+ provider_name TEXT,
113
+ model_name TEXT,
114
+ input_rate REAL,
115
+ output_rate REAL,
116
+ model_captured_at INTEGER
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
119
+ `);
120
+ indexCache = db;
121
+ indexCacheDir = indexDir;
122
+ return db;
123
+ }
124
+
125
+ /** One row of the global repo registry (multi-repo dashboard source). */
126
+ export interface RepoRegistryRow {
127
+ repoRoot: string;
128
+ displayName: string;
129
+ stateDir: string;
130
+ firstSeen: number;
131
+ lastSeen: number;
132
+ lastCompactedAt: number | null;
133
+ checkpointCount: number;
134
+ tokensSaved: number;
135
+ compressedOriginalBytes: number;
136
+ provider: string | null;
137
+ providerName: string | null;
138
+ modelName: string | null;
139
+ inputRate: number | null;
140
+ outputRate: number | null;
141
+ modelCapturedAt: number | null;
142
+ }
143
+
144
+ /**
145
+ * Upsert a repo's aggregate stats into the global index. Called on repo-switch
146
+ * (infrequent). Preserves first_seen + the model columns on update (model is
147
+ * written separately by recordRepoModel so we never clobber it here with nulls).
148
+ */
149
+ export function upsertRepoRegistry(
150
+ row: {
151
+ repoRoot: string;
152
+ displayName: string;
153
+ stateDir: string;
154
+ checkpointCount: number;
155
+ tokensSaved: number;
156
+ compressedOriginalBytes: number;
157
+ lastCompactedAt?: number | null;
158
+ },
159
+ indexDir: string = getIndexDir(),
160
+ ): void {
161
+ const db = openIndexStore(indexDir);
162
+ const now = Date.now();
163
+ db.prepare(
164
+ `INSERT INTO repo_registry
165
+ (repo_root, display_name, state_dir, first_seen, last_seen, last_compacted_at,
166
+ checkpoint_count, tokens_saved, compressed_original_bytes)
167
+ VALUES (@repo_root, @display_name, @state_dir, @now, @now, @last_compacted_at,
168
+ @checkpoint_count, @tokens_saved, @compressed_original_bytes)
169
+ ON CONFLICT(repo_root) DO UPDATE SET
170
+ display_name = excluded.display_name,
171
+ state_dir = excluded.state_dir,
172
+ last_seen = excluded.last_seen,
173
+ last_compacted_at = COALESCE(excluded.last_compacted_at, repo_registry.last_compacted_at),
174
+ checkpoint_count = excluded.checkpoint_count,
175
+ tokens_saved = excluded.tokens_saved,
176
+ compressed_original_bytes = excluded.compressed_original_bytes`,
177
+ ).run({
178
+ repo_root: row.repoRoot,
179
+ display_name: row.displayName,
180
+ state_dir: row.stateDir,
181
+ now,
182
+ last_compacted_at: row.lastCompactedAt ?? null,
183
+ checkpoint_count: row.checkpointCount,
184
+ tokens_saved: row.tokensSaved,
185
+ compressed_original_bytes: row.compressedOriginalBytes,
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Record the active model/provider for a repo in the global index (denormalized
191
+ * so the All-repos table shows model without opening each repo's DB). Upserts a
192
+ * bare registry row if the repo isn't registered yet.
193
+ */
194
+ export function recordRepoModel(
195
+ repoRoot: string,
196
+ model: {
197
+ provider: string;
198
+ providerName: string | null;
199
+ modelName: string | null;
200
+ inputRate: number;
201
+ outputRate: number;
202
+ stateDir: string;
203
+ displayName: string;
204
+ },
205
+ indexDir: string = getIndexDir(),
206
+ ): void {
207
+ const db = openIndexStore(indexDir);
208
+ const now = Date.now();
209
+ db.prepare(
210
+ `INSERT INTO repo_registry
211
+ (repo_root, display_name, state_dir, first_seen, last_seen,
212
+ provider, provider_name, model_name, input_rate, output_rate, model_captured_at)
213
+ VALUES (@repo_root, @display_name, @state_dir, @now, @now,
214
+ @provider, @provider_name, @model_name, @input_rate, @output_rate, @now)
215
+ ON CONFLICT(repo_root) DO UPDATE SET
216
+ last_seen = excluded.last_seen,
217
+ provider = excluded.provider,
218
+ provider_name = excluded.provider_name,
219
+ model_name = excluded.model_name,
220
+ input_rate = excluded.input_rate,
221
+ output_rate = excluded.output_rate,
222
+ model_captured_at = excluded.model_captured_at`,
223
+ ).run({
224
+ repo_root: repoRoot,
225
+ display_name: model.displayName,
226
+ state_dir: model.stateDir,
227
+ now,
228
+ provider: model.provider,
229
+ provider_name: model.providerName,
230
+ model_name: model.modelName,
231
+ input_rate: model.inputRate,
232
+ output_rate: model.outputRate,
233
+ });
234
+ }
235
+
236
+ function mapRegistryRow(row: any): RepoRegistryRow {
237
+ return {
238
+ repoRoot: row.repo_root,
239
+ displayName: row.display_name ?? "",
240
+ stateDir: row.state_dir,
241
+ firstSeen: row.first_seen ?? 0,
242
+ lastSeen: row.last_seen ?? 0,
243
+ lastCompactedAt: row.last_compacted_at ?? null,
244
+ checkpointCount: row.checkpoint_count ?? 0,
245
+ tokensSaved: row.tokens_saved ?? 0,
246
+ compressedOriginalBytes: row.compressed_original_bytes ?? 0,
247
+ provider: row.provider ?? null,
248
+ providerName: row.provider_name ?? null,
249
+ modelName: row.model_name ?? null,
250
+ inputRate: row.input_rate ?? null,
251
+ outputRate: row.output_rate ?? null,
252
+ modelCapturedAt: row.model_captured_at ?? null,
253
+ };
254
+ }
255
+
256
+ /** All registered repos, most-recently-seen first. */
257
+ export function listRepoRegistry(indexDir: string = getIndexDir()): RepoRegistryRow[] {
258
+ const db = openIndexStore(indexDir);
259
+ const rows = db.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC").all() as any[];
260
+ return rows.map(mapRegistryRow);
261
+ }
262
+
263
+ /** A single repo's registry row, or undefined. */
264
+ export function getRepoRegistry(repoRoot: string, indexDir: string = getIndexDir()): RepoRegistryRow | undefined {
265
+ const db = openIndexStore(indexDir);
266
+ const row = db.prepare("SELECT * FROM repo_registry WHERE repo_root = ?").get(repoRoot) as any;
267
+ return row ? mapRegistryRow(row) : undefined;
268
+ }
269
+
270
+ /** Close the cached index connection (test teardown only). */
271
+ export function closeIndexStore(): void {
272
+ if (indexCache) {
273
+ indexCache.close();
274
+ indexCache = undefined;
275
+ indexCacheDir = undefined;
276
+ }
277
+ }
278
+
65
279
  function initSchema(db: Database.Database): void {
66
280
  db.exec(`
67
281
  CREATE TABLE IF NOT EXISTS context_chunks (