pi-mega-compact 0.4.18 → 0.4.20
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.
- package/dist/extensions/dashboard-server.js +165 -10
- package/dist/extensions/mega-compact.test.js +9 -4
- package/dist/extensions/mega-dashboard-cmds.js +89 -7
- package/extensions/dashboard-server.ts +173 -10
- package/extensions/mega-compact.test.ts +9 -4
- package/extensions/mega-dashboard-cmds.ts +81 -7
- package/package.json +1 -1
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
17
18
|
import Database from "better-sqlite3";
|
|
18
19
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
19
20
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
@@ -46,7 +47,7 @@ function readIndex() {
|
|
|
46
47
|
const rows = db
|
|
47
48
|
.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
|
|
48
49
|
.all();
|
|
49
|
-
const
|
|
50
|
+
const mapped = rows.map((r) => ({
|
|
50
51
|
repoRoot: String(r.repo_root ?? ""),
|
|
51
52
|
displayName: String(r.display_name ?? ""),
|
|
52
53
|
checkpointCount: Number(r.checkpoint_count ?? 0),
|
|
@@ -60,6 +61,23 @@ function readIndex() {
|
|
|
60
61
|
outputRate: r.output_rate ?? null,
|
|
61
62
|
lastSeen: Number(r.last_seen ?? 0),
|
|
62
63
|
}));
|
|
64
|
+
// Defensive display hygiene (belt-and-suspenders — the real fix is that
|
|
65
|
+
// tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
|
|
66
|
+
// paths that should never have been real repos, and collapse duplicate
|
|
67
|
+
// display names to the most-recently-seen row (rows are last_seen DESC, so
|
|
68
|
+
// the first occurrence wins). Keeps the All-repos list readable.
|
|
69
|
+
const isTransient = (p) => /^\/tmp\//.test(p) || /^\/private\/tmp\//.test(p) || /^\/var\/folders\//.test(p) ||
|
|
70
|
+
/\/mc-(ext|e2e|resume|recall)-/.test(p);
|
|
71
|
+
const seenName = new Set();
|
|
72
|
+
const repos = [];
|
|
73
|
+
for (const r of mapped) {
|
|
74
|
+
if (isTransient(r.repoRoot))
|
|
75
|
+
continue;
|
|
76
|
+
if (seenName.has(r.displayName))
|
|
77
|
+
continue;
|
|
78
|
+
seenName.add(r.displayName);
|
|
79
|
+
repos.push(r);
|
|
80
|
+
}
|
|
63
81
|
const summary = {
|
|
64
82
|
totalRepos: repos.length,
|
|
65
83
|
totalCheckpoints: repos.reduce((a, r) => a + r.checkpointCount, 0),
|
|
@@ -99,6 +117,7 @@ function readSnapshot(snapshotPath) {
|
|
|
99
117
|
crew: { activeAgents: 0, currentTurn: 0 },
|
|
100
118
|
repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
|
|
101
119
|
integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
|
|
120
|
+
model: undefined,
|
|
102
121
|
};
|
|
103
122
|
}
|
|
104
123
|
}
|
|
@@ -195,13 +214,26 @@ function dashboardHtml(tierName) {
|
|
|
195
214
|
.repo-model { color: #a371f7; }
|
|
196
215
|
.repo-none { color: #484f58; font-style: italic; }
|
|
197
216
|
.updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
|
|
217
|
+
.model-pill { background: #6e40c9; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
|
|
218
|
+
.card.cost h2 { color: #a371f7; }
|
|
219
|
+
.cost-usd { font-size: 22px; font-weight: 700; color: #3fb950; }
|
|
220
|
+
.cost-sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
|
|
221
|
+
.repo-link { cursor: pointer; }
|
|
222
|
+
.repo-link:hover td { color: #58a6ff; }
|
|
223
|
+
.repo-detail { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 50; }
|
|
224
|
+
.repo-detail.open { display: flex; }
|
|
225
|
+
.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; }
|
|
226
|
+
.repo-detail-box h2 { font-size: 14px; color: #f0f6fc; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
|
|
227
|
+
.repo-close { cursor: pointer; color: #8b949e; font-size: 20px; line-height: 1; border: none; background: none; padding: 0 4px; }
|
|
228
|
+
.repo-close:hover { color: #f0f6fc; }
|
|
229
|
+
.repo-path { font-size: 11px; color: #484f58; word-break: break-all; margin: -8px 0 12px; }
|
|
198
230
|
</style>
|
|
199
231
|
</head>
|
|
200
232
|
<body>
|
|
201
233
|
|
|
202
234
|
<div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
|
|
203
235
|
|
|
204
|
-
<h1><span>mega-compact</span><span class="tier">${tierName}</span></h1>
|
|
236
|
+
<h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="model-pill" id="hdr-model">—</span></h1>
|
|
205
237
|
|
|
206
238
|
<nav class="tabs">
|
|
207
239
|
<button class="tab active" data-tab="current">Current repo</button>
|
|
@@ -270,6 +302,17 @@ function dashboardHtml(tierName) {
|
|
|
270
302
|
<span class="label">Anchor</span><span class="value" id="cf-anchor">—</span>
|
|
271
303
|
</div>
|
|
272
304
|
</div>
|
|
305
|
+
<div class="card cost">
|
|
306
|
+
<h2>💰 Model & Cost Savings</h2>
|
|
307
|
+
<div class="cost-usd" id="cost-usd">≈ $0.00 saved</div>
|
|
308
|
+
<div class="cost-sub" id="cost-windows">0 context-windows extended</div>
|
|
309
|
+
<div class="stat-grid" style="margin-top:12px">
|
|
310
|
+
<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>
|
|
311
|
+
<span class="label" title="The provider serving the model.">Provider</span><span class="value" id="md-provider">—</span>
|
|
312
|
+
<span class="label" title="USD per input token, from the model's pricing.">Input Rate</span><span class="value" id="md-input">—</span>
|
|
313
|
+
<span class="label" title="USD per output token, from the model's pricing.">Output Rate</span><span class="value" id="md-output">—</span>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
273
316
|
<div class="card">
|
|
274
317
|
<h2>Crew / Agents</h2>
|
|
275
318
|
<div class="stat-grid">
|
|
@@ -297,9 +340,40 @@ function dashboardHtml(tierName) {
|
|
|
297
340
|
<div class="events-wrap" id="events"><div class="empty">connecting…</div></div>
|
|
298
341
|
</div>
|
|
299
342
|
|
|
343
|
+
<h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">All Repositories</h2>
|
|
344
|
+
<table class="repos">
|
|
345
|
+
<thead>
|
|
346
|
+
<tr>
|
|
347
|
+
<th>Repo</th><th>Model</th>
|
|
348
|
+
<th style="text-align:right">Checkpoints</th>
|
|
349
|
+
<th style="text-align:right">Tokens Saved</th>
|
|
350
|
+
<th style="text-align:right">Retained</th>
|
|
351
|
+
<th style="text-align:right">Last Compacted</th>
|
|
352
|
+
</tr>
|
|
353
|
+
</thead>
|
|
354
|
+
<tbody id="cur-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
|
|
355
|
+
</table>
|
|
356
|
+
<div class="updated" id="cur-updated"></div>
|
|
357
|
+
|
|
300
358
|
<div class="updated" id="updated"></div>
|
|
301
359
|
</div><!-- /panel-current -->
|
|
302
360
|
|
|
361
|
+
<!-- Per-repo detail modal -->
|
|
362
|
+
<div class="repo-detail" id="repo-detail">
|
|
363
|
+
<div class="repo-detail-box">
|
|
364
|
+
<h2><span id="rd-name">Repo</span><button class="repo-close" id="rd-close" title="Close">×</button></h2>
|
|
365
|
+
<div class="repo-path" id="rd-path"></div>
|
|
366
|
+
<div class="stat-grid">
|
|
367
|
+
<span class="label">Model</span><span class="value" id="rd-model">—</span>
|
|
368
|
+
<span class="label">Checkpoints</span><span class="value" id="rd-cp">0</span>
|
|
369
|
+
<span class="label">Tokens Saved</span><span class="value" id="rd-saved">0</span>
|
|
370
|
+
<span class="label">Compressed-Original</span><span class="value" id="rd-bytes">0 B</span>
|
|
371
|
+
<span class="label">Last Compacted</span><span class="value" id="rd-when">—</span>
|
|
372
|
+
<span class="label">Provider</span><span class="value" id="rd-provider">—</span>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
303
377
|
<!-- All repos (machine-wide registry from index.sqlite) -->
|
|
304
378
|
<div class="tab-panel" id="panel-all">
|
|
305
379
|
<table class="repos">
|
|
@@ -406,6 +480,24 @@ function dashboardHtml(tierName) {
|
|
|
406
480
|
document.getElementById('cf-auto').textContent = d.config.auto ? 'enabled' : 'disabled';
|
|
407
481
|
document.getElementById('cf-anchor').textContent = d.config.anchorUserMessages;
|
|
408
482
|
|
|
483
|
+
// --- Active model + cost savings (same calc as /mega-status) ---------------
|
|
484
|
+
var model = d.model;
|
|
485
|
+
document.getElementById('hdr-model').textContent = model && model.name ? model.name : '—';
|
|
486
|
+
document.getElementById('md-name').textContent = model && model.name ? model.name : '—';
|
|
487
|
+
document.getElementById('md-provider').textContent = model && model.providerName ? model.providerName : (model && model.provider ? model.provider : '—');
|
|
488
|
+
document.getElementById('md-input').textContent = model && model.inputRate ? '$' + (model.inputRate).toFixed(6) : '—';
|
|
489
|
+
document.getElementById('md-output').textContent = model && model.outputRate ? '$' + (model.outputRate).toFixed(6) : '—';
|
|
490
|
+
if (model && model.inputRate && repo.tokensSaved > 0) {
|
|
491
|
+
var usd = (repo.tokensSaved * model.inputRate);
|
|
492
|
+
var win = d.context.contextWindow || 0;
|
|
493
|
+
var windows = win > 0 ? (repo.tokensSaved / win).toFixed(1) : '0';
|
|
494
|
+
document.getElementById('cost-usd').textContent = '≈ $' + usd.toFixed(4) + ' saved';
|
|
495
|
+
document.getElementById('cost-windows').textContent = windows + ' context-windows extended';
|
|
496
|
+
} else {
|
|
497
|
+
document.getElementById('cost-usd').textContent = '≈ $0.00 saved';
|
|
498
|
+
document.getElementById('cost-windows').textContent = '0 context-windows extended';
|
|
499
|
+
}
|
|
500
|
+
|
|
409
501
|
document.getElementById('updated').textContent = 'Updated ' + new Date(d.updatedAt).toLocaleTimeString();
|
|
410
502
|
}
|
|
411
503
|
|
|
@@ -469,16 +561,16 @@ function dashboardHtml(tierName) {
|
|
|
469
561
|
document.getElementById('sm-saved').textContent = (s.totalTokensSaved || 0).toLocaleString();
|
|
470
562
|
document.getElementById('sm-bytes').textContent = fmtBytesTop(s.totalCompressedOriginalBytes);
|
|
471
563
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
564
|
+
// Shared clickable-row renderer for both the in-current table and the
|
|
565
|
+
// All-repos tab — each row opens the per-repo detail modal.
|
|
566
|
+
function rowsHtml() {
|
|
567
|
+
if (!repos.length) return '<tr><td colspan="6" class="repo-none">No repositories registered yet.</td></tr>';
|
|
568
|
+
return repos.map(function(r) {
|
|
477
569
|
var model = r.modelName
|
|
478
570
|
? '<span class="repo-model">' + sanitize(r.modelName) + '</span>'
|
|
479
571
|
: '<span class="repo-none">—</span>';
|
|
480
572
|
var when = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
|
|
481
|
-
return '<tr>' +
|
|
573
|
+
return '<tr class="repo-link" data-repo="' + sanitize(r.repoRoot) + '">' +
|
|
482
574
|
'<td title="' + sanitize(r.repoRoot) + '">' + sanitize(r.displayName || r.repoRoot) + '</td>' +
|
|
483
575
|
'<td>' + model + '</td>' +
|
|
484
576
|
'<td class="num">' + (r.checkpointCount || 0).toLocaleString() + '</td>' +
|
|
@@ -488,12 +580,48 @@ function dashboardHtml(tierName) {
|
|
|
488
580
|
'</tr>';
|
|
489
581
|
}).join('');
|
|
490
582
|
}
|
|
583
|
+
document.getElementById('cur-rows').innerHTML = rowsHtml();
|
|
584
|
+
document.getElementById('all-rows').innerHTML = rowsHtml();
|
|
585
|
+
bindRepoRows();
|
|
586
|
+
|
|
491
587
|
var stamp = d.updatedAt ? 'Updated ' + new Date(d.updatedAt).toLocaleTimeString() : '';
|
|
588
|
+
document.getElementById('cur-updated').textContent = stamp;
|
|
492
589
|
document.getElementById('all-updated').textContent = stamp;
|
|
493
590
|
document.getElementById('sm-updated').textContent = stamp;
|
|
494
591
|
}
|
|
592
|
+
|
|
593
|
+
// Per-repo detail modal ---------------------------------------------------
|
|
594
|
+
var detailEl = document.getElementById('repo-detail');
|
|
595
|
+
var indexCache = { repos: [] };
|
|
596
|
+
function openRepoDetail(root) {
|
|
597
|
+
var r = null;
|
|
598
|
+
for (var i = 0; i < indexCache.repos.length; i++) {
|
|
599
|
+
if (indexCache.repos[i].repoRoot === root) { r = indexCache.repos[i]; break; }
|
|
600
|
+
}
|
|
601
|
+
if (!r) return;
|
|
602
|
+
document.getElementById('rd-name').textContent = r.displayName || r.repoRoot;
|
|
603
|
+
document.getElementById('rd-path').textContent = r.repoRoot;
|
|
604
|
+
document.getElementById('rd-model').textContent = r.modelName || '—';
|
|
605
|
+
document.getElementById('rd-provider').textContent = r.providerName || (r.provider || '—');
|
|
606
|
+
document.getElementById('rd-cp').textContent = (r.checkpointCount || 0).toLocaleString();
|
|
607
|
+
document.getElementById('rd-saved').textContent = (r.tokensSaved || 0).toLocaleString();
|
|
608
|
+
document.getElementById('rd-bytes').textContent = fmtBytesTop(r.compressedOriginalBytes);
|
|
609
|
+
document.getElementById('rd-when').textContent = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
|
|
610
|
+
detailEl.classList.add('open');
|
|
611
|
+
}
|
|
612
|
+
document.getElementById('rd-close').addEventListener('click', function() { detailEl.classList.remove('open'); });
|
|
613
|
+
detailEl.addEventListener('click', function(e) { if (e.target === detailEl) detailEl.classList.remove('open'); });
|
|
614
|
+
function bindRepoRows() {
|
|
615
|
+
var rows = document.querySelectorAll('.repo-link');
|
|
616
|
+
for (var i = 0; i < rows.length; i++) {
|
|
617
|
+
rows[i].addEventListener('click', function() { openRepoDetail(this.getAttribute('data-repo')); });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
495
620
|
function pollIndex() {
|
|
496
|
-
fetch('/api/index').then(function(r) { return r.json(); }).then(
|
|
621
|
+
fetch('/api/index').then(function(r) { return r.json(); }).then(function(d) {
|
|
622
|
+
indexCache = d && d.repos ? d : indexCache;
|
|
623
|
+
renderIndex(d);
|
|
624
|
+
}).catch(function() {});
|
|
497
625
|
}
|
|
498
626
|
pollIndex();
|
|
499
627
|
setInterval(pollIndex, 5000);
|
|
@@ -524,6 +652,26 @@ function dashboardHtml(tierName) {
|
|
|
524
652
|
// Server
|
|
525
653
|
// ---------------------------------------------------------------------------
|
|
526
654
|
export function launchDashboardServer(stateDir) {
|
|
655
|
+
// Our own package version — exposed at /api/version so the launcher can
|
|
656
|
+
// detect a stale server (started by an older build) and replace it on
|
|
657
|
+
// upgrade instead of reuse it.
|
|
658
|
+
let SERVER_VERSION = "0.0.0";
|
|
659
|
+
try {
|
|
660
|
+
// dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
|
|
661
|
+
// two levels up. Guard each candidate so a dev-checkout layout still works.
|
|
662
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
663
|
+
const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
|
|
664
|
+
for (const p of candidates) {
|
|
665
|
+
if (!existsSync(p))
|
|
666
|
+
continue;
|
|
667
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
668
|
+
if (pkg.version) {
|
|
669
|
+
SERVER_VERSION = pkg.version;
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch { /* non-fatal */ }
|
|
527
675
|
const portFile = join(stateDir, "port.pid");
|
|
528
676
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
529
677
|
const eventsPath = join(stateDir, "events.log");
|
|
@@ -564,6 +712,13 @@ export function launchDashboardServer(stateDir) {
|
|
|
564
712
|
res.end(JSON.stringify(snap));
|
|
565
713
|
return;
|
|
566
714
|
}
|
|
715
|
+
// Server version — lets the /dashboard launcher detect a stale server from
|
|
716
|
+
// an older build and replace it on upgrade rather than reuse it.
|
|
717
|
+
if (req.url === "/api/version") {
|
|
718
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
719
|
+
res.end(JSON.stringify({ version: SERVER_VERSION }));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
567
722
|
// Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
|
|
568
723
|
// directly from SQLite (index.sqlite). Lets one dashboard show every repo's
|
|
569
724
|
// checkpoints, tokens saved, and active model. Read-only.
|
|
@@ -19,6 +19,9 @@ import { join } from "node:path";
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
const require = createRequire(import.meta.url);
|
|
21
21
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-ext-"));
|
|
22
|
+
// Isolate the machine-wide repo index so test runs (which call bindRepo ->
|
|
23
|
+
// upsertRepoRegistry) never pollute the developer's real ~/.mega-compact-index.
|
|
24
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
|
|
22
25
|
let counter = 0;
|
|
23
26
|
/** Build a mock pi + ctx and load the extension into them. */
|
|
24
27
|
function harness(opts = {}) {
|
|
@@ -268,13 +271,14 @@ test("/dashboard-stop reports no server when pid file missing", async () => {
|
|
|
268
271
|
test("/dashboard skips server spawn when already running", async () => {
|
|
269
272
|
const h = harness();
|
|
270
273
|
const confirms = [];
|
|
271
|
-
// Set up a fake HTTP server
|
|
274
|
+
// Set up a fake HTTP server on a port inside the dashboard's scan range
|
|
275
|
+
// (9320–9329) — isServerRunning() probes those ports, not the port.pid value.
|
|
272
276
|
const { createServer } = await import("node:http");
|
|
273
277
|
const server = createServer((_req, res) => {
|
|
274
278
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
275
279
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test", version: 1, config: {}, session: {}, context: {}, trigger: {}, store: {} }));
|
|
276
280
|
});
|
|
277
|
-
await new Promise((r) => server.listen(
|
|
281
|
+
await new Promise((r) => server.listen(9320, "127.0.0.1", r));
|
|
278
282
|
const addr = server.address();
|
|
279
283
|
const { join: j } = await import("node:path");
|
|
280
284
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -295,7 +299,8 @@ test("/dashboard skips server spawn when already running", async () => {
|
|
|
295
299
|
});
|
|
296
300
|
test("/dashboard-status reports running after dashboard start", async () => {
|
|
297
301
|
const h = harness();
|
|
298
|
-
// Write a fake port.pid
|
|
302
|
+
// Write a fake port.pid; the server must listen inside the scan range
|
|
303
|
+
// (9320–9329) or isServerRunning() won't detect it.
|
|
299
304
|
const { createServer } = await import("node:http");
|
|
300
305
|
const { join: j } = await import("node:path");
|
|
301
306
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -303,7 +308,7 @@ test("/dashboard-status reports running after dashboard start", async () => {
|
|
|
303
308
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
304
309
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
|
|
305
310
|
});
|
|
306
|
-
await new Promise((r) => server.listen(
|
|
311
|
+
await new Promise((r) => server.listen(9321, "127.0.0.1", r));
|
|
307
312
|
const addr = server.address();
|
|
308
313
|
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: addr.port, pid: process.pid }));
|
|
309
314
|
const ctx = h.ctx();
|
|
@@ -32,7 +32,10 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
32
32
|
}
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
|
-
/** Try to reach a running dashboard server. Returns
|
|
35
|
+
/** Try to reach a running dashboard server. Returns details or null.
|
|
36
|
+
* `hasPidFile` tells the caller whether this server was launched by us
|
|
37
|
+
* (port.pid present) — a live server with NO pid file is an orphan from an
|
|
38
|
+
* older/detached spawn that we should replace rather than reuse. */
|
|
36
39
|
async function isServerRunning() {
|
|
37
40
|
const port = await findLivePort();
|
|
38
41
|
if (!port) {
|
|
@@ -45,7 +48,68 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
45
48
|
}
|
|
46
49
|
return null;
|
|
47
50
|
}
|
|
48
|
-
return { port, url: `http://localhost:${port}
|
|
51
|
+
return { port, url: `http://localhost:${port}`, hasPidFile: existsSync(portFile) }; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
|
|
52
|
+
}
|
|
53
|
+
/** Version the running server on `port` reports, or null. */
|
|
54
|
+
async function serverVersion(port) {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`http://localhost:${port}/api/version`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: localhost version probe of the dashboard server this extension spawned
|
|
57
|
+
if (!res.ok)
|
|
58
|
+
return null;
|
|
59
|
+
const j = await res.json();
|
|
60
|
+
return j.version ?? null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Version of THIS extension (read from its own package.json). */
|
|
67
|
+
function ownVersion() {
|
|
68
|
+
try {
|
|
69
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
|
|
70
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
71
|
+
return pkg.version ?? null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** PID listening on 127.0.0.1:port (our own server), or null. Uses `ss`
|
|
78
|
+
* (Linux/macOS) — best-effort, returns null if unavailable. */
|
|
79
|
+
function pidOnPort(port) {
|
|
80
|
+
try {
|
|
81
|
+
const { execSync } = require("node:child_process"); // guardrails-allow PREVENT-PI-004: localhost-only, reads our own dashboard port owner
|
|
82
|
+
const out = execSync(`ss -ltnp 2>/dev/null | grep ':${port} '`, { encoding: "utf-8" });
|
|
83
|
+
const m = out.match(/pid=(\d+)/);
|
|
84
|
+
return m ? Number(m[1]) : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Kill a running dashboard server (best-effort): read the pid from port.pid,
|
|
91
|
+
* or — when there's no marker (an orphan) — from the port owner. Then remove
|
|
92
|
+
* the marker so the next spawn starts fresh. */
|
|
93
|
+
function killServerOnPort(port) {
|
|
94
|
+
let pid = null;
|
|
95
|
+
try {
|
|
96
|
+
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
97
|
+
if (info && info.pid)
|
|
98
|
+
pid = info.pid;
|
|
99
|
+
}
|
|
100
|
+
catch { /* no marker */ }
|
|
101
|
+
if (pid == null)
|
|
102
|
+
pid = pidOnPort(port); // orphan with no pid.pid
|
|
103
|
+
if (pid != null) {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, "SIGTERM");
|
|
106
|
+
}
|
|
107
|
+
catch { /* already gone */ }
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
unlinkSync(portFile);
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ }
|
|
49
113
|
}
|
|
50
114
|
/**
|
|
51
115
|
* Resolve the launchable dashboard-server module.
|
|
@@ -119,11 +183,29 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
119
183
|
runtime.bindRepo(ctx.cwd);
|
|
120
184
|
let info = await isServerRunning();
|
|
121
185
|
if (info) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
186
|
+
// Replace the server when it's stale: either (a) an orphan — a live
|
|
187
|
+
// server with no port.pid (e.g. left running from a detached spawn or a
|
|
188
|
+
// previous upgrade) that keeps serving old HTML from memory; or (b) a
|
|
189
|
+
// server that reports a different version than this extension (an older
|
|
190
|
+
// build). A live server WITH a matching pid file and version is reused.
|
|
191
|
+
const orphan = !info.hasPidFile;
|
|
192
|
+
const running = await serverVersion(info.port);
|
|
193
|
+
const want = ownVersion();
|
|
194
|
+
const stale = orphan || (want != null && running != null && running !== want);
|
|
195
|
+
if (stale) {
|
|
196
|
+
ctx.ui.notify(orphan
|
|
197
|
+
? "[mega-compact] replacing orphaned dashboard server…"
|
|
198
|
+
: `[mega-compact] replacing stale dashboard (${running} → ${want})…`);
|
|
199
|
+
killServerOnPort(info.port);
|
|
200
|
+
info = null;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
|
|
204
|
+
const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
|
|
205
|
+
if (open)
|
|
206
|
+
openBrowser(info.url);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
127
209
|
}
|
|
128
210
|
// Start the server
|
|
129
211
|
ctx.ui.notify("[mega-compact] starting dashboard server…");
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
|
-
import { join } from "node:path";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
18
19
|
import Database from "better-sqlite3";
|
|
19
20
|
|
|
20
21
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
@@ -61,7 +62,7 @@ function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] }
|
|
|
61
62
|
const rows = db
|
|
62
63
|
.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
|
|
63
64
|
.all() as Record<string, unknown>[];
|
|
64
|
-
const
|
|
65
|
+
const mapped: IndexRepo[] = rows.map((r) => ({
|
|
65
66
|
repoRoot: String(r.repo_root ?? ""),
|
|
66
67
|
displayName: String(r.display_name ?? ""),
|
|
67
68
|
checkpointCount: Number(r.checkpoint_count ?? 0),
|
|
@@ -75,6 +76,22 @@ function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] }
|
|
|
75
76
|
outputRate: (r.output_rate as number | null) ?? null,
|
|
76
77
|
lastSeen: Number(r.last_seen ?? 0),
|
|
77
78
|
}));
|
|
79
|
+
// Defensive display hygiene (belt-and-suspenders — the real fix is that
|
|
80
|
+
// tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
|
|
81
|
+
// paths that should never have been real repos, and collapse duplicate
|
|
82
|
+
// display names to the most-recently-seen row (rows are last_seen DESC, so
|
|
83
|
+
// the first occurrence wins). Keeps the All-repos list readable.
|
|
84
|
+
const isTransient = (p: string) =>
|
|
85
|
+
/^\/tmp\//.test(p) || /^\/private\/tmp\//.test(p) || /^\/var\/folders\//.test(p) ||
|
|
86
|
+
/\/mc-(ext|e2e|resume|recall)-/.test(p);
|
|
87
|
+
const seenName = new Set<string>();
|
|
88
|
+
const repos: IndexRepo[] = [];
|
|
89
|
+
for (const r of mapped) {
|
|
90
|
+
if (isTransient(r.repoRoot)) continue;
|
|
91
|
+
if (seenName.has(r.displayName)) continue;
|
|
92
|
+
seenName.add(r.displayName);
|
|
93
|
+
repos.push(r);
|
|
94
|
+
}
|
|
78
95
|
const summary = {
|
|
79
96
|
totalRepos: repos.length,
|
|
80
97
|
totalCheckpoints: repos.reduce((a, r) => a + r.checkpointCount, 0),
|
|
@@ -148,6 +165,19 @@ interface Snapshot {
|
|
|
148
165
|
dedupCollapsed: number;
|
|
149
166
|
storageDedupRate: number;
|
|
150
167
|
};
|
|
168
|
+
integrity: {
|
|
169
|
+
regionsRetained: number;
|
|
170
|
+
compressedOriginalBytes: number;
|
|
171
|
+
duplicatesCollapsed: number;
|
|
172
|
+
bytesPermanentlyDeleted: number;
|
|
173
|
+
};
|
|
174
|
+
model?: {
|
|
175
|
+
name: string;
|
|
176
|
+
provider: string;
|
|
177
|
+
providerName: string;
|
|
178
|
+
inputRate: number;
|
|
179
|
+
outputRate: number;
|
|
180
|
+
};
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
// ---------------------------------------------------------------------------
|
|
@@ -171,6 +201,7 @@ function readSnapshot(snapshotPath: string) {
|
|
|
171
201
|
crew: { activeAgents: 0, currentTurn: 0 },
|
|
172
202
|
repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
|
|
173
203
|
integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
|
|
204
|
+
model: undefined,
|
|
174
205
|
} as Snapshot;
|
|
175
206
|
}
|
|
176
207
|
}
|
|
@@ -268,13 +299,26 @@ function dashboardHtml(tierName: string): string {
|
|
|
268
299
|
.repo-model { color: #a371f7; }
|
|
269
300
|
.repo-none { color: #484f58; font-style: italic; }
|
|
270
301
|
.updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
|
|
302
|
+
.model-pill { background: #6e40c9; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
|
|
303
|
+
.card.cost h2 { color: #a371f7; }
|
|
304
|
+
.cost-usd { font-size: 22px; font-weight: 700; color: #3fb950; }
|
|
305
|
+
.cost-sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
|
|
306
|
+
.repo-link { cursor: pointer; }
|
|
307
|
+
.repo-link:hover td { color: #58a6ff; }
|
|
308
|
+
.repo-detail { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 50; }
|
|
309
|
+
.repo-detail.open { display: flex; }
|
|
310
|
+
.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; }
|
|
311
|
+
.repo-detail-box h2 { font-size: 14px; color: #f0f6fc; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
|
|
312
|
+
.repo-close { cursor: pointer; color: #8b949e; font-size: 20px; line-height: 1; border: none; background: none; padding: 0 4px; }
|
|
313
|
+
.repo-close:hover { color: #f0f6fc; }
|
|
314
|
+
.repo-path { font-size: 11px; color: #484f58; word-break: break-all; margin: -8px 0 12px; }
|
|
271
315
|
</style>
|
|
272
316
|
</head>
|
|
273
317
|
<body>
|
|
274
318
|
|
|
275
319
|
<div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
|
|
276
320
|
|
|
277
|
-
<h1><span>mega-compact</span><span class="tier">${tierName}</span></h1>
|
|
321
|
+
<h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="model-pill" id="hdr-model">—</span></h1>
|
|
278
322
|
|
|
279
323
|
<nav class="tabs">
|
|
280
324
|
<button class="tab active" data-tab="current">Current repo</button>
|
|
@@ -343,6 +387,17 @@ function dashboardHtml(tierName: string): string {
|
|
|
343
387
|
<span class="label">Anchor</span><span class="value" id="cf-anchor">—</span>
|
|
344
388
|
</div>
|
|
345
389
|
</div>
|
|
390
|
+
<div class="card cost">
|
|
391
|
+
<h2>💰 Model & Cost Savings</h2>
|
|
392
|
+
<div class="cost-usd" id="cost-usd">≈ $0.00 saved</div>
|
|
393
|
+
<div class="cost-sub" id="cost-windows">0 context-windows extended</div>
|
|
394
|
+
<div class="stat-grid" style="margin-top:12px">
|
|
395
|
+
<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>
|
|
396
|
+
<span class="label" title="The provider serving the model.">Provider</span><span class="value" id="md-provider">—</span>
|
|
397
|
+
<span class="label" title="USD per input token, from the model's pricing.">Input Rate</span><span class="value" id="md-input">—</span>
|
|
398
|
+
<span class="label" title="USD per output token, from the model's pricing.">Output Rate</span><span class="value" id="md-output">—</span>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
346
401
|
<div class="card">
|
|
347
402
|
<h2>Crew / Agents</h2>
|
|
348
403
|
<div class="stat-grid">
|
|
@@ -370,9 +425,40 @@ function dashboardHtml(tierName: string): string {
|
|
|
370
425
|
<div class="events-wrap" id="events"><div class="empty">connecting…</div></div>
|
|
371
426
|
</div>
|
|
372
427
|
|
|
428
|
+
<h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">All Repositories</h2>
|
|
429
|
+
<table class="repos">
|
|
430
|
+
<thead>
|
|
431
|
+
<tr>
|
|
432
|
+
<th>Repo</th><th>Model</th>
|
|
433
|
+
<th style="text-align:right">Checkpoints</th>
|
|
434
|
+
<th style="text-align:right">Tokens Saved</th>
|
|
435
|
+
<th style="text-align:right">Retained</th>
|
|
436
|
+
<th style="text-align:right">Last Compacted</th>
|
|
437
|
+
</tr>
|
|
438
|
+
</thead>
|
|
439
|
+
<tbody id="cur-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
|
|
440
|
+
</table>
|
|
441
|
+
<div class="updated" id="cur-updated"></div>
|
|
442
|
+
|
|
373
443
|
<div class="updated" id="updated"></div>
|
|
374
444
|
</div><!-- /panel-current -->
|
|
375
445
|
|
|
446
|
+
<!-- Per-repo detail modal -->
|
|
447
|
+
<div class="repo-detail" id="repo-detail">
|
|
448
|
+
<div class="repo-detail-box">
|
|
449
|
+
<h2><span id="rd-name">Repo</span><button class="repo-close" id="rd-close" title="Close">×</button></h2>
|
|
450
|
+
<div class="repo-path" id="rd-path"></div>
|
|
451
|
+
<div class="stat-grid">
|
|
452
|
+
<span class="label">Model</span><span class="value" id="rd-model">—</span>
|
|
453
|
+
<span class="label">Checkpoints</span><span class="value" id="rd-cp">0</span>
|
|
454
|
+
<span class="label">Tokens Saved</span><span class="value" id="rd-saved">0</span>
|
|
455
|
+
<span class="label">Compressed-Original</span><span class="value" id="rd-bytes">0 B</span>
|
|
456
|
+
<span class="label">Last Compacted</span><span class="value" id="rd-when">—</span>
|
|
457
|
+
<span class="label">Provider</span><span class="value" id="rd-provider">—</span>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
376
462
|
<!-- All repos (machine-wide registry from index.sqlite) -->
|
|
377
463
|
<div class="tab-panel" id="panel-all">
|
|
378
464
|
<table class="repos">
|
|
@@ -479,6 +565,24 @@ function dashboardHtml(tierName: string): string {
|
|
|
479
565
|
document.getElementById('cf-auto').textContent = d.config.auto ? 'enabled' : 'disabled';
|
|
480
566
|
document.getElementById('cf-anchor').textContent = d.config.anchorUserMessages;
|
|
481
567
|
|
|
568
|
+
// --- Active model + cost savings (same calc as /mega-status) ---------------
|
|
569
|
+
var model = d.model;
|
|
570
|
+
document.getElementById('hdr-model').textContent = model && model.name ? model.name : '—';
|
|
571
|
+
document.getElementById('md-name').textContent = model && model.name ? model.name : '—';
|
|
572
|
+
document.getElementById('md-provider').textContent = model && model.providerName ? model.providerName : (model && model.provider ? model.provider : '—');
|
|
573
|
+
document.getElementById('md-input').textContent = model && model.inputRate ? '$' + (model.inputRate).toFixed(6) : '—';
|
|
574
|
+
document.getElementById('md-output').textContent = model && model.outputRate ? '$' + (model.outputRate).toFixed(6) : '—';
|
|
575
|
+
if (model && model.inputRate && repo.tokensSaved > 0) {
|
|
576
|
+
var usd = (repo.tokensSaved * model.inputRate);
|
|
577
|
+
var win = d.context.contextWindow || 0;
|
|
578
|
+
var windows = win > 0 ? (repo.tokensSaved / win).toFixed(1) : '0';
|
|
579
|
+
document.getElementById('cost-usd').textContent = '≈ $' + usd.toFixed(4) + ' saved';
|
|
580
|
+
document.getElementById('cost-windows').textContent = windows + ' context-windows extended';
|
|
581
|
+
} else {
|
|
582
|
+
document.getElementById('cost-usd').textContent = '≈ $0.00 saved';
|
|
583
|
+
document.getElementById('cost-windows').textContent = '0 context-windows extended';
|
|
584
|
+
}
|
|
585
|
+
|
|
482
586
|
document.getElementById('updated').textContent = 'Updated ' + new Date(d.updatedAt).toLocaleTimeString();
|
|
483
587
|
}
|
|
484
588
|
|
|
@@ -542,16 +646,16 @@ function dashboardHtml(tierName: string): string {
|
|
|
542
646
|
document.getElementById('sm-saved').textContent = (s.totalTokensSaved || 0).toLocaleString();
|
|
543
647
|
document.getElementById('sm-bytes').textContent = fmtBytesTop(s.totalCompressedOriginalBytes);
|
|
544
648
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
649
|
+
// Shared clickable-row renderer for both the in-current table and the
|
|
650
|
+
// All-repos tab — each row opens the per-repo detail modal.
|
|
651
|
+
function rowsHtml() {
|
|
652
|
+
if (!repos.length) return '<tr><td colspan="6" class="repo-none">No repositories registered yet.</td></tr>';
|
|
653
|
+
return repos.map(function(r) {
|
|
550
654
|
var model = r.modelName
|
|
551
655
|
? '<span class="repo-model">' + sanitize(r.modelName) + '</span>'
|
|
552
656
|
: '<span class="repo-none">—</span>';
|
|
553
657
|
var when = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
|
|
554
|
-
return '<tr>' +
|
|
658
|
+
return '<tr class="repo-link" data-repo="' + sanitize(r.repoRoot) + '">' +
|
|
555
659
|
'<td title="' + sanitize(r.repoRoot) + '">' + sanitize(r.displayName || r.repoRoot) + '</td>' +
|
|
556
660
|
'<td>' + model + '</td>' +
|
|
557
661
|
'<td class="num">' + (r.checkpointCount || 0).toLocaleString() + '</td>' +
|
|
@@ -561,12 +665,48 @@ function dashboardHtml(tierName: string): string {
|
|
|
561
665
|
'</tr>';
|
|
562
666
|
}).join('');
|
|
563
667
|
}
|
|
668
|
+
document.getElementById('cur-rows').innerHTML = rowsHtml();
|
|
669
|
+
document.getElementById('all-rows').innerHTML = rowsHtml();
|
|
670
|
+
bindRepoRows();
|
|
671
|
+
|
|
564
672
|
var stamp = d.updatedAt ? 'Updated ' + new Date(d.updatedAt).toLocaleTimeString() : '';
|
|
673
|
+
document.getElementById('cur-updated').textContent = stamp;
|
|
565
674
|
document.getElementById('all-updated').textContent = stamp;
|
|
566
675
|
document.getElementById('sm-updated').textContent = stamp;
|
|
567
676
|
}
|
|
677
|
+
|
|
678
|
+
// Per-repo detail modal ---------------------------------------------------
|
|
679
|
+
var detailEl = document.getElementById('repo-detail');
|
|
680
|
+
var indexCache = { repos: [] };
|
|
681
|
+
function openRepoDetail(root) {
|
|
682
|
+
var r = null;
|
|
683
|
+
for (var i = 0; i < indexCache.repos.length; i++) {
|
|
684
|
+
if (indexCache.repos[i].repoRoot === root) { r = indexCache.repos[i]; break; }
|
|
685
|
+
}
|
|
686
|
+
if (!r) return;
|
|
687
|
+
document.getElementById('rd-name').textContent = r.displayName || r.repoRoot;
|
|
688
|
+
document.getElementById('rd-path').textContent = r.repoRoot;
|
|
689
|
+
document.getElementById('rd-model').textContent = r.modelName || '—';
|
|
690
|
+
document.getElementById('rd-provider').textContent = r.providerName || (r.provider || '—');
|
|
691
|
+
document.getElementById('rd-cp').textContent = (r.checkpointCount || 0).toLocaleString();
|
|
692
|
+
document.getElementById('rd-saved').textContent = (r.tokensSaved || 0).toLocaleString();
|
|
693
|
+
document.getElementById('rd-bytes').textContent = fmtBytesTop(r.compressedOriginalBytes);
|
|
694
|
+
document.getElementById('rd-when').textContent = r.lastCompactedAt ? new Date(r.lastCompactedAt).toLocaleString() : '—';
|
|
695
|
+
detailEl.classList.add('open');
|
|
696
|
+
}
|
|
697
|
+
document.getElementById('rd-close').addEventListener('click', function() { detailEl.classList.remove('open'); });
|
|
698
|
+
detailEl.addEventListener('click', function(e) { if (e.target === detailEl) detailEl.classList.remove('open'); });
|
|
699
|
+
function bindRepoRows() {
|
|
700
|
+
var rows = document.querySelectorAll('.repo-link');
|
|
701
|
+
for (var i = 0; i < rows.length; i++) {
|
|
702
|
+
rows[i].addEventListener('click', function() { openRepoDetail(this.getAttribute('data-repo')); });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
568
705
|
function pollIndex() {
|
|
569
|
-
fetch('/api/index').then(function(r) { return r.json(); }).then(
|
|
706
|
+
fetch('/api/index').then(function(r) { return r.json(); }).then(function(d) {
|
|
707
|
+
indexCache = d && d.repos ? d : indexCache;
|
|
708
|
+
renderIndex(d);
|
|
709
|
+
}).catch(function() {});
|
|
570
710
|
}
|
|
571
711
|
pollIndex();
|
|
572
712
|
setInterval(pollIndex, 5000);
|
|
@@ -599,6 +739,21 @@ function dashboardHtml(tierName: string): string {
|
|
|
599
739
|
// ---------------------------------------------------------------------------
|
|
600
740
|
|
|
601
741
|
export function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
|
|
742
|
+
// Our own package version — exposed at /api/version so the launcher can
|
|
743
|
+
// detect a stale server (started by an older build) and replace it on
|
|
744
|
+
// upgrade instead of reuse it.
|
|
745
|
+
let SERVER_VERSION = "0.0.0";
|
|
746
|
+
try {
|
|
747
|
+
// dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
|
|
748
|
+
// two levels up. Guard each candidate so a dev-checkout layout still works.
|
|
749
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
750
|
+
const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
|
|
751
|
+
for (const p of candidates) {
|
|
752
|
+
if (!existsSync(p)) continue;
|
|
753
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
754
|
+
if (pkg.version) { SERVER_VERSION = pkg.version; break; }
|
|
755
|
+
}
|
|
756
|
+
} catch { /* non-fatal */ }
|
|
602
757
|
const portFile = join(stateDir, "port.pid");
|
|
603
758
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
604
759
|
const eventsPath = join(stateDir, "events.log");
|
|
@@ -646,6 +801,14 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
|
|
|
646
801
|
return;
|
|
647
802
|
}
|
|
648
803
|
|
|
804
|
+
// Server version — lets the /dashboard launcher detect a stale server from
|
|
805
|
+
// an older build and replace it on upgrade rather than reuse it.
|
|
806
|
+
if (req.url === "/api/version") {
|
|
807
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
808
|
+
res.end(JSON.stringify({ version: SERVER_VERSION }));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
649
812
|
// Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
|
|
650
813
|
// directly from SQLite (index.sqlite). Lets one dashboard show every repo's
|
|
651
814
|
// checkpoints, tokens saved, and active model. Read-only.
|
|
@@ -22,6 +22,9 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
22
22
|
|
|
23
23
|
const require = createRequire(import.meta.url);
|
|
24
24
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-ext-"));
|
|
25
|
+
// Isolate the machine-wide repo index so test runs (which call bindRepo ->
|
|
26
|
+
// upsertRepoRegistry) never pollute the developer's real ~/.mega-compact-index.
|
|
27
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
|
|
25
28
|
let counter = 0;
|
|
26
29
|
|
|
27
30
|
/** Build a mock pi + ctx and load the extension into them. */
|
|
@@ -297,13 +300,14 @@ test("/dashboard-stop reports no server when pid file missing", async () => {
|
|
|
297
300
|
test("/dashboard skips server spawn when already running", async () => {
|
|
298
301
|
const h = harness();
|
|
299
302
|
const confirms: boolean[] = [];
|
|
300
|
-
// Set up a fake HTTP server
|
|
303
|
+
// Set up a fake HTTP server on a port inside the dashboard's scan range
|
|
304
|
+
// (9320–9329) — isServerRunning() probes those ports, not the port.pid value.
|
|
301
305
|
const { createServer } = await import("node:http");
|
|
302
306
|
const server = createServer((_req, res) => {
|
|
303
307
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
304
308
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test", version: 1, config: {}, session: {}, context: {}, trigger: {}, store: {} }));
|
|
305
309
|
});
|
|
306
|
-
await new Promise<void>((r) => server.listen(
|
|
310
|
+
await new Promise<void>((r) => server.listen(9320, "127.0.0.1", r));
|
|
307
311
|
const addr = server.address() as any;
|
|
308
312
|
const { join: j } = await import("node:path");
|
|
309
313
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -328,7 +332,8 @@ test("/dashboard skips server spawn when already running", async () => {
|
|
|
328
332
|
|
|
329
333
|
test("/dashboard-status reports running after dashboard start", async () => {
|
|
330
334
|
const h = harness();
|
|
331
|
-
// Write a fake port.pid
|
|
335
|
+
// Write a fake port.pid; the server must listen inside the scan range
|
|
336
|
+
// (9320–9329) or isServerRunning() won't detect it.
|
|
332
337
|
const { createServer } = await import("node:http");
|
|
333
338
|
const { join: j } = await import("node:path");
|
|
334
339
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -336,7 +341,7 @@ test("/dashboard-status reports running after dashboard start", async () => {
|
|
|
336
341
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
337
342
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
|
|
338
343
|
});
|
|
339
|
-
await new Promise<void>((r) => server.listen(
|
|
344
|
+
await new Promise<void>((r) => server.listen(9321, "127.0.0.1", r));
|
|
340
345
|
const addr = server.address() as any;
|
|
341
346
|
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: addr.port, pid: process.pid }));
|
|
342
347
|
|
|
@@ -36,8 +36,11 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** Try to reach a running dashboard server. Returns
|
|
40
|
-
|
|
39
|
+
/** Try to reach a running dashboard server. Returns details or null.
|
|
40
|
+
* `hasPidFile` tells the caller whether this server was launched by us
|
|
41
|
+
* (port.pid present) — a live server with NO pid file is an orphan from an
|
|
42
|
+
* older/detached spawn that we should replace rather than reuse. */
|
|
43
|
+
async function isServerRunning(): Promise<{ port: number; url: string; hasPidFile: boolean } | null> {
|
|
41
44
|
const port = await findLivePort();
|
|
42
45
|
if (!port) {
|
|
43
46
|
// Stale marker with no live server behind it — clean up.
|
|
@@ -46,7 +49,59 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
|
|
|
46
49
|
}
|
|
47
50
|
return null;
|
|
48
51
|
}
|
|
49
|
-
return { port, url: `http://localhost:${port}
|
|
52
|
+
return { port, url: `http://localhost:${port}`, hasPidFile: existsSync(portFile) }; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Version the running server on `port` reports, or null. */
|
|
56
|
+
async function serverVersion(port: number): Promise<string | null> {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`http://localhost:${port}/api/version`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: localhost version probe of the dashboard server this extension spawned
|
|
59
|
+
if (!res.ok) return null;
|
|
60
|
+
const j = await res.json() as { version?: string };
|
|
61
|
+
return j.version ?? null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Version of THIS extension (read from its own package.json). */
|
|
68
|
+
function ownVersion(): string | null {
|
|
69
|
+
try {
|
|
70
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
|
|
71
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
72
|
+
return pkg.version ?? null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** PID listening on 127.0.0.1:port (our own server), or null. Uses `ss`
|
|
79
|
+
* (Linux/macOS) — best-effort, returns null if unavailable. */
|
|
80
|
+
function pidOnPort(port: number): number | null {
|
|
81
|
+
try {
|
|
82
|
+
const { execSync } = require("node:child_process"); // guardrails-allow PREVENT-PI-004: localhost-only, reads our own dashboard port owner
|
|
83
|
+
const out = execSync(`ss -ltnp 2>/dev/null | grep ':${port} '`, { encoding: "utf-8" });
|
|
84
|
+
const m = out.match(/pid=(\d+)/);
|
|
85
|
+
return m ? Number(m[1]) : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Kill a running dashboard server (best-effort): read the pid from port.pid,
|
|
92
|
+
* or — when there's no marker (an orphan) — from the port owner. Then remove
|
|
93
|
+
* the marker so the next spawn starts fresh. */
|
|
94
|
+
function killServerOnPort(port: number): void {
|
|
95
|
+
let pid: number | null = null;
|
|
96
|
+
try {
|
|
97
|
+
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
98
|
+
if (info && info.pid) pid = info.pid;
|
|
99
|
+
} catch { /* no marker */ }
|
|
100
|
+
if (pid == null) pid = pidOnPort(port); // orphan with no pid.pid
|
|
101
|
+
if (pid != null) {
|
|
102
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
|
|
103
|
+
}
|
|
104
|
+
try { unlinkSync(portFile); } catch { /* ignore */ }
|
|
50
105
|
}
|
|
51
106
|
|
|
52
107
|
/**
|
|
@@ -122,10 +177,29 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
|
|
|
122
177
|
let info = await isServerRunning();
|
|
123
178
|
|
|
124
179
|
if (info) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
180
|
+
// Replace the server when it's stale: either (a) an orphan — a live
|
|
181
|
+
// server with no port.pid (e.g. left running from a detached spawn or a
|
|
182
|
+
// previous upgrade) that keeps serving old HTML from memory; or (b) a
|
|
183
|
+
// server that reports a different version than this extension (an older
|
|
184
|
+
// build). A live server WITH a matching pid file and version is reused.
|
|
185
|
+
const orphan = !info.hasPidFile;
|
|
186
|
+
const running = await serverVersion(info.port);
|
|
187
|
+
const want = ownVersion();
|
|
188
|
+
const stale = orphan || (want != null && running != null && running !== want);
|
|
189
|
+
if (stale) {
|
|
190
|
+
ctx.ui.notify(
|
|
191
|
+
orphan
|
|
192
|
+
? "[mega-compact] replacing orphaned dashboard server…"
|
|
193
|
+
: `[mega-compact] replacing stale dashboard (${running} → ${want})…`,
|
|
194
|
+
);
|
|
195
|
+
killServerOnPort(info.port);
|
|
196
|
+
info = null;
|
|
197
|
+
} else {
|
|
198
|
+
ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
|
|
199
|
+
const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
|
|
200
|
+
if (open) openBrowser(info.url);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
129
203
|
}
|
|
130
204
|
|
|
131
205
|
// Start the server
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.20",
|
|
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",
|