pi-mega-compact 0.7.6 → 0.7.7

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",
@@ -114,6 +114,23 @@ export interface DashboardSnapshot {
114
114
  duplicatesCollapsed: number; // dedup duplicates (original kept on survivor)
115
115
  bytesPermanentlyDeleted: number; // ALWAYS 0 — the invariant
116
116
  };
117
+ /** Cache-hit / recall-injection counters (live session + store-wide totals). */
118
+ cacheHits: {
119
+ session: number; // dedup skips + recall injections this session
120
+ total: number; // store-wide deduped collapses + recall injections
121
+ sessionTokensSaved: number; // tokens saved via cache hits this session
122
+ totalTokensSaved: number; // store-wide tokens saved via cache hits
123
+ };
124
+ /** Compaction counters (live session + store-wide cumulative). */
125
+ compacts: {
126
+ session: number; // compactions performed this session
127
+ total: number; // store-wide cumulative compaction count
128
+ };
129
+ /** Estimated wall-clock time saved (rough tokens/sec heuristic). */
130
+ timeSaved: {
131
+ compact: { sessionSec: number; totalSec: number };
132
+ cacheHit: { sessionSec: number; totalSec: number };
133
+ };
117
134
  /** Active model/provider (captured live) — shown on the current-repo card. */
118
135
  model?: {
119
136
  name: string; // Model.name or Model.id
@@ -15,7 +15,7 @@ import type { EngineMessage } from "../src/types.js";
15
15
  import { recallAndInline, recallAndInlineAsync, formatRecallBlock, type RecallInjectResult } from "../src/recall.js";
16
16
  import { normalizeSessionId } from "../src/store.js";
17
17
  import { estimateBlockTokens } from "../src/tokens.js";
18
- import { touchSession, logDaily } from "../src/store/sqlite.js";
18
+ import { touchSession, logDaily, incCompactCount, incRecallInjected, incCacheHitTokens } from "../src/store/sqlite.js";
19
19
  import { consolidateMemories } from "../src/memory.js";
20
20
  import {
21
21
  type MegaRuntime,
@@ -146,6 +146,9 @@ function doCompact(
146
146
  ? result.originalTokenEstimate
147
147
  : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
148
148
  runtime.rt.tokensSaved += saved;
149
+ runtime.rt.compactCount += 1;
150
+ incCompactCount(runtime.currentStateDir);
151
+ if (result.deduped) { runtime.rt.cacheHitTokens += saved; incCacheHitTokens(saved, runtime.currentStateDir); }
149
152
  runtime.rt.lastCompactAt = Date.now();
150
153
  if (result.deduped) runtime.rt.dedupSkips++;
151
154
  // Grow the rolling "saved" goal so the progress bar always has a fresh
@@ -435,6 +438,13 @@ export function doRecall(
435
438
  runtime.pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
436
439
  runtime.lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
437
440
  }
441
+ if (result.toInject.length > 0) {
442
+ let sumTokens = 0; for (const h of result.toInject) sumTokens += h.checkpoint.tokenEstimate;
443
+ runtime.rt.recallInjections += result.toInject.length;
444
+ runtime.rt.cacheHitTokens += sumTokens;
445
+ incRecallInjected(result.toInject.length, runtime.currentStateDir);
446
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
447
+ }
438
448
  return result;
439
449
  }
440
450
 
@@ -493,6 +503,13 @@ export async function doRecallAsync(
493
503
  if (!seen.has(h.checkpoint.checkpointId)) { merged.push(h); seen.add(h.checkpoint.checkpointId); }
494
504
  }
495
505
  const block = merged.length ? formatRecallBlock(merged) : "";
506
+ if (merged.length > 0) {
507
+ let sumTokens = 0; for (const h of merged) sumTokens += h.checkpoint.tokenEstimate;
508
+ runtime.rt.recallInjections += merged.length;
509
+ runtime.rt.cacheHitTokens += sumTokens;
510
+ incRecallInjected(merged.length, runtime.currentStateDir);
511
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
512
+ }
496
513
  return {
497
514
  toInject: merged,
498
515
  report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
@@ -24,6 +24,10 @@ import {
24
24
  latestModelSnapshot,
25
25
  upsertRepoRegistry,
26
26
  recordRepoModel,
27
+ getDedupStats,
28
+ getCompactCount,
29
+ getRecallInjected,
30
+ getCacheHitTokensSaved,
27
31
  type ModelSnapshot,
28
32
  } from "../src/store/sqlite.js";
29
33
  import { detectCrossRepoDrift } from "../src/driftDetection.js";
@@ -73,6 +77,10 @@ interface SessionRuntime {
73
77
  tokensSaved: number; // this session-instance only: reset on session_start
74
78
  lastCompactAt: number | null; // wall-clock ms of the last compaction this session
75
79
  lastNativeCompactAt: number | null; // COMPACT-DEDUP FIX: wall-clock ms of the last NATIVE pi compaction (session_compact event) — used by the agent_end/legacy race guard to skip a redundant ctx.compact() that would throw "Already compacted".
80
+ // S25: live dashboard counters (reset on session_start, mirrored to SQLite).
81
+ compactCount: number; // compactions performed this session-instance
82
+ recallInjections: number; // recall blocks injected this session-instance
83
+ cacheHitTokens: number; // tokens saved via cache hits (dedup + recall) this session
76
84
  }
77
85
 
78
86
  /** ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
@@ -94,6 +102,11 @@ export const C = {
94
102
 
95
103
  const PULSE = ["◐", "◓", "◑", "◒"];
96
104
 
105
+ // Rough tokens-processed-per-second heuristic for the dashboard's "time saved"
106
+ // estimate. Throughput varies by model/hardware; this is order-of-magnitude so
107
+ // the dashboard can show a human-readable figure, not a precise measurement.
108
+ const TOKENS_PER_SEC_ESTIMATE = 2000;
109
+
97
110
  // ── Full-width widget panel helpers ────────────────────────────────────────
98
111
  // pi's above-editor widget renderer (a Container of Text lines) does NOT pass
99
112
  // a terminal width to setWidget(), so lines render left-aligned by default. To
@@ -274,6 +287,9 @@ export class MegaRuntime {
274
287
  tokensSaved: 0,
275
288
  lastCompactAt: null,
276
289
  lastNativeCompactAt: null,
290
+ compactCount: 0,
291
+ recallInjections: 0,
292
+ cacheHitTokens: 0,
277
293
  };
278
294
  debounceUntil = 0;
279
295
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -485,6 +501,12 @@ export class MegaRuntime {
485
501
  const st = this.store.stats(this.rt.sessionId);
486
502
  const repo = this.store.repoStats();
487
503
  const di = this.store.dataInvariant();
504
+ // Live + store-wide cache-hit / compaction counters for the dashboard.
505
+ const ds = getDedupStats(this.currentStateDir);
506
+ const cacheHitsTotal = ds.deduped + getRecallInjected(this.currentStateDir);
507
+ const cacheHitsTotalTokens = getCacheHitTokensSaved(this.currentStateDir);
508
+ const cacheHitsSession = this.rt.dedupSkips + this.rt.recallInjections;
509
+ const sec = (tok: number) => (tok || 0) / TOKENS_PER_SEC_ESTIMATE;
488
510
  // Active model/provider for the current-repo card + the multi-repo table.
489
511
  const modelSnap = latestModelSnapshot(this.currentStateDir);
490
512
  const model = modelSnap
@@ -604,6 +626,20 @@ export class MegaRuntime {
604
626
  duplicatesCollapsed: di.duplicatesCollapsed,
605
627
  bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
606
628
  },
629
+ cacheHits: {
630
+ session: cacheHitsSession,
631
+ total: cacheHitsTotal,
632
+ sessionTokensSaved: this.rt.cacheHitTokens,
633
+ totalTokensSaved: cacheHitsTotalTokens,
634
+ },
635
+ compacts: {
636
+ session: this.rt.compactCount,
637
+ total: getCompactCount(this.currentStateDir),
638
+ },
639
+ timeSaved: {
640
+ compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(this.store.repoStats().tokensSaved) },
641
+ cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
642
+ },
607
643
  model,
608
644
  } as DashboardSnapshot);
609
645
 
@@ -849,7 +885,10 @@ export class MegaRuntime {
849
885
  tokensSaved: 0,
850
886
  lastCompactAt: null,
851
887
  lastNativeCompactAt: null,
852
- };
888
+ compactCount: 0,
889
+ recallInjections: 0,
890
+ cacheHitTokens: 0,
891
+ };
853
892
  this.statusKey = undefined;
854
893
  this.activeAgents = 0;
855
894
  this.currentTurn = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
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",
@@ -0,0 +1,68 @@
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 {
14
+ openStore,
15
+ incCompactCount,
16
+ getCompactCount,
17
+ incRecallInjected,
18
+ getRecallInjected,
19
+ incCacheHitTokens,
20
+ getCacheHitTokensSaved,
21
+ } from "./sqlite.js";
22
+
23
+ describe("live dashboard counters (meta integers)", () => {
24
+ let dir: string;
25
+ beforeEach(() => {
26
+ dir = mkdtempSync(join(tmpdir(), "cachehit-test-"));
27
+ });
28
+ afterEach(() => {
29
+ rmSync(dir, { recursive: true, force: true });
30
+ });
31
+
32
+ it("increments + reads compact_count and persists on reopen", () => {
33
+ incCompactCount(dir);
34
+ incCompactCount(dir);
35
+ assert.equal(getCompactCount(dir), 2);
36
+ // Ensure the store handle is (re)opened and the value is read back from disk.
37
+ openStore(dir);
38
+ assert.equal(getCompactCount(dir), 2);
39
+ });
40
+
41
+ it("accumulates recall injections + cache-hit tokens", () => {
42
+ incRecallInjected(3, dir);
43
+ incRecallInjected(2, dir);
44
+ assert.equal(getRecallInjected(dir), 5);
45
+ incCacheHitTokens(1200, dir);
46
+ incCacheHitTokens(800, dir);
47
+ assert.equal(getCacheHitTokensSaved(dir), 2000);
48
+ });
49
+
50
+ it("ignores non-positive increments (no-op)", () => {
51
+ incRecallInjected(0, dir);
52
+ incRecallInjected(-5, dir);
53
+ incCacheHitTokens(0, dir);
54
+ assert.equal(getRecallInjected(dir), 0);
55
+ assert.equal(getCacheHitTokensSaved(dir), 0);
56
+ });
57
+
58
+ it("persists all three counters across a fresh openStore handle", () => {
59
+ incCompactCount(dir);
60
+ incRecallInjected(4, dir);
61
+ incCacheHitTokens(500, dir);
62
+ // openStore returns the canonical (on-disk) handle; reading back proves durability.
63
+ openStore(dir);
64
+ assert.equal(getCompactCount(dir), 1);
65
+ assert.equal(getRecallInjected(dir), 4);
66
+ assert.equal(getCacheHitTokensSaved(dir), 500);
67
+ });
68
+ });
@@ -704,6 +704,34 @@ export function bumpDedupStats(deduped: boolean, stateDir: string = getStateDir(
704
704
  if (deduped) incMeta("deduped", 1, stateDir);
705
705
  }
706
706
 
707
+ // --- Live dashboard counters (schemaless meta key/value — NO migration) -----
708
+ // These reuse the private `incMeta` atomically-incrementing integer counter so
709
+ // all cumulative tallies live in the same `meta` table as tokens_saved etc.
710
+
711
+ export function incCompactCount(stateDir: string = getStateDir()): void {
712
+ incMeta("compact_count", 1, stateDir);
713
+ }
714
+
715
+ export function getCompactCount(stateDir: string = getStateDir()): number {
716
+ return getMetaNumber("compact_count", stateDir);
717
+ }
718
+
719
+ export function incRecallInjected(n: number, stateDir: string = getStateDir()): void {
720
+ if (n > 0) incMeta("recall_injected", n, stateDir);
721
+ }
722
+
723
+ export function getRecallInjected(stateDir: string = getStateDir()): number {
724
+ return getMetaNumber("recall_injected", stateDir);
725
+ }
726
+
727
+ export function incCacheHitTokens(delta: number, stateDir: string = getStateDir()): void {
728
+ if (delta > 0) incMeta("cache_hit_tokens_saved", delta, stateDir);
729
+ }
730
+
731
+ export function getCacheHitTokensSaved(stateDir: string = getStateDir()): number {
732
+ return getMetaNumber("cache_hit_tokens_saved", stateDir);
733
+ }
734
+
707
735
  // --- Future-feature foundation (resume sessions / daily log / lessons) -------
708
736
  // Scaffolded tables + minimal helpers so all store data lives in SQLite from
709
737
  // day one. Full UI/recall for these lands in later sprints.