pi-mega-compact 0.4.0 → 0.4.1

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
@@ -115,48 +115,64 @@ OpenAI-style contract and the `MEGACOMPACT_EMBEDDING_KEY` / `MEGACOMPACT_EMBEDDI
115
115
  compile). No network call and no API key are needed at runtime.
116
116
  - A pi coding agent install that loads extensions from `~/.pi/agent/extensions/`.
117
117
 
118
- ### From a git checkout
118
+ ### Install from npm (recommended)
119
119
 
120
120
  ```bash
121
- git clone https://github.com/TheArchitectit/pi-mega-compact.git \
122
- ~/.pi/agent/extensions/pi-mega-compact
123
- cd ~/.pi/agent/extensions/pi-mega-compact
124
- npm install
125
- npm run build
121
+ npm install pi-mega-compact
126
122
  ```
127
123
 
124
+ This places the package in `node_modules` and exposes the extension entry at
125
+ `node_modules/pi-mega-compact/extensions/mega-compact.ts`. Then point pi at it
126
+ (see "Register with pi" below).
127
+
128
128
  ### Register with pi
129
129
 
130
- Either copy/link the extension into pi's extensions dir (the clone above already
131
- does), **or** add it to your pi config's `pi.extensions` list:
130
+ Add the extension to your pi config's `pi.extensions` list, pointing at the
131
+ installed entry (npm path or a symlink into pi's extensions dir — either works):
132
132
 
133
133
  ```jsonc
134
134
  {
135
135
  "pi": {
136
- "extensions": ["~/.pi/agent/extensions/pi-mega-compact/extensions/mega-compact.ts"]
136
+ "extensions": ["pi-mega-compact/extensions/mega-compact.ts"]
137
137
  }
138
138
  }
139
139
  ```
140
140
 
141
- Or use the bundled helper (needs `jq`):
141
+ Or symlink the installed package into pi's extensions dir (the simplest path if
142
+ you run pi from the same machine):
142
143
 
143
144
  ```bash
144
- ./install.sh # copy into ~/.pi/agent/extensions/pi-mega-compact
145
- ./install.sh -s # symlink instead of copy (dev mode)
145
+ ln -s "$(npm root)/pi-mega-compact" ~/.pi/agent/extensions/pi-mega-compact
146
146
  ```
147
147
 
148
+ > **From a git checkout (development).** To hack on the extension, clone instead
149
+ > and build locally:
150
+ > ```bash
151
+ > git clone https://github.com/TheArchitectit/pi-mega-compact.git \
152
+ > ~/.pi/agent/extensions/pi-mega-compact
153
+ > cd ~/.pi/agent/extensions/pi-mega-compact
154
+ > npm install && npm run build
155
+ > ```
156
+ > The bundled `./install.sh` helper (`copy`) / `./install.sh -s` (`symlink`) does
157
+ > the same and also registers the path in pi's config (needs `jq`).
158
+
148
159
  ### Verify
149
160
 
150
161
  ```bash
151
- cd ~/.pi/agent/extensions/pi-mega-compact
152
- npm test # all unit/integration tests pass (192 as of v0.2.0)
162
+ npm test # all unit/integration tests pass (278 as of v0.4.0)
153
163
  npm run lint # tsc --noEmit + guardrails scan clean
154
164
  ```
155
165
 
156
166
  ### Uninstall
157
167
 
158
168
  ```bash
