pi-mega-compact 0.7.6 → 0.7.8

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * sqlite.cachehit.test.ts — tests for the live dashboard counters
3
+ * (incCompactCount / getCompactCount, incRecallInjected / getRecallInjected,
4
+ * incCacheHitTokens / getCacheHitTokensSaved). These reuse the schemaless
5
+ * `meta` integer counter, so no migration is required and the same on-disk
6
+ * SQLite store persists the tallies across (re)opens.
7
+ */
8
+ import { describe, it, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { openStore, incCompactCount, getCompactCount, incRecallInjected, getRecallInjected, incCacheHitTokens, getCacheHitTokensSaved, } from "./sqlite.js";
14
+ describe("live dashboard counters (meta integers)", () => {
15
+ let dir;
16
+ beforeEach(() => {
17
+ dir = mkdtempSync(join(tmpdir(), "cachehit-test-"));
18
+ });
19
+ afterEach(() => {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ });
22
+ it("increments + reads compact_count and persists on reopen", () => {
23
+ incCompactCount(dir);
24
+ incCompactCount(dir);
25
+ assert.equal(getCompactCount(dir), 2);
26
+ // Ensure the store handle is (re)opened and the value is read back from disk.
27
+ openStore(dir);
28
+ assert.equal(getCompactCount(dir), 2);
29
+ });
30
+ it("accumulates recall injections + cache-hit tokens", () => {
31
+ incRecallInjected(3, dir);
32
+ incRecallInjected(2, dir);
33
+ assert.equal(getRecallInjected(dir), 5);
34
+ incCacheHitTokens(1200, dir);
35
+ incCacheHitTokens(800, dir);
36
+ assert.equal(getCacheHitTokensSaved(dir), 2000);
37
+ });
38
+ it("ignores non-positive increments (no-op)", () => {
39
+ incRecallInjected(0, dir);
40
+ incRecallInjected(-5, dir);
41
+ incCacheHitTokens(0, dir);
42
+ assert.equal(getRecallInjected(dir), 0);
43
+ assert.equal(getCacheHitTokensSaved(dir), 0);
44
+ });
45
+ it("persists all three counters across a fresh openStore handle", () => {
46
+ incCompactCount(dir);
47
+ incRecallInjected(4, dir);
48
+ incCacheHitTokens(500, dir);
49
+ // openStore returns the canonical (on-disk) handle; reading back proves durability.
50
+ openStore(dir);
51
+ assert.equal(getCompactCount(dir), 1);
52
+ assert.equal(getRecallInjected(dir), 4);
53
+ assert.equal(getCacheHitTokensSaved(dir), 500);
54
+ });
55
+ });
@@ -594,6 +594,29 @@ export function bumpDedupStats(deduped, stateDir = getStateDir()) {
594
594
  if (deduped)
595
595
  incMeta("deduped", 1, stateDir);
596
596
  }
597
+ // --- Live dashboard counters (schemaless meta key/value — NO migration) -----
598
+ // These reuse the private `incMeta` atomically-incrementing integer counter so
599
+ // all cumulative tallies live in the same `meta` table as tokens_saved etc.
600
+ export function incCompactCount(stateDir = getStateDir()) {
601
+ incMeta("compact_count", 1, stateDir);
602
+ }
603
+ export function getCompactCount(stateDir = getStateDir()) {
604
+ return getMetaNumber("compact_count", stateDir);
605
+ }
606
+ export function incRecallInjected(n, stateDir = getStateDir()) {
607
+ if (n > 0)
608
+ incMeta("recall_injected", n, stateDir);
609
+ }
610
+ export function getRecallInjected(stateDir = getStateDir()) {
611
+ return getMetaNumber("recall_injected", stateDir);
612
+ }
613
+ export function incCacheHitTokens(delta, stateDir = getStateDir()) {
614
+ if (delta > 0)
615
+ incMeta("cache_hit_tokens_saved", delta, stateDir);
616
+ }
617
+ export function getCacheHitTokensSaved(stateDir = getStateDir()) {
618
+ return getMetaNumber("cache_hit_tokens_saved", stateDir);
619
+ }
597
620
  // --- Future-feature foundation (resume sessions / daily log / lessons) -------
598
621
  // Scaffolded tables + minimal helpers so all store data lives in SQLite from
599
622
  // day one. Full UI/recall for these lands in later sprints.
@@ -228,6 +228,75 @@ describe("multi-repo /api/index (S19)", () => {
228
228
  });
229
229
  });
