pi-mega-compact 0.7.5 → 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.
package/README.md CHANGED
@@ -6,19 +6,37 @@ sessions into a **local SQLite store** and offers **deduped inline recall** —
6
6
  running **locally inside the extension**, with **no remote MCP server** and
7
7
  **zero network calls at runtime** (PREVENT-PI-004).
8
8
 
9
- > **Current version:** `v0.6.9` storage backend is **`node:sqlite`**
10
- > (`DatabaseSync`, a Node 22.13 built-in), replacing the old `better-sqlite3`
11
- > native addon and the per-session gzipped JSON checkpoint files. **Zero native
12
- > build step, fully local, zero network at runtime.** Legacy
13
- > `.checkpoints.json.gz` snapshots are retained as disaster-recovery fallbacks
14
- > and auto-imported on first run. The S24 line ties auto-compact, the tier
15
- > label, trim depth, and durable-memory review to one **unified pressure
16
- > signal**, adds a **cross-repo memory-RAG index**, and relieves context
17
- > **during team runs** (not just at the end).
9
+ > **Status - v0.7.7.** Storage uses the built-in `node:sqlite` backend
10
+ > (`DatabaseSync`, Node >=22.13): zero native build step, fully local, and zero
11
+ > network at runtime. See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full
12
+ > changelog.
18
13
 
19
14
  ---
20
15
 