159
- rm -rf ~/.pi/agent/extensions/pi-mega-compact
169
+ npm uninstall pi-mega-compact
170
+ ```
171
+
172
+ If you symlinked it into pi's extensions dir, also remove that link:
173
+
174
+ ```bash
175
+ rm -f ~/.pi/agent/extensions/pi-mega-compact
160
176
  ```
161
177
 
162
178
  Then remove the path from pi's `pi.extensions` array.
@@ -53,8 +53,24 @@ interface Snapshot {
53
53
  store: {
54
54
  checkpointCount: number;
55
55
  totalTokenEstimate: number;
56
+ tokensSaved: number;
56
57
  injectedCount: number;
57
58
  dedupHitRate: number;
59
+ storageDedupRate: number;
60
+ dedupCollapsed: number;
61
+ };
62
+ crew: {
63
+ activeAgents: number;
64
+ currentTurn: number;
65
+ };
66
+ repo: {
67
+ checkpointCount: number;
68
+ totalTokenEstimate: number;
69
+ tokensSaved: number;
70
+ sessionCount: number;
71
+ dedupAttempts: number;
72
+ dedupCollapsed: number;
73
+ storageDedupRate: number;
58
74
  };
59
75
  }
60
76
 
@@ -75,7 +91,9 @@ function readSnapshot(snapshotPath: string) {
75
91
  session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
76
92
  context: { tokens: null, percent: null, contextWindow: 0 },
77
93
  trigger: { armed: false, ready: false, currentTokens: null, thresholdTokens: 100_000, fastGatePct: 80 },
78
- store: { checkpointCount: 0, totalTokenEstimate: 0, injectedCount: 0, dedupHitRate: 0 },
94
+ store: { checkpointCount: 0, totalTokenEstimate: 0, tokensSaved: 0, injectedCount: 0, dedupHitRate: 0, storageDedupRate: 0, dedupCollapsed: 0 },
95
+ crew: { activeAgents: 0, currentTurn: 0 },
96
+ repo: { checkpointCount: 0, totalTokenEstimate: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
79
97
  } as Snapshot;
80
98
  }
81
99
  }
@@ -166,12 +184,26 @@ function dashboardHtml(tierName: string): string {
166
184
  <h2>Vector Store</h2>
167
185
  <div class="stat-grid">
168
186
  <span class="label">Checkpoints</span><span class="value" id="st-count">0</span>
169
- <span class="label">Total Tokens</span><span class="value" id="st-tokens">0</span>
187
+ <span class="label">Tokens Stored</span><span class="value" id="st-tokens">0</span>
188
+ <span class="label">Tokens Saved</span><span class="value" id="st-saved">0</span>
170
189
  <span class="label">Injected</span><span class="value" id="st-injected">0</span>
171
190
  <span class="label">Dedup Rate</span><span class="value" id="st-dedup">0%</span>
191
+ <span class="label">Storage Dedup</span><span class="value" id="st-sdedup">0%</span>
192
+ <span class="label">Collapsed</span><span class="value" id="st-collapsed">0</span>
172
193
  <span class="label">Last ID</span><span class="value" id="st-lastid">—</span>
173
194
  </div>
174
195
  </div>
196
+ <div class="card">
197
+ <h2>Repo (all sessions)</h2>
198
+ <div class="stat-grid">
199
+ <span class="label">Checkpoints</span><span class="value" id="rp-count">0</span>
200
+ <span class="label">Tokens Stored</span><span class="value" id="rp-tokens">0</span>
201
+ <span class="label">Tokens Saved</span><span class="value" id="rp-saved">0</span>
202
+ <span class="label">Sessions</span><span class="value" id="rp-sessions">0</span>
203
+ <span class="label">Collapsed</span><span class="value" id="rp-collapsed">0</span>
204
+ <span class="label">Storage Dedup</span><span class="value" id="rp-sdedup">0%</span>
205
+ </div>
206
+ </div>
175
207
  <div class="card">
176
208
  <h2>Configuration</h2>
177
209
  <div class="conf-grid">
@@ -182,6 +214,14 @@ function dashboardHtml(tierName: string): string {
182
214
  <span class="label">Anchor</span><span class="value" id="cf-anchor">—</span>
183
215
  </div>
184
216
  </div>
217
+ <div class="card">
218
+ <h2>Crew / Agents</h2>
219
+ <div class="stat-grid">
220
+ <span class="label">Active Agents</span><span class="value" id="cr-agents">0</span>
221
+ <span class="label">Current Turn</span><span class="value" id="cr-turn">0</span>
222
+ <span class="label">Status</span><span class="value" id="cr-status">idle</span>
223
+ </div>
224
+ </div>
185
225
  </div>
186
226
 
187
227
  <div class="events">
@@ -223,10 +263,31 @@ function dashboardHtml(tierName: string): string {
223
263
 
224
264
  document.getElementById('st-count').textContent = d.store.checkpointCount;
225
265
  document.getElementById('st-tokens').textContent = d.store.totalTokenEstimate.toLocaleString();
266
+ document.getElementById('st-saved').textContent = (d.store.tokensSaved || 0).toLocaleString();
226
267
  document.getElementById('st-injected').textContent = d.store.injectedCount;
227
268
  document.getElementById('st-dedup').textContent = Math.round(d.store.dedupHitRate * 100) + '%';
269
+ var sdr = d.store.storageDedupRate || 0;
270
+ document.getElementById('st-sdedup').textContent = (sdr * 100 >= 10 ? Math.round(sdr * 100) : (sdr * 100).toFixed(1)) + '%';
271
+ document.getElementById('st-collapsed').textContent = d.store.dedupCollapsed || 0;
228
272
  document.getElementById('st-lastid').textContent = d.session.lastCheckpointId || '—';
229
273
 
274
+ // Repo-wide (all sessions in this repo's SQLite store).
275
+ var repo = d.repo || { checkpointCount: 0, totalTokenEstimate: 0, tokensSaved: 0, sessionCount: 0, dedupCollapsed: 0, storageDedupRate: 0 };
276
+ document.getElementById('rp-count').textContent = repo.checkpointCount;
277
+ document.getElementById('rp-tokens').textContent = repo.totalTokenEstimate.toLocaleString();
278
+ document.getElementById('rp-saved').textContent = (repo.tokensSaved || 0).toLocaleString();
279
+ document.getElementById('rp-sessions').textContent = repo.sessionCount || 0;
280
+ document.getElementById('rp-collapsed').textContent = repo.dedupCollapsed || 0;
281
+ var rsdr = repo.storageDedupRate || 0;
282
+ document.getElementById('rp-sdedup').textContent = (rsdr * 100 >= 10 ? Math.round(rsdr * 100) : (rsdr * 100).toFixed(1)) + '%';
283
+
284
+ // Crew / agents (live sub-agent activity + turn).
285
+ var crew = d.crew || { activeAgents: 0, currentTurn: 0 };
286
+ document.getElementById('cr-agents').textContent = crew.activeAgents || 0;
287
+ document.getElementById('cr-turn').textContent = crew.currentTurn || 0;
288
+ document.getElementById('cr-status').textContent = (crew.activeAgents > 0)
289
+ ? ('▶ ' + crew.activeAgents + ' running') : 'idle';
290
+
230
291
  document.getElementById('cf-tier').textContent = d.tier;
231
292
  document.getElementById('cf-threshold').textContent = d.config.thresholdTokens.toLocaleString();
232
293
  document.getElementById('cf-gate').textContent = d.config.fastGatePct + '%';
@@ -322,9 +322,15 @@ test("state snapshot writes dashboard.json after compaction", async () => {
322
322
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
323
323
  // Fire auto-trigger compaction (context event above 80% threshold)
324
324
  await h.fire("context", { type: "context", messages: h.session }, ctx);
325
- const { existsSync: ex } = await import("node:fs");
325
+ const { existsSync: ex, readFileSync: rf } = await import("node:fs");
326
326
  const { join: j } = await import("node:path");
327
- assert.ok(ex(j(h.stateDir, "dashboard.json")), "dashboard.json written after compaction");
327
+ const snapPath = j(h.stateDir, "dashboard.json");
328
+ assert.ok(ex(snapPath), "dashboard.json written after compaction");
329
+ const snap = JSON.parse(rf(snapPath, "utf-8"));
330
+ // Item B: tokensSaved is populated (original − stored) after a real compaction.
331
+ assert.ok(snap.store.tokensSaved > 0, "snapshot.store.tokensSaved > 0 after compaction");
332
+ // Item A: crew (live agent) block is present in the dashboard snapshot.
333
+ assert.ok(snap.crew && typeof snap.crew.activeAgents === "number", "snapshot.crew.activeAgents present");
328
334
  });
329
335
 
330
336
  test("events.log receives compaction events", async () => {
@@ -57,6 +57,7 @@ interface SessionRuntime {
57
57
  lastCompactedTokens: number;
58
58
  dedupSkips: number; // compactions skipped because regionHash already stored
59
59
  dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
60
+ tokensSaved: number; // this session-instance only: reset on session_start
60
61
  }
61
62
 
62
63
  function envFlag(name: string, fallback: number): number {
@@ -190,12 +191,26 @@ interface DashboardSnapshot {
190
191
  store: {
191
192
  checkpointCount: number;
192
193
  totalTokenEstimate: number;
194
+ tokensSaved: number;
193
195
  injectedCount: number;
194
196
  dedupHitRate: number;
195
197
  storageDedupRate: number;
196
198
  dedupAttempts: number;
197
199
  dedupCollapsed: number;
198
200
  };
201
+ crew: {
202
+ activeAgents: number;
203
+ currentTurn: number;
204
+ };
205
+ repo: {
206
+ checkpointCount: number; // across all sessions in this repo's store
207
+ totalTokenEstimate: number; // repo-wide stored checkpoint tokens
208
+ tokensSaved: number; // repo-wide cumulative stored-summary tokens
209
+ sessionCount: number; // distinct sessions with checkpoints
210
+ dedupAttempts: number; // cumulative add() calls (store-wide)
211
+ dedupCollapsed: number; // cumulative deduped collapses (store-wide)
212
+ storageDedupRate: number; // deduped / attempts, 0..1
213
+ };
199
214
  }
200
215
 
201
216
  class Dashboard {
@@ -261,6 +276,7 @@ export default function (pi: ExtensionAPI) {
261
276
  function snapshot(ctx?: ExtensionContext): void {
262
277
  if (ctx) bindRepo(ctx.cwd);
263
278
  const st = store.stats(rt.sessionId);
279
+ const repo = store.repoStats();
264
280
  const armed = lastCtxPercent != null && lastCtxPercent >= config.fastGatePct;
265
281
  const ready = armed && (lastCtxTokens ?? 0) >= config.thresholdTokens;
266
282
  dashboard.snapshot({
@@ -287,7 +303,17 @@ export default function (pi: ExtensionAPI) {
287
303
  },
288
304
  context: { tokens: lastCtxTokens, percent: lastCtxPercent, contextWindow: lastCtxWindow },
289
305
  trigger: { armed, ready, currentTokens: lastCtxTokens, thresholdTokens: config.thresholdTokens, fastGatePct: config.fastGatePct },
290
- store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
306
+ crew: { activeAgents, currentTurn },
307
+ store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, tokensSaved: rt.tokensSaved, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
308
+ repo: {
309
+ checkpointCount: repo.checkpointCount,
310
+ totalTokenEstimate: repo.totalTokenEstimate,
311
+ tokensSaved: repo.tokensSaved,
312
+ sessionCount: repo.sessionCount,
313
+ dedupAttempts: repo.dedupAttempts,
314
+ dedupCollapsed: repo.dedupCollapsed,
315
+ storageDedupRate: repo.storageDedupRate,
316
+ },
291
317
  });
292
318
 
293
319
  // Live stats widget above the editor
@@ -303,7 +329,12 @@ export default function (pi: ExtensionAPI) {
303
329
  const dedupStr = storageRate * 100 >= 10
304
330
  ? `${Math.round(storageRate * 100)}%`
305
331
  : `${(storageRate * 100).toFixed(1)}%`;
306
- const savedStr = st.totalTokenEstimate > 0 ? `${Math.round(st.totalTokenEstimate / 1000)}k` : "0";
332
+ // saved = cumulative original stored tokens (this session). Show real
333
+ // token counts; use "k" only at/above 1000 so small-but-real savings are
334
+ // visible (previously Math.round(x/1000) rounded everything <1000 to 0).
335
+ const savedStr = rt.tokensSaved >= 1000
336
+ ? `${(rt.tokensSaved / 1000).toFixed(1)}k`
337
+ : `${rt.tokensSaved}`;
307
338
  const agentStr = activeAgents > 0 ? ` │ 🤖 ${activeAgents} agent${activeAgents === 1 ? "" : "s"}` : "";
308
339
  const turnStr = currentTurn > 0 ? ` │ turn ${currentTurn}` : "";
309
340
  ctx.ui.setWidget(
@@ -326,6 +357,7 @@ export default function (pi: ExtensionAPI) {
326
357
  lastCompactedTokens: 0,
327
358
  dedupSkips: 0,
328
359
  dedupAttempts: 0,
360
+ tokensSaved: 0,
329
361
  };
330
362
  let debounceUntil = 0;
331
363
  // Agent tracking for real-time widget updates
@@ -352,6 +384,7 @@ export default function (pi: ExtensionAPI) {
352
384
  lastCompactedTokens: 0,
353
385
  dedupSkips: 0,
354
386
  dedupAttempts: 0,
387
+ tokensSaved: 0,
355
388
  };
356
389
  statusKey = undefined;
357
390
  activeAgents = 0;
@@ -392,6 +425,11 @@ export default function (pi: ExtensionAPI) {
392
425
  rt.lastCompactedFrom = result.compactedFrom;
393
426
  rt.lastCompactedTokens = result.tokenEstimate;
394
427
  rt.dedupAttempts++;
428
+ // Per-session "tokens saved" = this session-instance only: the stored-summary
429
+ // tokens persisted on each NEW (non-deduped) compaction. It resets to 0 on
430
+ // session_start (rt is rebuilt) — so a fresh session shows 0 while the repo's
431
+ // cumulative saved (SQLite meta) keeps the historical running total.
432
+ if (!result.deduped) rt.tokensSaved += result.tokenEstimate;
395
433
  if (result.deduped) rt.dedupSkips++;
396
434
 
397
435
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
@@ -145,6 +145,13 @@ function initSchema(db: Database.Database): void {
145
145
  );
146
146
  CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
147
147
  CREATE INDEX IF NOT EXISTS idx_raptor_parent ON raptor_nodes(parent_id);
148
+
149
+ -- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
150
+ CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
151
+ id UNINDEXED,
152
+ normalized_text,
153
+ tokenize='trigram'
154
+ );
148
155
  `);