230
230
 
231
+ describe("multi-repo /api/servers (active cache-hit stats)", () => {
232
+ test("returns active repos with live dashboard.json cache-hit/compaction stats", async () => {
233
+ const dir = mkdtempSync(join(tmpdir(), "dash-servers-"));
234
+ const indexDir = mkdtempSync(join(tmpdir(), "index-servers-"));
235
+ process.env.MEGACOMPACT_INDEX_DIR = indexDir;
236
+ process.env.MEGACOMPACT_DASHBOARD_PORT = "19323";
237
+
238
+ const { upsertRepoRegistry } = await import("../src/store/sqlite.js");
239
+
240
+ const activeState = mkdtempSync(join(tmpdir(), "srv-active-"));
241
+ writeFileSync(join(activeState, "dashboard.json"), JSON.stringify({
242
+ updatedAt: new Date().toISOString(),
243
+ tier: "high",
244
+ context: { tokens: 5000, percent: 0.42, contextWindow: 12000 },
245
+ session: { id: "s1", state: "idle" },
246
+ cacheHits: { session: 3, total: 7, sessionTokensSaved: 1200, totalTokensSaved: 9000 },
247
+ compacts: { session: 2, total: 5 },
248
+ timeSaved: { compact: { sessionSec: 1.5, totalSec: 4 }, cacheHit: { sessionSec: 0.6, totalSec: 4.5 } },
249
+ }, null, 2));
250
+ upsertRepoRegistry(
251
+ { repoRoot: "/home/u/active", displayName: "active", stateDir: activeState, checkpointCount: 4, tokensSaved: 9000, compressedOriginalBytes: 0, lastSeen: Math.floor(Date.now() / 1000), modelName: "gpt-4o", providerName: "OpenAI" },
252
+ indexDir,
253
+ );
254
+
255
+ const staleState = mkdtempSync(join(tmpdir(), "srv-stale-"));
256
+ writeFileSync(join(staleState, "dashboard.json"), JSON.stringify({ updatedAt: new Date().toISOString(), tier: "low" }, null, 2));
257
+ const longAgo = Math.floor(Date.now() / 1000) - 7 * 86_400;
258
+ upsertRepoRegistry(
259
+ { repoRoot: "/home/u/stale", displayName: "stale", stateDir: staleState, checkpointCount: 1, tokensSaved: 100, compressedOriginalBytes: 0, lastSeen: longAgo },
260
+ indexDir,
261
+ );
262
+
263
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
264
+ try {
265
+ await waitFor(async () => {
266
+ try {
267
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
268
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
269
+ return res.ok;
270
+ } catch { return false; }
271
+ });
272
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
273
+ const body = (await fetch(`http://localhost:${raw.port}/api/servers`).then((r) => r.json())) as {
274
+ updatedAt: string;
275
+ servers: Array<Record<string, unknown>>;
276
+ };
277
+ assert.equal(body.servers.length, 1, "only the active repo is returned");
278
+ const s = body.servers[0];
279
+ assert.equal(s.displayName, "active");
280
+ assert.equal(s.tier, "high");
281
+ assert.equal(s.model, "gpt-4o");
282
+ assert.equal(s.provider, "OpenAI");
283
+ assert.equal(s.contextPct, 0.42);
284
+ assert.equal(s.state, "idle");
285
+ assert.deepEqual(s.cacheHits, { session: 3, total: 7, sessionTokensSaved: 1200, totalTokensSaved: 9000 });
286
+ assert.deepEqual(s.compacts, { session: 2, total: 5 });
287
+ const ts = s.timeSaved as { compact: { sessionSec: number; totalSec: number }; cacheHit: { sessionSec: number; totalSec: number } };
288
+ assert.equal(ts.compact.sessionSec, 1.5);
289
+ assert.equal(ts.cacheHit.sessionSec, 0.6);
290
+ } finally {
291
+ child.kill("SIGTERM");
292
+ delete process.env.MEGACOMPACT_INDEX_DIR;
293
+ delete process.env.MEGACOMPACT_DASHBOARD_PORT;
294
+ rmSync(dir, { recursive: true, force: true });
295
+ rmSync(indexDir, { recursive: true, force: true });
296
+ }
297
+ });
298
+ });
299
+
231
300
  // ---------------------------------------------------------------------------