21
- ## What this is (the 30-second version)
16
+ ## Features
17
+
18
+ - **Local & private** - everything stays on your disk. No telemetry, no API key, no MCP server, no cloud. The only network surface is an optional localhost dashboard you open yourself.
19
+ - **Two-layer compaction** - a non-destructive live summary every LLM call, plus durable checkpoints that relieve context mid-run (not just at the end).
20
+ - **Vector store + dedup** - each checkpoint is embedded and stored locally; an L0->L2 + RAPTOR cascade collapses duplicate work so storage and recall stay lean.
21
+ - **Automatic recall** - the most relevant checkpoints are re-inlined on resume or branch switch; cross-repo memory-RAG augments a thin store with decisions from other repos.
22
+ - **Live dashboard** - a localhost-only view of token usage, store stats, savings, per-repo activity, and an **Active Repos tab** that shows every currently-open session (last 30 minutes) side by side — so running multiple sessions at once is visible in one place.
23
+ - **Database maintenance** - `/mega-db-*` commands plus best-effort auto-maintenance on session start.
24
+
25
+ ## Table of contents
26
+
27
+ - [Overview](#overview)
28
+ - [How it works](#how-it-works)
29
+ - [Installation](#installation)
30
+ - [Usage](#usage)
31
+ - [Configuration](#configuration)
32
+ - [Dashboard](#dashboard)
33
+ - [Architecture](#architecture)
34
+ - [Development](#development)
35
+ - [Testing & bug reports](#testing--bug-reports)
36
+ - [Acknowledgements](#acknowledgements)
37
+ - [License](#license)
38
+
39
+ ## Overview
22
40
 
23
41
  pi's context window is finite. When a session gets long — especially a team run
24
42
  with sub-agents — pi-mega-compact keeps it going without overflowing:
@@ -214,7 +232,7 @@ rm -f ~/.pi/agent/extensions/pi-mega-compact
214
232
 
215
233
  ---
216
234
 
217
- ## How to use it
235
+ ## Usage
218
236
 
219
237
  Once installed and registered, pi-mega-compact runs **automatically** — you don't
220
238
  have to drive it. Past the context threshold it compacts in the background and
@@ -222,6 +240,8 @@ drops a checkpoint; on resume it re-inlines the relevant ones silently.
222
240
 
223
241
  The commands (slash commands inside pi):
224
242
 
243
+ ### Commands
244
+
225
245
  | Command | Description |
226
246
  |---|---|
227
247
  | `/mega-compact [summary...]` | Manually compact the current session. A summary arg is used verbatim; otherwise the COLLAPSE heuristics build one. Persists a `chkpt_xxx`. |
@@ -234,18 +254,26 @@ The commands (slash commands inside pi):
234
254
  | `/mega-view <chkpt\|recent>` | Show a checkpoint's verbatim original region. |
235
255
  | `/mega-help` | Explain the toolbar widget terms (live tier, gate, dedup, tokens saved). |
236
256
  | `/mega-compat-check` | Detect extension conflicts (duplicate commands / overlapping handlers) across installed pi extensions. |
237
-
238
- The **tier** you see in the toolbar and dashboard is a *live pressure band* (`low` `medium` `high` `ultra` `mega`) that climbs automatically as your context window fills and falls back as it's relieved — it is driven by `currentTokens / effectiveThreshold`, not a manual setting. The base compaction *threshold* is set by `MEGACOMPACT_TIER` at startup as a **% of the model context window** (`low` 50% · `medium` 60% · `high` 70% · `ultra` 70% · `mega` 75%; default `low`) the fire point is `tierPct × contextWindow`, so it always lands below pi's native ~80% auto-compaction (any model size). The old static token amounts (50k/100k/200k/1M/10M) are now only the boot fallback used before the first context event reports a window. `/mega-tier` was removed in v0.6.0. Higher pressure also deepens the live trim and reviews durable memory more often — the whole system reacts as one.
239
- | `/mega-dashboard` | Start the **localhost-only** live dashboard and open it in a browser (token gauge, store stats, live event stream, per-repo + cross-repo drift). |
240
- | `/mega-dashboard-status` | Report dashboard server status. |
257
+ | `/mega-db-stats` | Show mega-compact SQLite DB stats: table row counts, disk footprint (db + WAL + SHM), page count, freelist %, WAL frames. Read-only; safe any time. |
258
+ | `/mega-db-prune [days]` | DELETE `raw_transcript` + `checkpoint_epochs` rows older than N days (default 30) + orphan `dedup_mirror` rows. Reports deleted counts + reclaimed bytes. |
259
+ | `/mega-db-vacuum` | `VACUUM` the DB (rebuild pages, reclaim freelist). Heavy: briefly doubles disk usage. |
260
+ | `/mega-db-check` | `PRAGMA integrity_check` + `wal_checkpoint(TRUNCATE)`. Fold the WAL into the main file and verify DB health. Use after a crash. |
261
+ | `/mega-db-reconcile` | Fix `dedup_mirror.ref_count` drift vs actual `raw_transcript` refs, delete orphan dedup rows, backfill missing `content_ref`. Run after `/mega-db-prune` or a crash. |
262
+ | `/mega-dashboard [open]` | Start the **localhost-only** live dashboard and open it in a browser (token gauge, store stats, live event stream, per-repo + All-repos/Summary views, cross-repo drift). |
263
+ | `/mega-dashboard-status` | Report dashboard server status (port / url / live). |
241
264
  | `/mega-dashboard-stop` | Stop the dashboard server. |
242
265
 
266
+ ### The tier system
267
+
268
+ The **tier** you see in the toolbar and dashboard is a *live pressure band* (`low` → `medium` → `high` → `ultra` → `mega`) that climbs automatically as your context window fills and falls back as it's relieved — it is driven by `currentTokens / effectiveThreshold`, not a manual setting. The base compaction *threshold* is set by `MEGACOMPACT_TIER` at startup as a **% of the model context window** (`low` 50% · `medium` 60% · `high` 70% · `ultra` 70% · `mega` 75%; default `low`) — the fire point is `tierPct × contextWindow`, so it always lands below pi's native ~80% auto-compaction (any model size). The old static token amounts (50k/100k/200k/1M/10M) are now only the boot fallback used before the first context event reports a window. `/mega-tier` was removed in v0.7.6. Higher pressure also deepens the live trim and reviews durable memory more often — the whole system reacts as one.
269
+
270
+
243
271
  ### Live stats widget
244
272
 
245
273
  Above the pi editor the extension shows a compact widget:
246
274
 
247
275
  ```
248
- ⚡ high·low v0.6.0 │ 142k/200k tokens (71%) │ 3 chkpts │ 🤖 2 agents │ turn 5
276
+ ⚡ high·low v0.7.7 │ 142k/200k tokens (71%) │ 3 chkpts │ 🤖 2 agents │ turn 5
249
277
  ◐ armed │ dedup: 92% │ saved: 45k tok
250
278
  ```
251
279
 
@@ -259,6 +287,13 @@ Above the pi editor the extension shows a compact widget:
259
287
  - **Dedup hit rate** — % of checkpoints collapsed as duplicates
260
288
  - **Active agents / turn** — sub-agent count and conversation turn (when > 0)
261
289
 
290
+ > **DB housekeeping** — `/mega-db-stats` / `prune` / `vacuum` / `check` / `reconcile`
291
+ > give you manual control over the SQLite store. In addition, a best-effort
292
+ > **auto-maintenance** pass runs on `session_start`: it prunes rows older than
293
+ > 30d, checkpoints the WAL if it's over 10 MB, and VACUUMs if the DB is over
294
+ > 100 MB AND the freelist is >20% of pages. It never blocks session start and
295
+ > logs a one-line summary to the diagnostic log. (v0.7.6+)
296
+
262
297
  ---
263
298
 
264
299
  ## Configuration (env-backed)
@@ -266,6 +301,8 @@ Above the pi editor the extension shows a compact widget:
266
301
  All defaults are in `src/config/dedup.ts` (single source of truth). Set env vars
267
302
  before starting pi.
268
303
 
304
+ ### Core settings
305
+
269
306
  | Variable | Default | Meaning |
270
307
  |---|---|---|
271
308
  | `MEGACOMPACT_FAST_GATE_PCT` | `70` | Context-usage % that arms the auto-trigger. Defaults to the tier's % of window (`tierPct*100`): low 50 · med 60 · high 70 · ultra 70 · mega 75. Override raises the arming floor. |
@@ -279,7 +316,7 @@ before starting pi.
279
316
  | `MEGACOMPACT_DEDUP_SIM` | `0.90` | Cosine threshold to collapse near-dupes. |
280
317
  | `MEGACOMPACT_STATE_DIR` | _(none — per-repo default)_ | Override the store location. By default state is per-repo at `<repo>/.pi/mega-compact/`; this env var forces a single explicit dir (used as the fallback for non-git cwds). |
281
318
 
282
- #### Dedup pipeline flags (single source: `src/config/dedup.ts`)
319
+ ### Dedup pipeline flags
283
320
 
284
321
  These gate the L0/L1/L2/RAPTOR dedup tiers. Defaults reproduce the all-active
285
322
  behavior. `MARK_ONLY_*` tiers run + record their decision but never
@@ -309,7 +346,7 @@ See `docs/DEDUP_RUNBOOK.md` for incident response (SEV tiers, first-15-min
309
346
  checklist, MARK_ONLY degrade) and `docs/RETENTION_POLICY.md` for TTL / soft-delete
310
347
  / VACUUM.
311
348
 
312
- #### Continuity + memory knobs
349
+ ### Continuity & memory
313
350
 
314
351
  | Variable | Default | Meaning |
315
352
  |---|---|---|
@@ -320,17 +357,84 @@ checklist, MARK_ONLY degrade) and `docs/RETENTION_POLICY.md` for TTL / soft-dele
320
357
  | `MEGACOMPACT_MEMORY_REVIEW_INTERVAL` | `10` | Turns between auto-review cycles. |
321
358
  | `MEGACOMPACT_PGLITE_DISABLED` | _(unset — index on)_ | Kill-switch for the PGlite/HNSW cross-repo index; set `1`/`true` to disable (falls back to sync per-session scan). |
322
359
 
323
- #### Dashboard
324
-
325
- The localhost-only dashboard adds a **Summary** + **All-repos** view over the
326
- machine-wide `repo_registry`, plus a **cross-repo drift** report (`GET /api/drift`)
327
- flagging stale repos (>30d idle), compaction lag (an active repo >24h behind the
328
- most-recently-active repo's last compaction), and recent model churn (within 7d).
329
- All read-only the report never writes the index.
360
+ ## Dashboard
361
+
362
+ The localhost-only dashboard (started with `/mega-dashboard`) is a single-page
363
+ app served from a detached child process on `127.0.0.1` (random port in
364
+ 9320–9329). Every API is read-only the server never writes the index or your
365
+ store. It reads the machine-wide `repo_registry`
366
+ (`~/.mega-compact-index/index.sqlite`) plus the current repo's own `node:sqlite`
367
+ store.
368
+
369
+ ### Tabs
370
+
371
+ - **Current repo** — the live single-session view: context-window gauge, trigger
372
+ status, the Vector Store (checkpoints / dropped / kept / freed / injected /
373
+ dedup / collapsed), the repo-wide aggregate, the Data Safety card, the live
374
+ Model & Cost Savings card, and crew/agent activity.
375
+ - **All repos** — a machine-wide table aggregated from `repo_registry`
376
+ (`GET /api/index`): one row per repo with checkpoints, tokens saved,
377
+ compressed-originals, last-compacted, and active model. Each row opens a
378
+ per-repo detail modal. Currently-open sessions are badged **active** (see
379
+ below).
380
+ - **Summary** — machine-wide header tiles plus a **savings-by-model** table
381
+ (tokens in/out/freed, context window, $ saved) grouped by the model you were
382
+ running.
383
+
384
+ ### Active Repos tab
385
+
386
+ The dashboard has a dedicated **Active Repos** tab that lists every server /
387
+ session seen within the **last 30 minutes**, each with its live tier,
388
+ context %, and session state. It is backed by `GET /api/servers`, which walks
389
+ the machine-wide `repo_registry`, reads each repo's per-process
390
+ `dashboard.json` snapshot, and returns one row per currently-open session —
391
+ so 1–6 sessions running at once are visible together in a single table instead
392
+ of only the single current-repo view. Each row shows that repo's live
393
+ cache-hit / compaction / time-saved totals (see below).
394
+
395
+ The older **All repos** view and `GET /api/summary` still surface an
396
+ `activeRepos` count, and `GET /api/repos?active=Nh` filters to repos seen
397
+ within the last *N* hours (e.g. `?active=24h`, hour-granular) for the
398
+ longer-window cross-repo table.
399
+
400
+ ### Metrics (DB-backed, durable)
401
+
402
+ The dashboard's cumulative **Cache hits**, **Compactions**, and **Estimated
403
+ time saved** cards are backed by **SQLite `meta` counters**
404
+ (`compact_count`, `recall_injected`, `cache_hit_tokens_saved`, plus the
405
+ existing `tokens_saved` / `deduped`), not the per-process `dashboard.json`
406
+ snapshot. Because they live in the repo's `node:sqlite` store, the totals are
407
+ **durable across session restarts** and travel with the repo's state dir —
408
+ `dashboard.json` is now just the live per-process view that feeds the Active
409
+ Repos rows. The cards show:
410
+
411
+ - **Cache hits** = dedup collapses (`deduped`) + recall re-injections
412
+ (`recall_injected`) — as **current session** and **repo-wide total**.
413
+ - **Compactions** = current session (`checkpointCount`) + repo-wide total
414
+ (`compact_count` from `meta`).
415
+ - **Estimated time saved** = compact time saved + cache-hit time saved, derived
416
+ from tokens ÷ ~2k tok/s and labeled `est.`, as current session + total.
417
+
418
+ ### Localhost API
419
+
420
+ `GET /api/snapshot` (current-repo live state),
421
+ `/api/servers` (**Active Repos** — sessions active in the last 30 min, with
422
+ tier / context % / state / live cache-hit & compaction totals),
423
+ `/api/index` (all repos), `/api/repos` (with `?active=Nh` filter), `/api/summary`
424
+ (header tiles + `activeRepos`), `/api/drift` (cross-repo drift: stale /
425
+ compaction-lag / model-churn — read-only), `/api/events` (SSE live event stream),
426
+ and `/api/version`.
427
+
428
+ ### Data safety
429
+
430
+ Every compacted region is kept verbatim (compressed);
431
+ the Data Safety card shows regions retained, compressed-originals bytes, dedup
432
+ duplicates, and permanently-deleted bytes (**always 0**). Nothing is permanently
433
+ deleted — any region is restorable.
330
434
 
331
435
  ---
332
436
 
333
- ## Architecture & layout
437
+ ## Architecture
334
438
 
335
439
  ```
336
440
  extensions/mega-compact.ts pi extension entry; wires src/ into pi lifecycle
@@ -370,7 +474,7 @@ The extension entry adapts between the engine and pi's runtime types.
370
474
 
371
475
  ```bash
372
476
  npm run build # tsc
373
- npm test # build + node --test on dist/**/*.test.js (353 tests)
477
+ npm test # build + node --test on dist/**/*.test.js (407 tests)
374
478
  npm run lint # tsc --noEmit + guardrails-scan
375
479
  npm run guardrails # regression_check + guardrails-scan
376
480
  ```
@@ -45,6 +45,7 @@ function log(...parts) {
45
45
  // concurrent writer's WAL never blocks the request). All registry data lives in
46
46
  // SQLite (the project's one-store invariant) — there is no JSON mirror. Same
47
47
  // index-dir resolution as src/store/sqlite.ts getIndexDir().
48
+ const ACTIVE_WINDOW_SEC = 1800;
48
49
  function getIndexDir() {
49
50
  const override = process.env.MEGACOMPACT_INDEX_DIR;
50
51
  if (override && override.trim() !== "")
@@ -169,9 +170,6 @@ function readIndex() {
169
170
  // ---------------------------------------------------------------------------
170
171
  /** Package version of this extension, surfaced in the dashboard header. */
171
172
  let dashboardServerVersion = "0.0.0";
172
- // ---------------------------------------------------------------------------
173
- // Helpers
174
- // ---------------------------------------------------------------------------
175
173
  function readSnapshot(snapshotPath) {
176
174
  try {
177
175
  const raw = readFileSync(snapshotPath, "utf-8");
@@ -192,6 +190,9 @@ function readSnapshot(snapshotPath) {
192
190
  crew: { activeAgents: 0, currentTurn: 0 },
193
191
  repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
194
192
  integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
193
+ cacheHits: { session: 0, total: 0, sessionTokensSaved: 0, totalTokensSaved: 0 },
194
+ compacts: { session: 0, total: 0 },
195
+ timeSaved: { compact: { sessionSec: 0, totalSec: 0 }, cacheHit: { sessionSec: 0, totalSec: 0 } },
195
196
  compression: { session: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 }, repo: { tokensIn: 0, tokensOut: 0, tokensFreed: 0, compressionPct: 0, dedupPct: 0 } },
196
197
  model: undefined,
197
198
  };
@@ -315,6 +316,7 @@ function dashboardHtml(tierName) {
315
316
  <nav class="tabs">
316
317
  <button class="tab active" data-tab="current">Current repo</button>
317
318
  <button class="tab" data-tab="all">All repos</button>
319
+ <button class="tab" data-tab="active">Active Repos</button>
318
320
  <button class="tab" data-tab="summary">Summary</button>
319
321
  </nav>
320
322
 
@@ -416,6 +418,26 @@ function dashboardHtml(tierName) {
416
418
  </ul>
417
419
  <p class="legend-note">Hover any label above for a quick explanation.</p>
418
420
  </div>
421
+ <div class="card">
422
+ <h2>💾 Cache Hits &amp; Compactions</h2>
423
+ <div class="stat-grid">
424
+ <span class="label">Cache Hits (session)</span><span class="value" id="ch-session">0</span>
425
+ <span class="label">Cache Hits (total)</span><span class="value" id="ch-total">0</span>
426
+ <span class="label">Tokens Saved (session)</span><span class="value" id="ch-tok-session">0</span>
427
+ <span class="label">Tokens Saved (total)</span><span class="value" id="ch-tok-total">0</span>
428
+ <span class="label">Compactions (session)</span><span class="value" id="cp-session">0</span>
429
+ <span class="label">Compactions (total)</span><span class="value" id="cp-total">0</span>
430
+ </div>
431
+ </div>
432
+ <div class="card">
433
+ <h2>⏱ Time Saved (est.)</h2>
434
+ <div class="stat-grid">
435
+ <span class="label">Compact (session)</span><span class="value" id="ts-compact-session">0</span>
436
+ <span class="label">Compact (total)</span><span class="value" id="ts-compact-total">0</span>
437
+ <span class="label">Cache Hit (session)</span><span class="value" id="ts-cache-session">0</span>
438
+ <span class="label">Cache Hit (total)</span><span class="value" id="ts-cache-total">0</span>
439
+ </div>
440
+ </div>
419
441
  </div>
420
442
 
421
443
  <div class="events">
@@ -441,6 +463,28 @@ function dashboardHtml(tierName) {
441
463
  <div class="updated" id="updated"></div>
442
464
  </div><!-- /panel-current -->
443
465
 
466
+ <!-- Active repos (live cache-hit / compaction stats across machines) -->
467
+ <div class="tab-panel" id="panel-active">
468
+ <div class="card">
469
+ <h2>Active Repos — Live Cache Hits &amp; Compactions</h2>
470
+ <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>
471
+ <table class="repos">
472
+ <thead>
473
+ <tr>
474
+ <th>Repo</th><th>Model</th><th>Tier</th>
475
+ <th style="text-align:right">Context %</th><th>State</th>
476
+ <th style="text-align:right">Compactions (s/t)</th>
477
+ <th style="text-align:right">Cache Hits (s/t)</th>
478
+ <th style="text-align:right">Compact s/t (s)</th>
479
+ <th style="text-align:right">CacheHit s/t (s)</th>
480
+ </tr>
481
+ </thead>
482
+ <tbody id="active-rows"><tr><td colspan="9" class="repo-none">loading…</td></tr></tbody>
483
+ </table>
484
+ <div class="updated" id="active-updated"></div>
485
+ </div>
486
+ </div>
487
+
444
488
  <!-- Per-repo detail modal -->
445
489
  <div class="repo-detail" id="repo-detail">
446
490
  <div class="repo-detail-box">
@@ -635,6 +679,21 @@ function dashboardHtml(tierName) {
635
679
  document.getElementById('cost-windows').textContent = '0 context-windows extended';
636
680
  }
637
681
 
682
+ // --- Cache hits & compactions (live counters) ---------------------------
683
+ var ch = d.cacheHits || { session: 0, total: 0, sessionTokensSaved: 0, totalTokensSaved: 0 };
684
+ var cp = d.compacts || { session: 0, total: 0 };
685
+ var ts = d.timeSaved || { compact: { sessionSec: 0, totalSec: 0 }, cacheHit: { sessionSec: 0, totalSec: 0 } };
686
+ document.getElementById('ch-session').textContent = (ch.session || 0).toLocaleString();
687
+ document.getElementById('ch-total').textContent = (ch.total || 0).toLocaleString();
688
+ document.getElementById('ch-tok-session').textContent = (ch.sessionTokensSaved || 0).toLocaleString();
689
+ document.getElementById('ch-tok-total').textContent = (ch.totalTokensSaved || 0).toLocaleString();
690
+ document.getElementById('cp-session').textContent = (cp.session || 0).toLocaleString();
691
+ document.getElementById('cp-total').textContent = (cp.total || 0).toLocaleString();
692
+ document.getElementById('ts-compact-session').textContent = fmtSec(ts.compact.sessionSec);
693
+ document.getElementById('ts-compact-total').textContent = fmtSec(ts.compact.totalSec);
694
+ document.getElementById('ts-cache-session').textContent = fmtSec(ts.cacheHit.sessionSec);
695
+ document.getElementById('ts-cache-total').textContent = fmtSec(ts.cacheHit.totalSec);
696
+
638
697
  document.getElementById('updated').textContent = 'Updated ' + new Date(d.updatedAt).toLocaleTimeString();
639
698
  }
640
699
 
@@ -840,9 +899,51 @@ function dashboardHtml(tierName) {
840
899
  pollIndex();
841
900
  setInterval(pollIndex, 5000);
842
901
 
902
+ // --- Active repos (live cache-hit / compaction stats) ---------------------
903
+ function fmtSec(s) {
904
+ s = s || 0;
905
+ if (s >= 3600) return (s / 3600).toFixed(1) + 'h';
906
+ if (s >= 60) return Math.round(s / 60) + 'm';
907
+ if (s >= 1) return s.toFixed(1) + 's';
908
+ return Math.round(s * 1000) + 'ms';
909
+ }
910
+ function renderActiveRepos(d) {
911
+ d = d || { updatedAt: null, servers: [] };
912
+ var servers = d.servers || [];
913
+ var rowsEl = document.getElementById('active-rows');
914
+ if (!rowsEl) return;
915
+ if (!servers.length) {
916
+ rowsEl.innerHTML = '<tr><td colspan="9" class="repo-none">No active repositories.</td></tr>';
917
+ } else {
918
+ rowsEl.innerHTML = servers.map(function(r) {
919
+ var ch = r.cacheHits || { session: 0, total: 0, sessionTokensSaved: 0, totalTokensSaved: 0 };
920
+ var cp = r.compacts || { session: 0, total: 0 };
921
+ var ts = r.timeSaved || { compact: { sessionSec: 0, totalSec: 0 }, cacheHit: { sessionSec: 0, totalSec: 0 } };
922
+ return '<tr>' +
923
+ '<td title="' + sanitize(r.repoRoot) + '">' + sanitize(r.displayName || r.repoRoot) + '</td>' +
924
+ '<td>' + sanitize(r.model || '—') + '</td>' +
925
+ '<td>' + sanitize(r.tier || '—') + '</td>' +
926
+ '<td class="num">' + (r.contextPct != null ? Math.round(r.contextPct * 100) + '%' : '—') + '</td>' +
927
+ '<td>' + sanitize(r.state || '—') + '</td>' +
928
+ '<td class="num">' + (cp.session || 0) + ' / ' + (cp.total || 0) + '</td>' +
929
+ '<td class="num">' + (ch.session || 0) + ' / ' + (ch.total || 0) + '</td>' +
930
+ '<td class="num">' + fmtSec(ts.compact.sessionSec) + ' / ' + fmtSec(ts.compact.totalSec) + '</td>' +
931
+ '<td class="num">' + fmtSec(ts.cacheHit.sessionSec) + ' / ' + fmtSec(ts.cacheHit.totalSec) + '</td>' +
932
+ '</tr>';
933
+ }).join('');
934
+ }
935
+ var upd = document.getElementById('active-updated');
936
+ if (upd) upd.textContent = d.updatedAt ? 'Updated ' + new Date(d.updatedAt).toLocaleTimeString() : '';
937
+ }
938
+ function pollServers() {
939
+ fetch('/api/servers').then(function(r) { return r.json(); }).then(renderActiveRepos).catch(function() {});
940
+ }
941
+ pollServers();
942
+ setInterval(pollServers, 5000);
943
+
843
944
  // --- Tab switching ------------------------------------------------------
844
945
  var tabs = document.querySelectorAll('.tab');
845
- var panels = { current: 'panel-current', all: 'panel-all', summary: 'panel-summary' };
946
+ var panels = { current: 'panel-current', all: 'panel-all', active: 'panel-active', summary: 'panel-summary' };
846
947
  for (var i = 0; i < tabs.length; i++) {
847
948
  tabs[i].addEventListener('click', function() {
848
949
  var name = this.getAttribute('data-tab');
@@ -855,6 +956,7 @@ function dashboardHtml(tierName) {
855
956
  }
856
957
  }
857
958
  if (name === 'all' || name === 'summary') pollIndex();
959
+ if (name === 'active') pollServers();
858
960
  });
859
961
  }
860
962
  })();
@@ -1063,6 +1165,37 @@ export async function launchDashboardServer(stateDir) {
1063
1165
  res.end(JSON.stringify(report));
1064
1166
  return;
1065
1167
  }
1168
+ if (req.url === "/api/servers") {
1169
+ try {
1170
+ const idx = readIndex();
1171
+ const nowSec = Math.floor(Date.now() / 1000);
1172
+ const servers = (idx?.repos ?? []).filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC).map((r) => {
1173
+ const out = { repoRoot: r.repoRoot, displayName: r.displayName, model: r.modelName, provider: r.providerName, lastSeen: r.lastSeen, lastCompactedAt: r.lastCompactedAt };
1174
+ try {
1175
+ const p = join(r.stateDir, "dashboard.json");
1176
+ if (existsSync(p)) {
1177
+ const snap = JSON.parse(readFileSync(p, "utf-8"));
1178
+ out.tier = snap.tier ?? null;
1179
+ out.contextPct = (snap.context && snap.context.percent != null) ? snap.context.percent : null;
1180
+ out.state = (snap.session && snap.session.state) || null;
1181
+ out.cacheHits = snap.cacheHits ?? null;
1182
+ out.compacts = snap.compacts ?? null;
1183
+ out.timeSaved = snap.timeSaved ?? null;
1184
+ out.updatedAt = snap.updatedAt ?? null;
1185
+ }
1186
+ }
1187
+ catch { /* best-effort */ }
1188
+ return out;
1189
+ }).sort((a, b) => b.lastSeen - a.lastSeen);
1190
+ res.writeHead(200, { "Content-Type": "application/json" });
1191
+ res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
1192
+ }
1193
+ catch {
1194
+ res.writeHead(500, { "Content-Type": "application/json" });
1195
+ res.end(JSON.stringify({ error: "servers_unavailable" }));
1196
+ }
1197
+ return;
1198
+ }
1066
1199
  if (req.url === "/api/events") {
1067
1200
  res.writeHead(200, {
1068
1201
  "Content-Type": "text/event-stream",
@@ -195,6 +195,65 @@ describe("multi-repo /api/index (S19)", () => {
195
195
  }
196
196
  });
197
197
  });
198
+ describe("multi-repo /api/servers (active cache-hit stats)", () => {
199
+ test("returns active repos with live dashboard.json cache-hit/compaction stats", async () => {
200
+ const dir = mkdtempSync(join(tmpdir(), "dash-servers-"));
201
+ const indexDir = mkdtempSync(join(tmpdir(), "index-servers-"));
202
+ process.env.MEGACOMPACT_INDEX_DIR = indexDir;
203
+ process.env.MEGACOMPACT_DASHBOARD_PORT = "19323";
204
+ const { upsertRepoRegistry } = await import("../src/store/sqlite.js");
205
+ const activeState = mkdtempSync(join(tmpdir(), "srv-active-"));
206
+ writeFileSync(join(activeState, "dashboard.json"), JSON.stringify({
207
+ updatedAt: new Date().toISOString(),
208
+ tier: "high",
209
+ context: { tokens: 5000, percent: 0.42, contextWindow: 12000 },
210
+ session: { id: "s1", state: "idle" },
211
+ cacheHits: { session: 3, total: 7, sessionTokensSaved: 1200, totalTokensSaved: 9000 },
212
+ compacts: { session: 2, total: 5 },
213
+ timeSaved: { compact: { sessionSec: 1.5, totalSec: 4 }, cacheHit: { sessionSec: 0.6, totalSec: 4.5 } },
214
+ }, null, 2));
215
+ upsertRepoRegistry({ 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" }, indexDir);
216
+ const staleState = mkdtempSync(join(tmpdir(), "srv-stale-"));
217
+ writeFileSync(join(staleState, "dashboard.json"), JSON.stringify({ updatedAt: new Date().toISOString(), tier: "low" }, null, 2));
218
+ const longAgo = Math.floor(Date.now() / 1000) - 7 * 86_400;
219
+ upsertRepoRegistry({ repoRoot: "/home/u/stale", displayName: "stale", stateDir: staleState, checkpointCount: 1, tokensSaved: 100, compressedOriginalBytes: 0, lastSeen: longAgo }, indexDir);
220
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
221
+ try {
222
+ await waitFor(async () => {
223
+ try {
224
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
225
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
226
+ return res.ok;
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ });
232
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
233
+ const body = (await fetch(`http://localhost:${raw.port}/api/servers`).then((r) => r.json()));
234
+ assert.equal(body.servers.length, 1, "only the active repo is returned");
235
+ const s = body.servers[0];
236
+ assert.equal(s.displayName, "active");
237
+ assert.equal(s.tier, "high");
238
+ assert.equal(s.model, "gpt-4o");
239
+ assert.equal(s.provider, "OpenAI");
240
+ assert.equal(s.contextPct, 0.42);
241
+ assert.equal(s.state, "idle");
242
+ assert.deepEqual(s.cacheHits, { session: 3, total: 7, sessionTokensSaved: 1200, totalTokensSaved: 9000 });
243
+ assert.deepEqual(s.compacts, { session: 2, total: 5 });
244
+ const ts = s.timeSaved;
245
+ assert.equal(ts.compact.sessionSec, 1.5);
246
+ assert.equal(ts.cacheHit.sessionSec, 0.6);
247
+ }
248
+ finally {
249
+ child.kill("SIGTERM");
250
+ delete process.env.MEGACOMPACT_INDEX_DIR;
251
+ delete process.env.MEGACOMPACT_DASHBOARD_PORT;
252
+ rmSync(dir, { recursive: true, force: true });
253
+ rmSync(indexDir, { recursive: true, force: true });
254
+ }
255
+ });
256
+ });
198
257
  // ---------------------------------------------------------------------------
199
258
  // Lifecycle integration — launch the compiled server as a real subprocess
200
259
  // (the same way the /dashboard command spawns it) and assert the two failure
@@ -11,7 +11,7 @@ import { compactSession } from "../src/engine.js";
11
11
  import { recallAndInline, recallAndInlineAsync, formatRecallBlock } from "../src/recall.js";
12
12
  import { normalizeSessionId } from "../src/store.js";
13
13
  import { estimateBlockTokens } from "../src/tokens.js";
14
- import { touchSession, logDaily } from "../src/store/sqlite.js";
14
+ import { touchSession, logDaily, incCompactCount, incRecallInjected, incCacheHitTokens } from "../src/store/sqlite.js";
15
15
  import { consolidateMemories } from "../src/memory.js";
16
16
  import { C, MARKER_TYPE, } from "./mega-runtime.js";
17
17
  import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
@@ -104,6 +104,12 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
104
104
  ? result.originalTokenEstimate
105
105
  : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
106
106
  runtime.rt.tokensSaved += saved;
107
+ runtime.rt.compactCount += 1;
108
+ incCompactCount(runtime.currentStateDir);
109
+ if (result.deduped) {
110
+ runtime.rt.cacheHitTokens += saved;
111
+ incCacheHitTokens(saved, runtime.currentStateDir);
112
+ }
107
113
  runtime.rt.lastCompactAt = Date.now();
108
114
  if (result.deduped)
109
115
  runtime.rt.dedupSkips++;
@@ -376,6 +382,15 @@ export function doRecall(runtime, config, ctx, query, source) {
376
382
  runtime.pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
377
383
  runtime.lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
378
384
  }
385
+ if (result.toInject.length > 0) {
386
+ let sumTokens = 0;
387
+ for (const h of result.toInject)
388
+ sumTokens += h.checkpoint.tokenEstimate;
389
+ runtime.rt.recallInjections += result.toInject.length;
390
+ runtime.rt.cacheHitTokens += sumTokens;
391
+ incRecallInjected(result.toInject.length, runtime.currentStateDir);
392
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
393
+ }
379
394
  return result;
380
395
  }
381
396
  /**
@@ -425,6 +440,15 @@ export async function doRecallAsync(runtime, config, ctx, query, source, opts =
425
440
  }
426
441
  }
427
442
  const block = merged.length ? formatRecallBlock(merged) : "";
443
+ if (merged.length > 0) {
444
+ let sumTokens = 0;
445
+ for (const h of merged)
446
+ sumTokens += h.checkpoint.tokenEstimate;
447
+ runtime.rt.recallInjections += merged.length;
448
+ runtime.rt.cacheHitTokens += sumTokens;
449
+ incRecallInjected(merged.length, runtime.currentStateDir);
450
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
451
+ }
428
452
  return {
429
453
  toInject: merged,
430
454
  report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
@@ -16,7 +16,7 @@ import { VectorStore } from "../src/vectorStore.js";
16
16
  import { toEngineMessages } from "../src/adapt.js";
17
17
  import { normalizeSessionId } from "../src/store.js";
18
18
  import { Logger } from "../src/log.js";
19
- import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, } from "../src/store/sqlite.js";
19
+ import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, getDedupStats, getCompactCount, getRecallInjected, getCacheHitTokensSaved, } from "../src/store/sqlite.js";
20
20
  import { detectCrossRepoDrift } from "../src/driftDetection.js";
21
21
  import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens, } from "./mega-config.js";
22
22
  import { Dashboard } from "./mega-dashboard.js";
@@ -57,6 +57,10 @@ export const C = {
57
57
  red: "\x1b[38;5;203m", // pressure / overflow
58
58
  };
59
59
  const PULSE = ["◐", "◓", "◑", "◒"];
60
+ // Rough tokens-processed-per-second heuristic for the dashboard's "time saved"
61
+ // estimate. Throughput varies by model/hardware; this is order-of-magnitude so
62
+ // the dashboard can show a human-readable figure, not a precise measurement.
63
+ const TOKENS_PER_SEC_ESTIMATE = 2000;
60
64
  // ── Full-width widget panel helpers ────────────────────────────────────────
61
65
  // pi's above-editor widget renderer (a Container of Text lines) does NOT pass
62
66
  // a terminal width to setWidget(), so lines render left-aligned by default. To
@@ -192,6 +196,9 @@ export class MegaRuntime {
192
196
  tokensSaved: 0,
193
197
  lastCompactAt: null,
194
198
  lastNativeCompactAt: null,
199
+ compactCount: 0,
200
+ recallInjections: 0,
201
+ cacheHitTokens: 0,
195
202
  };
196
203
  debounceUntil = 0;
197
204
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -390,6 +397,12 @@ export class MegaRuntime {
390
397
  const st = this.store.stats(this.rt.sessionId);
391
398
  const repo = this.store.repoStats();
392
399
  const di = this.store.dataInvariant();
400
+ // Live + store-wide cache-hit / compaction counters for the dashboard.
401
+ const ds = getDedupStats(this.currentStateDir);
402
+ const cacheHitsTotal = ds.deduped + getRecallInjected(this.currentStateDir);
403
+ const cacheHitsTotalTokens = getCacheHitTokensSaved(this.currentStateDir);
404
+ const cacheHitsSession = this.rt.dedupSkips + this.rt.recallInjections;
405
+ const sec = (tok) => (tok || 0) / TOKENS_PER_SEC_ESTIMATE;
393
406
  // Active model/provider for the current-repo card + the multi-repo table.
394
407
  const modelSnap = latestModelSnapshot(this.currentStateDir);
395
408
  const model = modelSnap
@@ -505,6 +518,20 @@ export class MegaRuntime {
505
518
  duplicatesCollapsed: di.duplicatesCollapsed,
506
519
  bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
507
520
  },
521
+ cacheHits: {
522
+ session: cacheHitsSession,
523
+ total: cacheHitsTotal,
524
+ sessionTokensSaved: this.rt.cacheHitTokens,
525
+ totalTokensSaved: cacheHitsTotalTokens,
526
+ },
527
+ compacts: {
528
+ session: this.rt.compactCount,
529
+ total: getCompactCount(this.currentStateDir),
530
+ },
531
+ timeSaved: {
532
+ compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(this.store.repoStats().tokensSaved) },
533
+ cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
534
+ },
508
535
  model,
509
536
  });
510
537
  // Live stats widget above the editor
@@ -722,6 +749,9 @@ export class MegaRuntime {
722
749
  tokensSaved: 0,
723
750
  lastCompactAt: null,
724
751
  lastNativeCompactAt: null,
752
+ compactCount: 0,
753
+ recallInjections: 0,
754
+ cacheHitTokens: 0,
725
755
  };
726
756
  this.statusKey = undefined;
727
757
  this.activeAgents = 0;