149
156
  const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
150
157
  | { value: string }
@@ -152,15 +159,80 @@ function initSchema(db: Database.Database): void {
152
159
  if (!v) {
153
160
  db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", String(SCHEMA_VERSION));
154
161
  }
162
+ }
155
163
 
156
- // FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
157
- db.exec(`
158
- CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
159
- id UNINDEXED,
160
- normalized_text,
161
- tokenize='trigram'
162
- );
163
- `);
164
+ /** Read a string-valued meta key (or undefined). Used for cumulative counters. */
165
+ export function getMeta(key: string, stateDir: string = getStateDir()): string | undefined {
166
+ const db = openStore(stateDir);
167
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as
168
+ | { value: string }
169
+ | undefined;
170
+ return row?.value;
171
+ }
172
+
173
+ /**
174
+ * Cumulative "tokens saved" — the sum of stored checkpoint token estimates across
175
+ * all compactions in this store (one per repo). Persisted in the SQLite `meta`
176
+ * table so it survives session restarts and travels with the repo's state dir,
177
+ * mirroring how `storageDedupRate` is cumulative. Incremented in VectorStore.add()
178
+ * when a new (non-deduped) checkpoint is persisted.
179
+ */
180
+ export function getTokensSaved(stateDir: string = getStateDir()): number {
181
+ const raw = getMeta("tokens_saved", stateDir);
182
+ const n = raw == null ? 0 : Number(raw);
183
+ return Number.isFinite(n) ? n : 0;
184
+ }
185
+
186
+ /** Add `delta` (>=0) to the cumulative tokens-saved counter. */
187
+ export function addTokensSaved(delta: number, stateDir: string = getStateDir()): void {
188
+ if (!(delta > 0)) return;
189
+ const db = openStore(stateDir);
190
+ db.prepare(
191
+ `INSERT INTO meta(key, value) VALUES('tokens_saved', ?)
192
+ ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`,
193
+ ).run(String(delta), delta);
194
+ }
195
+
196
+ /** Cumulative store-wide dedup accounting (Sprint 9+). Persisted in the SQLite
197
+ * `meta` table so it survives session restarts and travels with the repo's
198
+ * state dir — mirroring `tokens_saved`. Replaces the legacy JSON
199
+ * `dedup-stats.json` file (all stats now live in the SQLite store). */
200
+ export interface DedupStats {
201
+ /** Total add() calls (new checkpoints + deduped collapses). */
202
+ attempts: number;
203
+ /** add() calls that collapsed onto an existing checkpoint. */
204
+ deduped: number;
205
+ }
206
+
207
+ /** Read a store-wide integer counter from the meta table (0 if absent). */
208
+ export function getMetaNumber(key: string, stateDir: string = getStateDir()): number {
209
+ const raw = getMeta(key, stateDir);
210
+ const n = raw == null ? 0 : Number(raw);
211
+ return Number.isFinite(n) ? n : 0;
212
+ }
213
+
214
+ /** Atomically add `delta` to an integer meta counter. */
215
+ function incMeta(key: string, delta: number, stateDir: string = getStateDir()): void {
216
+ if (!(delta > 0)) return;
217
+ const db = openStore(stateDir);
218
+ db.prepare(
219
+ `INSERT INTO meta(key, value) VALUES(?, ?)
220
+ ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`,
221
+ ).run(key, String(delta), delta);
222
+ }
223
+
224
+ /** Read the cumulative store-wide dedup counters. */
225
+ export function getDedupStats(stateDir: string = getStateDir()): DedupStats {
226
+ return {
227
+ attempts: getMetaNumber("dedup_attempts", stateDir),
228
+ deduped: getMetaNumber("deduped", stateDir),
229
+ };
230
+ }
231
+
232
+ /** Increment the store-wide dedup counters for one add() call. */
233
+ export function bumpDedupStats(deduped: boolean, stateDir: string = getStateDir()): void {
234
+ incMeta("dedup_attempts", 1, stateDir);
235
+ if (deduped) incMeta("deduped", 1, stateDir);
164
236
  }