232
301
  // Lifecycle integration — launch the compiled server as a real subprocess
233
302
  // (the same way the /dashboard command spawns it) and assert the two failure
@@ -46,6 +46,8 @@ function log(...parts: unknown[]): void {
46
46
  // concurrent writer's WAL never blocks the request). All registry data lives in
47
47
  // SQLite (the project's one-store invariant) — there is no JSON mirror. Same
48
48
  // index-dir resolution as src/store/sqlite.ts getIndexDir().
49
+ const ACTIVE_WINDOW_SEC = 1800;
50
+
49
51
  function getIndexDir(): string {
50
52
  const override = process.env.MEGACOMPACT_INDEX_DIR;
51
53
  if (override && override.trim() !== "") return override;
@@ -266,6 +268,20 @@ interface Snapshot {
266
268
  duplicatesCollapsed: number;
267
269
  bytesPermanentlyDeleted: number;
268
270
  };
271
+ cacheHits: {
272
+ session: number;
273
+ total: number;
274
+ sessionTokensSaved: number;
275
+ totalTokensSaved: number;
276
+ };
277
+ compacts: {
278
+ session: number;
279
+ total: number;
280
+ };
281
+ timeSaved: {
282
+ compact: { sessionSec: number; totalSec: number };
283
+ cacheHit: { sessionSec: number; totalSec: number };
284
+ };
269
285
  compression: {
270
286
  session: { tokensIn: number; tokensOut: number; tokensFreed: number; compressionPct: number; dedupPct: number };
271
287
  repo: { tokensIn: number; tokensOut: number; tokensFreed: number; compressionPct: number; dedupPct: number };
@@ -283,6 +299,8 @@ interface Snapshot {
283
299
  // Helpers
284
300
  // ---------------------------------------------------------------------------
285
301
 
302
+ interface LiveSnapshot { tier?: string; updatedAt?: string | null; context?: { tokens?: number | null; percent?: number | null; contextWindow?: number }; session?: { id?: string; state?: string }; cacheHits?: { session: number; total: number; sessionTokensSaved: number; totalTokensSaved: number }; compacts?: { session: number; total: number }; timeSaved?: { compact: { sessionSec: number; totalSec: number }; cacheHit: { sessionSec: number; totalSec: number } }; }
303
+
286
304
  function readSnapshot(snapshotPath: string) {
287
305
  try {
288
306
  const raw = readFileSync(snapshotPath, "utf-8");
@@ -302,6 +320,9 @@ function readSnapshot(snapshotPath: string) {
302
320
  crew: { activeAgents: 0, currentTurn: 0 },
303
321
  repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
304
322
  integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
323
+ cacheHits: { session: 0, total: 0, sessionTokensSaved: 0, totalTokensSaved: 0 },
324
+ compacts: { session: 0, total: 0 },
325
+ timeSaved: { compact: { sessionSec: 0, totalSec: 0 }, cacheHit: { sessionSec: 0, totalSec: 0 } },
305
326
  compression: { session: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 }, repo: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 } },
306
327
  model: undefined,
307
328
  } as Snapshot;
@@ -426,6 +447,7 @@ function dashboardHtml(tierName: string): string {
426
447
  <nav class="tabs">
427
448
  <button class="tab active" data-tab="current">Current repo</button>
428
449
  <button class="tab" data-tab="all">All repos</button>
450
+ <button class="tab" data-tab="active">Active Repos</button>
429
451
  <button class="tab" data-tab="summary">Summary</button>
430
452
  </nav>
431
453
 
@@ -527,6 +549,26 @@ function dashboardHtml(tierName: string): string {
527
549
  </ul>
528
550
  <p class="legend-note">Hover any label above for a quick explanation.</p>
529
551
  </div>
552
+ <div class="card">
553
+ <h2>💾 Cache Hits &amp; Compactions</h2>
554
+ <div class="stat-grid">
555
+ <span class="label">Cache Hits (session)</span><span class="value" id="ch-session">0</span>
556
+ <span class="label">Cache Hits (total)</span><span class="value" id="ch-total">0</span>
557
+ <span class="label">Tokens Saved (session)</span><span class="value" id="ch-tok-session">0</span>
558
+ <span class="label">Tokens Saved (total)</span><span class="value" id="ch-tok-total">0</span>
559
+ <span class="label">Compactions (session)</span><span class="value" id="cp-session">0</span>
560
+ <span class="label">Compactions (total)</span><span class="value" id="cp-total">0</span>
561
+ </div>
562
+ </div>
563
+ <div class="card">
564
+ <h2>⏱ Time Saved (est.)</h2>
565
+ <div class="stat-grid">
566
+ <span class="label">Compact (session)</span><span class="value" id="ts-compact-session">0</span>
567
+ <span class="label">Compact (total)</span><span class="value" id="ts-compact-total">0</span>
568
+ <span class="label">Cache Hit (session)</span><span class="value" id="ts-cache-session">0</span>
569
+ <span class="label">Cache Hit (total)</span><span class="value" id="ts-cache-total">0</span>
570
+ </div>
571
+ </div>
530
572
  </div>
531
573
 
532
574
  <div class="events">
@@ -552,6 +594,28 @@ function dashboardHtml(tierName: string): string {
552
594
  <div class="updated" id="updated"></div>
553
595
  </div><!-- /panel-current -->
554
596
 
597
+ <!-- Active repos (live cache-hit / compaction stats across machines) -->
598
+ <div class="tab-panel" id="panel-active">
599
+ <div class="card">
600
+ <h2>Active Repos — Live Cache Hits &amp; Compactions</h2>
601
+ <p class="legend-note">Repos seen within the last 30 minutes, with their per-repo cache-hit, compaction, and time-saved (est.) totals pulled live from each repo's dashboard.json.</p>
602
+ <table class="repos">
603
+ <thead>
604
+ <tr>
605
+ <th>Repo</th><th>Model</th><th>Tier</th>
606
+ <th style="text-align:right">Context %</th><th>State</th>
607
+ <th style="text-align:right">Compactions (s/t)</th>
608
+ <th style="text-align:right">Cache Hits (s/t)</th>
609
+ <th style="text-align:right">Compact s/t (s)</th>
610
+ <th style="text-align:right">CacheHit s/t (s)</th>
611
+ </tr>
612
+ </thead>
613
+ <tbody id="active-rows"><tr><td colspan="9" class="repo-none">loading…</td></tr></tbody>
614
+ </table>
615
+ <div class="updated" id="active-updated"></div>
616
+ </div>
617
+ </div>
618
+
555
619
  <!-- Per-repo detail modal -->
556
620
  <div class="repo-detail" id="repo-detail">
557
621
  <div class="repo-detail-box">
@@ -746,6 +810,21 @@ function dashboardHtml(tierName: string): string {
746
810
  document.getElementById('cost-windows').textContent = '0 context-windows extended';
747
811
  }
748
812
 
813
+ // --- Cache hits & compactions (live counters) ---------------------------
814
+ var ch = d.cacheHits || { session: 0, total: 0, sessionTokensSaved: 0, totalTokensSaved: 0 };
815
+ var cp = d.compacts || { session: 0, total: 0 };
816
+ var ts = d.timeSaved || { compact: { sessionSec: 0, totalSec: 0 }, cacheHit: { sessionSec: 0, totalSec: 0 } };
817
+ document.getElementById('ch-session').textContent = (ch.session || 0).toLocaleString();
818
+ document.getElementById('ch-total').textContent = (ch.total || 0).toLocaleString();
819
+ document.getElementById('ch-tok-session').textContent = (ch.sessionTokensSaved || 0).toLocaleString();
820
+ document.getElementById('ch-tok-total').textContent = (ch.totalTokensSaved || 0).toLocaleString();
821
+ document.getElementById('cp-session').textContent = (cp.session || 0).toLocaleString();
822
+ document.getElementById('cp-total').textContent = (cp.total || 0).toLocaleString();
823
+ document.getElementById('ts-compact-session').textContent = fmtSec(ts.compact.sessionSec);
824
+ document.getElementById('ts-compact-total').textContent = fmtSec(ts.compact.totalSec);
825
+ document.getElementById('ts-cache-session').textContent = fmtSec(ts.cacheHit.sessionSec);
826
+ document.getElementById('ts-cache-total').textContent = fmtSec(ts.cacheHit.totalSec);
827
+
749
828
  document.getElementById('updated').textContent = 'Updated ' + new Date(d.updatedAt).toLocaleTimeString();
750
829
  }
751
830
 
@@ -951,9 +1030,51 @@ function dashboardHtml(tierName: string): string {
951
1030
  pollIndex();
952
1031
  setInterval(pollIndex, 5000);
953
1032
 
1033
+ // --- Active repos (live cache-hit / compaction stats) ---------------------
1034
+ function fmtSec(s) {
1035
+ s = s || 0;
1036
+ if (s >= 3600) return (s / 3600).toFixed(1) + 'h';
1037
+ if (s >= 60) return Math.round(s / 60) + 'm';
1038
+ if (s >= 1) return s.toFixed(1) + 's';
1039
+ return Math.round(s * 1000) + 'ms';
1040
+ }
1041
+ function renderActiveRepos(d) {
1042
+ d = d || { updatedAt: null, servers: [] };
1043
+ var servers = d.servers || [];
1044
+ var rowsEl = document.getElementById('active-rows');
1045
+ if (!rowsEl) return;
1046
+ if (!servers.length) {
1047
+ rowsEl.innerHTML = '<tr><td colspan="9" class="repo-none">No active repositories.</td></tr>';
1048
+ } else {
1049
+ rowsEl.innerHTML = servers.map(function(r) {
1050
+ var ch = r.cacheHits || { session: 0, total: 0, sessionTokensSaved: 0, totalTokensSaved: 0 };
1051
+ var cp = r.compacts || { session: 0, total: 0 };
1052
+ var ts = r.timeSaved || { compact: { sessionSec: 0, totalSec: 0 }, cacheHit: { sessionSec: 0, totalSec: 0 } };
1053
+ return '<tr>' +
1054
+ '<td title="' + sanitize(r.repoRoot) + '">' + sanitize(r.displayName || r.repoRoot) + '</td>' +
1055
+ '<td>' + sanitize(r.model || '—') + '</td>' +
1056
+ '<td>' + sanitize(r.tier || '—') + '</td>' +
1057
+ '<td class="num">' + (r.contextPct != null ? Math.round(r.contextPct * 100) + '%' : '—') + '</td>' +
1058
+ '<td>' + sanitize(r.state || '—') + '</td>' +
1059
+ '<td class="num">' + (cp.session || 0) + ' / ' + (cp.total || 0) + '</td>' +
1060
+ '<td class="num">' + (ch.session || 0) + ' / ' + (ch.total || 0) + '</td>' +
1061
+ '<td class="num">' + fmtSec(ts.compact.sessionSec) + ' / ' + fmtSec(ts.compact.totalSec) + '</td>' +
1062
+ '<td class="num">' + fmtSec(ts.cacheHit.sessionSec) + ' / ' + fmtSec(ts.cacheHit.totalSec) + '</td>' +
1063
+ '</tr>';
1064
+ }).join('');
1065
+ }
1066
+ var upd = document.getElementById('active-updated');
1067
+ if (upd) upd.textContent = d.updatedAt ? 'Updated ' + new Date(d.updatedAt).toLocaleTimeString() : '';
1068
+ }
1069
+ function pollServers() {
1070
+ fetch('/api/servers').then(function(r) { return r.json(); }).then(renderActiveRepos).catch(function() {});
1071
+ }
1072
+ pollServers();
1073
+ setInterval(pollServers, 5000);
1074
+
954
1075
  // --- Tab switching ------------------------------------------------------
955
1076
  var tabs = document.querySelectorAll('.tab');
956
- var panels = { current: 'panel-current', all: 'panel-all', summary: 'panel-summary' };
1077
+ var panels = { current: 'panel-current', all: 'panel-all', active: 'panel-active', summary: 'panel-summary' };
957
1078
  for (var i = 0; i < tabs.length; i++) {
958
1079
  tabs[i].addEventListener('click', function() {
959
1080
  var name = this.getAttribute('data-tab');
@@ -966,6 +1087,7 @@ function dashboardHtml(tierName: string): string {
966
1087
  }
967
1088
  }
968
1089
  if (name === 'all' || name === 'summary') pollIndex();
1090
+ if (name === 'active') pollServers();
969
1091
  });
970
1092
  }
971
1093
  })();
@@ -1170,6 +1292,24 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
1170
1292
  return;
1171
1293
  }
1172
1294
 
1295
+ if (req.url === "/api/servers") {
1296
+ try {
1297
+ const idx = readIndex();
1298
+ const nowSec = Math.floor(Date.now() / 1000);
1299
+ const servers = (idx?.repos ?? []).filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC).map((r) => {
1300
+ const out: Record<string, unknown> = { repoRoot: r.repoRoot, displayName: r.displayName, model: r.modelName, provider: r.providerName, lastSeen: r.lastSeen, lastCompactedAt: r.lastCompactedAt };
1301
+ try { const p = join(r.stateDir, "dashboard.json"); if (existsSync(p)) { const snap = JSON.parse(readFileSync(p, "utf-8")) as LiveSnapshot; out.tier = snap.tier ?? null; out.contextPct = (snap.context && snap.context.percent != null) ? snap.context.percent : null; out.state = (snap.session && snap.session.state) || null; out.cacheHits = snap.cacheHits ?? null; out.compacts = snap.compacts ?? null; out.timeSaved = snap.timeSaved ?? null; out.updatedAt = snap.updatedAt ?? null; } } catch { /* best-effort */ }
1302
+ return out;
1303
+ }).sort((a, b) => (b.lastSeen as number) - (a.lastSeen as number));
1304
+ res.writeHead(200, { "Content-Type": "application/json" });
1305
+ res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
1306
+ } catch {
1307
+ res.writeHead(500, { "Content-Type": "application/json" });
1308
+ res.end(JSON.stringify({ error: "servers_unavailable" }));
1309
+ }
1310
+ return;
1311
+ }
1312
+
1173
1313
  if (req.url === "/api/events") {
1174
1314
  res.writeHead(200, {
1175
1315
  "Content-Type": "text/event-stream",