165
237
 
166
238
  /** Map a DB row to the public StoredCheckpoint shape. */
@@ -423,6 +495,48 @@ export function storeStats(sessionId: string, stateDir: string = getStateDir()):
423
495
  };
424
496
  }
425
497
 
498
+ /** Repo-wide stats — aggregates every session in this store (one per repo).
499
+ * Backed by the SQLite `meta` cumulative counters (`tokens_saved`,
500
+ * `dedup_attempts`, `deduped`) plus a SUM over all `context_chunks`. This is the
501
+ * cumulative, resumable, cross-device view the dashboard surfaces as "Repo …". */
502
+ export interface RepoStats {
503
+ /** Total checkpoints across all sessions (excludes SemDeDup-removed rows). */
504
+ checkpointCount: number;
505
+ /** Sum of all stored checkpoint token estimates (repo-wide). */
506
+ totalTokenEstimate: number;
507
+ /** Total active sessions with at least one checkpoint. */
508
+ sessionCount: number;
509
+ /** Cumulative stored-summary tokens saved (Σ stored summaries). */
510
+ tokensSaved: number;
511
+ /** Cumulative dedup add() attempts (store-wide). */
512
+ dedupAttempts: number;
513
+ /** Cumulative deduped collapses (store-wide). */
514
+ dedupCollapsed: number;
515
+ /** Storage dedup rate (deduped / attempts), 0..1. */
516
+ storageDedupRate: number;
517
+ }
518
+
519
+ export function repoStats(stateDir: string = getStateDir()): RepoStats {
520
+ const db = openStore(stateDir);
521
+ const row = db
522
+ .prepare(
523
+ `SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
524
+ COUNT(DISTINCT session_id) AS sessions
525
+ FROM context_chunks WHERE dedup_status != 'removed'`,
526
+ )
527
+ .get() as { c: number; tok: number; sessions: number };
528
+ const ds = getDedupStats(stateDir);
529
+ return {
530
+ checkpointCount: row.c,
531
+ totalTokenEstimate: row.tok,
532
+ sessionCount: row.sessions,
533
+ tokensSaved: getMetaNumber("tokens_saved", stateDir),
534
+ dedupAttempts: ds.attempts,
535
+ dedupCollapsed: ds.deduped,
536
+ storageDedupRate: ds.attempts === 0 ? 0 : ds.deduped / ds.attempts,
537
+ };
538
+ }
539
+
426
540
  /** Close and evict a cached connection (test teardown only). */
427
541
  export function closeStore(stateDir: string): void {
428
542
  const db = cache.get(stateDir);
package/src/store.ts CHANGED
@@ -168,25 +168,8 @@ export function saveSessionState(sessionId: string, state: SessionState, stateDi
168
168
  }
169
169
 
170
170
  /**
171
- * Cumulative store-wide dedup accounting. Lives outside the per-session
172
- * SessionState (which resets on every session instance) so the dedup rate
173
- * stays stable across session restarts. Persisted as plain JSON in the
174
- * state dir.
171
+ * Cumulative store-wide dedup accounting now lives in the SQLite `meta` table
172
+ * (see store/sqlite.ts: getDedupStats / bumpDedupStats). All store stats are
173
+ * SQLite-backed so they survive session restarts and travel with the repo's
174
+ * state dir. The legacy JSON `dedup-stats.json` path was removed.
175
175
  */
176
- export interface DedupStats {
177
- /** Total add() calls (new checkpoints + deduped collapses). */
178
- attempts: number;
179
- /** add() calls that collapsed onto an existing checkpoint. */
180
- deduped: number;
181
- }
182
-
183
- const DEDUP_STATS_FILE = "dedup-stats.json";
184
-
185
- export function loadDedupStats(stateDir: string = getStateDir()): DedupStats {
186
- const file = join(stateDir, DEDUP_STATS_FILE);
187
- return readGzJson<DedupStats>(file, { attempts: 0, deduped: 0 });
188
- }
189
-
190
- export function saveDedupStats(stats: DedupStats, stateDir: string = getStateDir()): void {
191
- writeGzJson(join(stateDir, DEDUP_STATS_FILE), stats);
192
- }
@@ -250,6 +250,51 @@ test("stats reports counts, last checkpoint, and dedup rate", () => {
250
250
  assert.ok(Math.abs(st2.dedupHitRate - 0.5) < 1e-9);
251
251
  });
252
252
 
253
+ test("tokensSaved accumulates on new checkpoints; deduped add bumps collapsed not saved", () => {
254
+ const s = store();
255
+ // Two genuinely new checkpoints (stored-sum definition of tokensSaved).
256
+ s.add({ sessionId: "sess_saved", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, timestamp: 1 });
257
+ s.add({ sessionId: "sess_saved", summary: "beta", regionText: "region beta text", tokenEstimate: 700, timestamp: 2 });
258
+ const st = s.stats("sess_saved");
259
+ assert.equal(st.tokensSaved, 1200, "per-session tokensSaved = Σ stored summary tokens");
260
+ assert.equal(st.dedupCollapsed, 0);
261
+ assert.equal(st.dedupAttempts, 2);
262
+
263
+ // A third add that dedups onto an existing region (same summaryHash path):
264
+ // collapses onto an existing checkpoint, so it must NOT grow tokensSaved, but
265
+ // must bump dedupCollapsed + dedupAttempts.
266
+ const deduped = s.add({ sessionId: "sess_saved", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, timestamp: 3 });
267
+ assert.ok(deduped.deduped, "identical region should dedup");
268
+ const st3 = s.stats("sess_saved");
269
+ assert.equal(st3.tokensSaved, 1200, "deduped add does not add to tokensSaved (stored-sum)");
270
+ assert.equal(st3.dedupCollapsed, 1, "deduped collapse counted separately");
271
+ assert.equal(st3.dedupAttempts, 3);
272
+ });
273
+
274
+ test("repoStats aggregates every session in the SQLite store", () => {
275
+ const dir = join(baseTmp, `repo-${counter++}`);
276
+ const a = new VectorStore({ dedupSim: 0.9, stateDir: dir });
277
+ const b = new VectorStore({ dedupSim: 0.9, stateDir: dir }); // same disk store, diff instance
278
+ a.add({ sessionId: "sess_a", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, timestamp: 1 });
279
+ b.add({ sessionId: "sess_b", summary: "beta", regionText: "region beta text", tokenEstimate: 700, timestamp: 2 });
280
+
281
+ const repo = a.repoStats();
282
+ assert.equal(repo.checkpointCount, 2, "checkpoints across both sessions");
283
+ assert.equal(repo.sessionCount, 2, "two distinct sessions");
284
+ assert.equal(repo.totalTokenEstimate, 1200);
285
+ assert.equal(repo.tokensSaved, 1200, "repo-wide cumulative stored-summary tokens");
286
+ assert.equal(repo.dedupCollapsed, 0);
287
+
288
+ // A deduped add into sess_a collapses onto its existing checkpoint: repo
289
+ // tokensSaved stays put, dedupCollapsed climbs.
290
+ const deduped = a.add({ sessionId: "sess_a", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, timestamp: 3 });
291
+ assert.ok(deduped.deduped);
292
+ const repo2 = a.repoStats();
293
+ assert.equal(repo2.tokensSaved, 1200, "deduped collapse does not change repo tokensSaved");
294
+ assert.equal(repo2.dedupCollapsed, 1);
295
+ assert.equal(repo2.checkpointCount, 2, "still two stored checkpoints");
296
+ });
297
+
253
298
  test("computeRegionHash normalizes whitespace before hashing", () => {
254
299
  const h1 = computeRegionHash("foo bar");
255
300
  const h2 = computeRegionHash("foo bar");
@@ -14,7 +14,7 @@ import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
14
14
  import { loadDedupConfig, type DedupConfigShape, type DedupTier } from "./config/dedup.js";
15
15
  import { logDecision } from "./monitoring.js";
16
16
  import type { StoredCheckpoint, SessionState } from "./store.js";
17
- import { getStateDir, normalizeSessionId, compressSmart, loadDedupStats, saveDedupStats } from "./store.js";
17
+ import { getStateDir, normalizeSessionId, compressSmart } from "./store.js";
18
18
  import { computeContentDigest } from "./dedup/digest.js";
19
19
  import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "./dedup/l1-minhash.js";
20
20
  import { lshBands } from "./dedup/l1-lsh.js";
@@ -32,6 +32,10 @@ import {
32
32
  insertLshBuckets,
33
33
  lshCandidateChunks,
34
34
  setDedupStatus,
35
+ addTokensSaved,
36
+ getDedupStats,
37
+ bumpDedupStats,
38
+ repoStats as repoStatsFromStore,
35
39
  } from "./store/sqlite.js";
36
40
  import { migrateJsonToSqlite } from "./store/migrate.js";
37
41
 
@@ -137,9 +141,6 @@ export class VectorStore {
137
141
  const regionHash = computeRegionHash(input.regionText);
138
142
  const all = listCheckpoints(sessionId, this.stateDir);
139
143
  const cfg = this.cfg;
140
- // Cumulative store-wide dedup accounting (survives session resets).
141
- const ds = loadDedupStats(this.stateDir);
142
- ds.attempts++;
143
144
  // Tracks whether a tier matched while in MARK_ONLY (record-but-don't-collapse),
144
145
  // and which tier.
145
146
  let markOnly: DedupTier | null = null;
@@ -165,8 +166,7 @@ export class VectorStore {
165
166
  } else {
166
167
  contentMatch.timestamp = input.timestamp;
167
168
  upsertCheckpoint(contentMatch, this.stateDir);
168
- ds.deduped++;
169
- saveDedupStats(ds, this.stateDir);
169
+ bumpDedupStats(true, this.stateDir);
170
170
  const r = { checkpoint: contentMatch, deduped: true, reason: "contentHash" };
171
171
  this.record("L0", "deduped", "contentHash", Date.now() - t0);
172
172
  return r;
@@ -181,8 +181,7 @@ export class VectorStore {
181
181
  if (cfg.MARK_ONLY_L0) {
182
182
  markOnly = "L0"; // fall through
183
183
  } else {
184
- ds.deduped++;
185
- saveDedupStats(ds, this.stateDir);
184
+ bumpDedupStats(true, this.stateDir);
186
185
  const r = { checkpoint: regionMatch, deduped: true, reason: "regionHash" };
187
186
  this.record("L0", "deduped", "regionHash", Date.now() - t0);
188
187
  return r;
@@ -203,8 +202,7 @@ export class VectorStore {
203
202
  } else {
204
203
  summaryMatch.timestamp = input.timestamp;
205
204
  upsertCheckpoint(summaryMatch, this.stateDir);
206
- ds.deduped++;
207
- saveDedupStats(ds, this.stateDir);
205
+ bumpDedupStats(true, this.stateDir);
208
206
  const r = { checkpoint: summaryMatch, deduped: true, reason: "summaryHash" };
209
207
  this.record("L0", "deduped", "summaryHash", Date.now() - t0);
210
208
  return r;
@@ -221,8 +219,7 @@ export class VectorStore {
221
219
  if (l1 && !cfg.MARK_ONLY_L1) {
222
220
  l1.timestamp = input.timestamp;
223
221
  upsertCheckpoint(l1, this.stateDir);
224
- ds.deduped++;
225
- saveDedupStats(ds, this.stateDir);
222
+ bumpDedupStats(true, this.stateDir);
226
223
  const r = { checkpoint: l1, deduped: true, reason: "l1MinHash" };
227
224
  this.record("L1", "deduped", "l1MinHash", Date.now() - t0);
228
225
  return r;
@@ -257,8 +254,7 @@ export class VectorStore {
257
254
  // Near-identical — update timestamp on existing checkpoint
258
255
  nearest.checkpoint.timestamp = input.timestamp;
259
256
  upsertCheckpoint(nearest.checkpoint, this.stateDir);
260
- ds.deduped++;
261
- saveDedupStats(ds, this.stateDir);
257
+ bumpDedupStats(true, this.stateDir);
262
258
  const r = { checkpoint: nearest.checkpoint, deduped: true, reason: "contentSimilarity" };
263
259
  this.record("L2", "deduped", "contentSimilarity", Date.now() - t0);
264
260
  return r;
@@ -291,6 +287,9 @@ export class VectorStore {
291
287
  // Persistence is SQLite (store/sqlite.ts). upsertCheckpoint keeps the
292
288
  // idempotent-by-id semantics the old JSON append implied.
293
289
  upsertCheckpoint(checkpoint, this.stateDir);
290
+ // Cumulative "tokens saved" counter (per-repo SQLite meta). Bumped for every
291
+ // new checkpoint persisted, so it survives sessions and travels with the repo.
292
+ addTokensSaved(input.tokenEstimate ?? 0, this.stateDir);
294
293
  // L1: persist this checkpoint's MinHash signature + LSH buckets so future
295
294
  // near-duplicate inserts can find it. Deterministic given the seed.
296
295
  const sig = minhashSignature(input.regionText);
@@ -320,7 +319,8 @@ export class VectorStore {
320
319
  } else {
321
320
  this.record("L0", "new", undefined, Date.now() - t0);
322
321
  }
323
- saveDedupStats(ds, this.stateDir);
322
+ // Cumulative store-wide dedup accounting (attempt, not collapsed).
323
+ bumpDedupStats(false, this.stateDir);
324
324
  return { checkpoint, deduped: false };
325
325
  }
326
326
 
@@ -517,6 +517,7 @@ export class VectorStore {
517
517
  injectedCount: number;
518
518
  dedupHitRate: number; // injected / checkpoints, 0..1
519
519
  storageDedupRate: number; // deduped adds / total adds, 0..1 (cumulative)
520
+ tokensSaved: number; // cumulative stored checkpoint tokens (per-repo SQLite)
520
521
  dedupAttempts: number; // cumulative add() calls (store-wide)
521
522
  dedupCollapsed: number; // cumulative deduped collapses (store-wide)
522
523
  } {
@@ -528,17 +529,31 @@ export class VectorStore {
528
529
  );
529
530
  const last = ordered[ordered.length - 1];
530
531
  const injected = state.injectedCheckpointIds.length;
531
- const ds = loadDedupStats(this.stateDir);
532
+ const ds = getDedupStats(this.stateDir);
533
+ // Per-session "tokens saved" = this session's Σ stored summary token
534
+ // estimates (stored-sum definition). Equal to totalTokenEstimate by
535
+ // construction; repo-wide cumulative saved lives in repoStats().
536
+ const sessionTok = cps.reduce((s, c) => s + (c.tokenEstimate ?? 0), 0);
532
537
  return {
533
538
  checkpointCount: cps.length,
534
- totalTokenEstimate: cps.reduce((s, c) => s + (c.tokenEstimate ?? 0), 0),
539
+ totalTokenEstimate: sessionTok,
535
540
  lastCheckpointId: last?.checkpointId,
536
541
  lastSummary: last?.summary,
537
542
  injectedCount: injected,
538
543
  dedupHitRate: cps.length === 0 ? 0 : injected / cps.length,
539
544
  storageDedupRate: ds.attempts === 0 ? 0 : ds.deduped / ds.attempts,
545
+ tokensSaved: sessionTok,
540
546
  dedupAttempts: ds.attempts,
541
547
  dedupCollapsed: ds.deduped,
542
548
  };
543
549
  }
550
+
551
+ /**
552
+ * Repo-wide stats — aggregates every session in this store (one per repo).
553
+ * Cumulative, resumable, cross-device. Surfaces the dashboard's "Repo …"
554
+ * figures; distinct from {@link stats} (per-session).
555
+ */
556
+ repoStats(): ReturnType<typeof repoStatsFromStore> {
557
+ return repoStatsFromStore(this.stateDir);
558
+ }
544
559
  }