pi-mega-compact 0.4.1 → 0.4.2

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.
@@ -53,6 +53,7 @@ interface Snapshot {
53
53
  store: {
54
54
  checkpointCount: number;
55
55
  totalTokenEstimate: number;
56
+ originalTokens: number;
56
57
  tokensSaved: number;
57
58
  injectedCount: number;
58
59
  dedupHitRate: number;
@@ -66,6 +67,7 @@ interface Snapshot {
66
67
  repo: {
67
68
  checkpointCount: number;
68
69
  totalTokenEstimate: number;
70
+ originalTokens: number;
69
71
  tokensSaved: number;
70
72
  sessionCount: number;
71
73
  dedupAttempts: number;
@@ -91,9 +93,9 @@ function readSnapshot(snapshotPath: string) {
91
93
  session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
92
94
  context: { tokens: null, percent: null, contextWindow: 0 },
93
95
  trigger: { armed: false, ready: false, currentTokens: null, thresholdTokens: 100_000, fastGatePct: 80 },
94
- store: { checkpointCount: 0, totalTokenEstimate: 0, tokensSaved: 0, injectedCount: 0, dedupHitRate: 0, storageDedupRate: 0, dedupCollapsed: 0 },
96
+ store: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, injectedCount: 0, dedupHitRate: 0, storageDedupRate: 0, dedupCollapsed: 0 },
95
97
  crew: { activeAgents: 0, currentTurn: 0 },
96
- repo: { checkpointCount: 0, totalTokenEstimate: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
98
+ repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
97
99
  } as Snapshot;
98
100
  }
99
101
  }
@@ -185,6 +187,7 @@ function dashboardHtml(tierName: string): string {
185
187
  <div class="stat-grid">
186
188
  <span class="label">Checkpoints</span><span class="value" id="st-count">0</span>
187
189
  <span class="label">Tokens Stored</span><span class="value" id="st-tokens">0</span>
190
+ <span class="label">Original Tokens</span><span class="value" id="st-orig">0</span>
188
191
  <span class="label">Tokens Saved</span><span class="value" id="st-saved">0</span>
189
192
  <span class="label">Injected</span><span class="value" id="st-injected">0</span>
190
193
  <span class="label">Dedup Rate</span><span class="value" id="st-dedup">0%</span>
@@ -198,6 +201,7 @@ function dashboardHtml(tierName: string): string {
198
201
  <div class="stat-grid">
199
202
  <span class="label">Checkpoints</span><span class="value" id="rp-count">0</span>
200
203
  <span class="label">Tokens Stored</span><span class="value" id="rp-tokens">0</span>
204
+ <span class="label">Original Tokens</span><span class="value" id="rp-orig">0</span>
201
205
  <span class="label">Tokens Saved</span><span class="value" id="rp-saved">0</span>
202
206
  <span class="label">Sessions</span><span class="value" id="rp-sessions">0</span>
203
207
  <span class="label">Collapsed</span><span class="value" id="rp-collapsed">0</span>
@@ -263,6 +267,7 @@ function dashboardHtml(tierName: string): string {
263
267
 
264
268
  document.getElementById('st-count').textContent = d.store.checkpointCount;
265
269
  document.getElementById('st-tokens').textContent = d.store.totalTokenEstimate.toLocaleString();
270
+ document.getElementById('st-orig').textContent = (d.store.originalTokens || 0).toLocaleString();
266
271
  document.getElementById('st-saved').textContent = (d.store.tokensSaved || 0).toLocaleString();
267
272
  document.getElementById('st-injected').textContent = d.store.injectedCount;
268
273
  document.getElementById('st-dedup').textContent = Math.round(d.store.dedupHitRate * 100) + '%';
@@ -272,9 +277,10 @@ function dashboardHtml(tierName: string): string {
272
277
  document.getElementById('st-lastid').textContent = d.session.lastCheckpointId || '—';
273
278
 
274
279
  // 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 };
280
+ var repo = d.repo || { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupCollapsed: 0, storageDedupRate: 0 };
276
281
  document.getElementById('rp-count').textContent = repo.checkpointCount;
277
282
  document.getElementById('rp-tokens').textContent = repo.totalTokenEstimate.toLocaleString();
283
+ document.getElementById('rp-orig').textContent = (repo.originalTokens || 0).toLocaleString();
278
284
  document.getElementById('rp-saved').textContent = (repo.tokensSaved || 0).toLocaleString();
279
285
  document.getElementById('rp-sessions').textContent = repo.sessionCount || 0;
280
286
  document.getElementById('rp-collapsed').textContent = repo.dedupCollapsed || 0;
@@ -327,8 +327,17 @@ test("state snapshot writes dashboard.json after compaction", async () => {
327
327
  const snapPath = j(h.stateDir, "dashboard.json");
328
328
  assert.ok(ex(snapPath), "dashboard.json written after compaction");
329
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");
330
+ // Item B: the honest token model is wired the original dropped region was
331
+ // captured (originalTokens > 0), and the saved amount never exceeds the
332
+ // original (saved = max(0, original − stored) ≤ original). For this tiny
333
+ // harness session the summary can be ≥ the region, so saved may be 0; the
334
+ // positive "saved > 0" case with a large region is covered by the
335
+ // vectorStore unit tests.
336
+ assert.ok(snap.store.originalTokens > 0, "snapshot.store.originalTokens captured after compaction");
337
+ assert.ok(
338
+ snap.store.originalTokens >= snap.store.tokensSaved,
339
+ "model invariant: original region >= tokens saved",
340
+ );
332
341
  // Item A: crew (live agent) block is present in the dashboard snapshot.
333
342
  assert.ok(snap.crew && typeof snap.crew.activeAgents === "number", "snapshot.crew.activeAgents present");
334
343
  });
@@ -37,6 +37,7 @@ import { recallAndInline } from "../src/recall.js";
37
37
  import { autoCompactCheck } from "../src/compact.js";
38
38
  import { estimateSessionTokens } from "../src/tokens.js";
39
39
  import { normalizeSessionId } from "../src/store.js";
40
+ import { touchSession, logDaily } from "../src/store/sqlite.js";
40
41
  import { Logger } from "../src/log.js";
41
42
  import type { EngineMessage } from "../src/types.js";
42
43
  import { writeFileSync, appendFileSync, readFileSync } from "node:fs";
@@ -191,7 +192,8 @@ interface DashboardSnapshot {
191
192
  store: {
192
193
  checkpointCount: number;
193
194
  totalTokenEstimate: number;
194
- tokensSaved: number;
195
+ originalTokens: number; // Σ original dropped-region tokens (this session)
196
+ tokensSaved: number; // Σ(original − stored) for this session
195
197
  injectedCount: number;
196
198
  dedupHitRate: number;
197
199
  storageDedupRate: number;
@@ -205,7 +207,8 @@ interface DashboardSnapshot {
205
207
  repo: {
206
208
  checkpointCount: number; // across all sessions in this repo's store
207
209
  totalTokenEstimate: number; // repo-wide stored checkpoint tokens
208
- tokensSaved: number; // repo-wide cumulative stored-summary tokens
210
+ originalTokens: number; // repo-wide Σ original dropped-region tokens
211
+ tokensSaved: number; // repo-wide cumulative (original − stored) + deduped orig
209
212
  sessionCount: number; // distinct sessions with checkpoints
210
213
  dedupAttempts: number; // cumulative add() calls (store-wide)
211
214
  dedupCollapsed: number; // cumulative deduped collapses (store-wide)
@@ -304,10 +307,11 @@ export default function (pi: ExtensionAPI) {
304
307
  context: { tokens: lastCtxTokens, percent: lastCtxPercent, contextWindow: lastCtxWindow },
305
308
  trigger: { armed, ready, currentTokens: lastCtxTokens, thresholdTokens: config.thresholdTokens, fastGatePct: config.fastGatePct },
306
309
  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 },
310
+ store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, originalTokens: st.originalTokens, tokensSaved: rt.tokensSaved, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
308
311
  repo: {
309
312
  checkpointCount: repo.checkpointCount,
310
313
  totalTokenEstimate: repo.totalTokenEstimate,
314
+ originalTokens: repo.originalTokens,
311
315
  tokensSaved: repo.tokensSaved,
312
316
  sessionCount: repo.sessionCount,
313
317
  dedupAttempts: repo.dedupAttempts,
@@ -425,13 +429,28 @@ export default function (pi: ExtensionAPI) {
425
429
  rt.lastCompactedFrom = result.compactedFrom;
426
430
  rt.lastCompactedTokens = result.tokenEstimate;
427
431
  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;
432
+ // Honest "tokens saved" for this session-instance only:
433
+ // new checkpoint → original stored
434
+ // deduped onto existing whole original region (nothing new stored)
435
+ // Resets to 0 on session_start (rt is rebuilt) so a fresh session shows 0
436
+ // while the repo's cumulative saved (SQLite meta) keeps the running total.
437
+ const saved = result.deduped
438
+ ? result.originalTokenEstimate
439
+ : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
440
+ rt.tokensSaved += saved;
433
441
  if (result.deduped) rt.dedupSkips++;
434
442
 
443
+ // Record session activity + a daily-log entry in the per-repo SQLite store
444
+ // (foundation for resume-sessions / daily-log features). Best-effort — never
445
+ // block a compaction on bookkeeping.
446
+ try {
447
+ const repo = resolveRepoRoot(ctx.cwd);
448
+ touchSession(sid, repo, currentStateDir);
449
+ logDaily(sid, "compact", result.checkpointId, saved, currentStateDir);
450
+ } catch {
451
+ /* non-fatal: stats bookkeeping only */
452
+ }
453
+
435
454
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
436
455
  // skip re-vectorizing an already-compacted region (zero token cost).
437
456
  pi.appendEntry(MARKER_TYPE, {
@@ -441,7 +460,6 @@ export default function (pi: ExtensionAPI) {
441
460
  deduped: result.deduped,
442
461
  });
443
462
 
444
- const saved = result.tokenEstimate;
445
463
  setStatus(
446
464
  ctx,
447
465
  rt.persistedThisSession
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",
package/src/engine.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import { findSuperseded, supersede } from "./supersede.js";
16
16
  import { summarizeMessages, mergeCompactSummaries, formatCompactSummary } from "./compact.js";
17
17
  import { extractiveSummarize } from "./extractive.js";
18
- import { estimateSessionTokens } from "./tokens.js";
18
+ import { estimateSessionTokens, estimateBlockTokens } from "./tokens.js";
19
19
  import { computeRegionHash, VectorStore, type SearchHit } from "./vectorStore.js";
20
20
  import type { EngineMessage } from "./types.js";
21
21
 
@@ -51,6 +51,11 @@ export interface CompactResult {
51
51
  summary: string;
52
52
  regionHash: string;
53
53
  tokenEstimate: number;
54
+ /** Token count of the original dropped region (before compaction). The honest
55
+ * "tokens saved" base = originalTokenEstimate − tokenEstimate (stored), or the
56
+ * full originalTokenEstimate when the region deduped onto an existing
57
+ * checkpoint (nothing new stored). */
58
+ originalTokenEstimate: number;
54
59
  /** Index in `messages` where the compacted slice begins (for the caller to
55
60
  * build a drop range). */
56
61
  compactedFrom: number;
@@ -87,6 +92,7 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
87
92
  summary: "",
88
93
  regionHash: "",
89
94
  tokenEstimate: 0,
95
+ originalTokenEstimate: 0,
90
96
  compactedFrom,
91
97
  };
92
98
  }
@@ -105,7 +111,6 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
105
111
  let keyDecisions: string[];
106
112
  let nextSteps: string[];
107
113
  let filesModified: string[];
108
- let tokenEstimate: number;
109
114
 
110
115
  if (useExtractive && !input.summary) {
111
116
  const ext = extractiveSummarize(keep);
@@ -114,7 +119,6 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
114
119
  keyDecisions = input.keyDecisions ?? ext.keyDecisions;
115
120
  nextSteps = input.nextSteps ?? ext.nextSteps;
116
121
  filesModified = input.filesModified ?? ext.filesModified;
117
- tokenEstimate = input.tokenEstimate ?? ext.tokenEstimate;
118
122
  } else {
119
123
  const collapsed = input.summary ?? summarizeMessages(keep);
120
124
  summary = formatCompactSummary(collapsed);
@@ -122,9 +126,18 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
122
126
  keyDecisions = input.keyDecisions ?? [];
123
127
  nextSteps = input.nextSteps ?? [];
124
128
  filesModified = input.filesModified ?? [];
125
- tokenEstimate = input.tokenEstimate ?? estimateSessionTokens(compactable);
126
129
  }
127
130
 
131
+ // Honest "tokens saved" accounting:
132
+ // - originalTokenEstimate = the dropped region's token count (what context
133
+ // held before compaction) = the compacted slice's tokens.
134
+ // - storedTokens = the persisted summary's token count, computed from the
135
+ // actual summary string so it's honest for BOTH the extractive and legacy
136
+ // COLLAPSE paths (the legacy path's fallback estimateSessionTokens is the
137
+ // *original* size, not the stored size).
138
+ const originalTokenEstimate = estimateSessionTokens(compactable);
139
+ const storedTokens = estimateBlockTokens(summary);
140
+
128
141
  // Region text = the compacted slice, used for dedup + embedding.
129
142
  const regionText = input.regionText ?? keep.map((m) => m.text).join("\n");
130
143
  const regionHash = computeRegionHash(regionText);
@@ -137,7 +150,8 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
137
150
  nextSteps,
138
151
  filesModified,
139
152
  regionText,
140
- tokenEstimate,
153
+ tokenEstimate: storedTokens,
154
+ originalTokenEstimate,
141
155
  timestamp: input.timestamp ?? 0,
142
156
  });
143
157
 
@@ -148,7 +162,8 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
148
162
  checkpointId: add.checkpoint.checkpointId,
149
163
  summary,
150
164
  regionHash,
151
- tokenEstimate,
165
+ tokenEstimate: storedTokens,
166
+ originalTokenEstimate,
152
167
  compactedFrom,
153
168
  };
154
169
  }
@@ -80,6 +80,7 @@ function initSchema(db: Database.Database): void {
80
80
  files_modified TEXT, -- JSON array
81
81
  embedding_blob BLOB, -- float32 vector
82
82
  token_estimate INTEGER,
83
+ original_token_estimate INTEGER, -- dropped region size (tokens saved = orig − stored)
83
84
  timestamp INTEGER,
84
85
  dedup_status TEXT DEFAULT 'active',
85
86
  compressed_original BLOB -- optional DR copy
@@ -146,6 +147,42 @@ function initSchema(db: Database.Database): void {
146
147
  CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
147
148
  CREATE INDEX IF NOT EXISTS idx_raptor_parent ON raptor_nodes(parent_id);
148
149
 
150
+ -- Foundation for future features (resume sessions, daily log, lessons
151
+ -- learned). Scaffolded now so all store data lives in SQLite from day one;
152
+ -- population is minimal (touchSession / logDaily on compact) and the full
153
+ -- UI/recall for these lands in later sprints.
154
+
155
+ -- Per-session registry (resume + per-repo session history).
156
+ CREATE TABLE IF NOT EXISTS sessions (
157
+ session_id TEXT PRIMARY KEY,
158
+ repo TEXT,
159
+ started_at INTEGER,
160
+ ended_at INTEGER,
161
+ last_compacted_at INTEGER,
162
+ status TEXT DEFAULT 'active'
163
+ );
164
+
165
+ -- Append-only daily activity log (the "daily log" feature seed).
166
+ CREATE TABLE IF NOT EXISTS daily_log (
167
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
168
+ day TEXT NOT NULL, -- YYYY-MM-DD
169
+ session_id TEXT,
170
+ event TEXT, -- e.g. 'compact'
171
+ detail TEXT,
172
+ tokens_saved INTEGER DEFAULT 0,
173
+ ts INTEGER
174
+ );
175
+ CREATE INDEX IF NOT EXISTS idx_daily_log_day ON daily_log(day);
176
+
177
+ -- Lessons learned (future recall/browse feature seed).
178
+ CREATE TABLE IF NOT EXISTS lessons (
179
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
180
+ session_id TEXT,
181
+ repo TEXT,
182
+ lesson TEXT,
183
+ ts INTEGER
184
+ );
185
+
149
186
  -- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
150
187
  CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
151
188
  id UNINDEXED,
@@ -235,6 +272,65 @@ export function bumpDedupStats(deduped: boolean, stateDir: string = getStateDir(
235
272
  if (deduped) incMeta("deduped", 1, stateDir);
236
273
  }
237
274
 
275
+ // --- Future-feature foundation (resume sessions / daily log / lessons) -------
276
+ // Scaffolded tables + minimal helpers so all store data lives in SQLite from
277
+ // day one. Full UI/recall for these lands in later sprints.
278
+
279
+ /** Upsert a `sessions` row (resume + per-repo session history). */
280
+ export function touchSession(
281
+ sessionId: string,
282
+ repo: string | undefined,
283
+ stateDir: string = getStateDir(),
284
+ ): void {
285
+ const db = openStore(stateDir);
286
+ const sid = normalizeSessionId(sessionId);
287
+ const existing = db
288
+ .prepare("SELECT started_at FROM sessions WHERE session_id = ?")
289
+ .get(sid) as { started_at: number | null } | undefined;
290
+ const now = Math.floor(Date.now() / 1000);
291
+ if (!existing) {
292
+ db.prepare(
293
+ `INSERT INTO sessions(session_id, repo, started_at, last_compacted_at, status)
294
+ VALUES(?, ?, ?, ?, 'active')`,
295
+ ).run(sid, repo ?? null, now, now);
296
+ } else {
297
+ db.prepare(
298
+ "UPDATE sessions SET last_compacted_at = ?, repo = COALESCE(?, repo), status = 'active' WHERE session_id = ?",
299
+ ).run(now, repo ?? null, sid);
300
+ }
301
+ }
302
+
303
+ /** Append a `daily_log` entry (day = YYYY-MM-DD, local-naive from Date). */
304
+ export function logDaily(
305
+ sessionId: string,
306
+ event: string,
307
+ detail: string | undefined,
308
+ tokensSaved: number,
309
+ stateDir: string = getStateDir(),
310
+ ): void {
311
+ const db = openStore(stateDir);
312
+ const day = new Date().toISOString().slice(0, 10);
313
+ const now = Math.floor(Date.now() / 1000);
314
+ db.prepare(
315
+ `INSERT INTO daily_log(day, session_id, event, detail, tokens_saved, ts)
316
+ VALUES(?, ?, ?, ?, ?, ?)`,
317
+ ).run(day, normalizeSessionId(sessionId), event, detail ?? null, tokensSaved, now);
318
+ }
319
+
320
+ /** Append a `lessons` entry (future lessons-learned browse/recall). */
321
+ export function addLesson(
322
+ sessionId: string,
323
+ repo: string | undefined,
324
+ lesson: string,
325
+ stateDir: string = getStateDir(),
326
+ ): void {
327
+ const db = openStore(stateDir);
328
+ const now = Math.floor(Date.now() / 1000);
329
+ db.prepare(
330
+ `INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`,
331
+ ).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
332
+ }
333
+
238
334
  /** Map a DB row to the public StoredCheckpoint shape. */
239
335
  function rowToCheckpoint(row: any): StoredCheckpoint {
240
336
  return {
@@ -247,6 +343,7 @@ function rowToCheckpoint(row: any): StoredCheckpoint {
247
343
  nextSteps: row.next_steps ? JSON.parse(row.next_steps) : [],
248
344
  filesModified: row.files_modified ? JSON.parse(row.files_modified) : [],
249
345
  tokenEstimate: row.token_estimate ?? 0,
346
+ originalTokenEstimate: row.original_token_estimate ?? undefined,
250
347
  regionHash: row.region_hash ?? "",
251
348
  contentHash: row.content_hash ?? undefined,
252
349
  contentHash2: row.content_hash2 ?? undefined,
@@ -269,11 +366,11 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
269
366
  (id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
270
367
  normalized_text, summary, topic_summary, summary_hash,
271
368
  key_decisions, next_steps, files_modified, embedding_blob,
272
- token_estimate, timestamp, dedup_status, compressed_original)
369
+ token_estimate, original_token_estimate, timestamp, dedup_status, compressed_original)
273
370
  VALUES (@id, @sid, @region_hash, @content_hash, @content_hash2, @content_hash_version,
274
371
  @normalized_text, @summary, @topic_summary, @summary_hash,
275
372
  @key_decisions, @next_steps, @files_modified, @embedding_blob,
276
- @token_estimate, @timestamp, @dedup_status, @compressed_original)
373
+ @token_estimate, @original_token_estimate, @timestamp, @dedup_status, @compressed_original)
277
374
  ON CONFLICT(session_id, id) DO UPDATE SET
278
375
  summary=excluded.summary,
279
376
  topic_summary=excluded.topic_summary,
@@ -283,6 +380,7 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
283
380
  files_modified=excluded.files_modified,
284
381
  embedding_blob=excluded.embedding_blob,
285
382
  token_estimate=excluded.token_estimate,
383
+ original_token_estimate=excluded.original_token_estimate,
286
384
  timestamp=excluded.timestamp,
287
385
  dedup_status=excluded.dedup_status,
288
386
  compressed_original=excluded.compressed_original`,
@@ -302,6 +400,7 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
302
400
  files_modified: jsonText(cp.filesModified),
303
401
  embedding_blob: encodeEmbedding(cp.embedding ?? []),
304
402
  token_estimate: cp.tokenEstimate ?? 0,
403
+ original_token_estimate: cp.originalTokenEstimate ?? null,
305
404
  timestamp: cp.timestamp ?? 0,
306
405
  dedup_status: "active",
307
406
  compressed_original: cp.compressedOriginal ?? null,
@@ -508,6 +607,8 @@ export interface RepoStats {
508
607
  sessionCount: number;
509
608
  /** Cumulative stored-summary tokens saved (Σ stored summaries). */
510
609
  tokensSaved: number;
610
+ /** Sum of original dropped-region token estimates (repo-wide). */
611
+ originalTokens: number;
511
612
  /** Cumulative dedup add() attempts (store-wide). */
512
613
  dedupAttempts: number;
513
614
  /** Cumulative deduped collapses (store-wide). */
@@ -521,14 +622,16 @@ export function repoStats(stateDir: string = getStateDir()): RepoStats {
521
622
  const row = db
522
623
  .prepare(
523
624
  `SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
625
+ COALESCE(SUM(original_token_estimate),0) AS orig,
524
626
  COUNT(DISTINCT session_id) AS sessions
525
627
  FROM context_chunks WHERE dedup_status != 'removed'`,
526
628
  )
527
- .get() as { c: number; tok: number; sessions: number };
629
+ .get() as { c: number; tok: number; orig: number; sessions: number };
528
630
  const ds = getDedupStats(stateDir);
529
631
  return {
530
632
  checkpointCount: row.c,
531
633
  totalTokenEstimate: row.tok,
634
+ originalTokens: row.orig,
532
635
  sessionCount: row.sessions,
533
636
  tokensSaved: getMetaNumber("tokens_saved", stateDir),
534
637
  dedupAttempts: ds.attempts,
package/src/store.ts CHANGED
@@ -58,6 +58,9 @@ export interface StoredCheckpoint {
58
58
  nextSteps: string[];
59
59
  filesModified: string[];
60
60
  tokenEstimate: number;
61
+ /** Token count of the ORIGINAL dropped region (before compaction). Drives the
62
+ * honest "tokens saved" = originalTokenEstimate − tokenEstimate. */
63
+ originalTokenEstimate?: number;
61
64
  regionHash: string;
62
65
  /** Primary content-addressable hash (full 64-hex SHA-256 of normalized text). */
63
66
  contentHash?: string;
@@ -250,47 +250,55 @@ 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", () => {
253
+ test("tokensSaved = original stored per session; deduped add saves the whole region", () => {
254
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 });
255
+ // Two genuinely new checkpoints. saved = original − stored.
256
+ // cp1: orig 2000, stored 500 saved 1500
257
+ // cp2: orig 3000, stored 700 saved 2300
258
+ s.add({ sessionId: "sess_saved", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, originalTokenEstimate: 2000, timestamp: 1 });
259
+ s.add({ sessionId: "sess_saved", summary: "beta", regionText: "region beta text", tokenEstimate: 700, originalTokenEstimate: 3000, timestamp: 2 });
258
260
  const st = s.stats("sess_saved");
259
- assert.equal(st.tokensSaved, 1200, "per-session tokensSaved = Σ stored summary tokens");
261
+ assert.equal(st.totalTokenEstimate, 1200, "Σ stored summaries");
262
+ assert.equal(st.originalTokens, 5000, "Σ original region tokens");
263
+ assert.equal(st.tokensSaved, 3800, "per-session saved = Σ(original − stored) = 1500 + 2300");
260
264
  assert.equal(st.dedupCollapsed, 0);
261
265
  assert.equal(st.dedupAttempts, 2);
262
266
 
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
+ // A third add that dedups onto an existing region: whole original region (2000)
268
+ // is discarded (nothing new stored) repo saved grows by the full original,
269
+ // dedupCollapsed bumps, and no new checkpoint row is created.
270
+ const deduped = s.add({ sessionId: "sess_saved", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, originalTokenEstimate: 2000, timestamp: 3 });
267
271
  assert.ok(deduped.deduped, "identical region should dedup");
268
272
  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");
273
+ // Per-session DB sum only covers stored rows (deduped adds create no row), so
274
+ // the per-session figure is unchanged; the deduped save lands in the repo meta.
275
+ assert.equal(st3.tokensSaved, 3800, "per-session DB sum unchanged by deduped add");
276
+ assert.equal(st3.dedupCollapsed, 1, "deduped collapse counted");
271
277
  assert.equal(st3.dedupAttempts, 3);
278
+ // Repo cumulative counter DID capture the deduped region's full original size.
279
+ assert.equal(s.repoStats().tokensSaved, 3800 + 2000, "repo saved includes deduped original");
272
280
  });
273
281
 
274
- test("repoStats aggregates every session in the SQLite store", () => {
282
+ test("repoStats aggregates every session + counts deduped original tokens", () => {
275
283
  const dir = join(baseTmp, `repo-${counter++}`);
276
284
  const a = new VectorStore({ dedupSim: 0.9, stateDir: dir });
277
285
  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 });
286
+ a.add({ sessionId: "sess_a", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, originalTokenEstimate: 2000, timestamp: 1 });
287
+ b.add({ sessionId: "sess_b", summary: "beta", regionText: "region beta text", tokenEstimate: 700, originalTokenEstimate: 3000, timestamp: 2 });
280
288
 
281
289
  const repo = a.repoStats();
282
290
  assert.equal(repo.checkpointCount, 2, "checkpoints across both sessions");
283
291
  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");
292
+ assert.equal(repo.totalTokenEstimate, 1200, "Σ stored");
293
+ assert.equal(repo.originalTokens, 5000, "Σ original");
294
+ assert.equal(repo.tokensSaved, 3800, "repo saved = Σ(original − stored) = 1500 + 2300");
286
295
  assert.equal(repo.dedupCollapsed, 0);
287
296
 
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 });
297
+ // A deduped add into sess_a: whole original region saved, no new row.
298
+ const deduped = a.add({ sessionId: "sess_a", summary: "alpha", regionText: "region alpha text", tokenEstimate: 500, originalTokenEstimate: 2000, timestamp: 3 });
291
299
  assert.ok(deduped.deduped);
292
300
  const repo2 = a.repoStats();
293
- assert.equal(repo2.tokensSaved, 1200, "deduped collapse does not change repo tokensSaved");
301
+ assert.equal(repo2.tokensSaved, 3800 + 2000, "deduped collapse adds full original region to repo saved");
294
302
  assert.equal(repo2.dedupCollapsed, 1);
295
303
  assert.equal(repo2.checkpointCount, 2, "still two stored checkpoints");
296
304
  });
@@ -53,6 +53,11 @@ export interface AddInput {
53
53
  nextSteps?: string[];
54
54
  filesModified?: string[];
55
55
  tokenEstimate?: number;
56
+ /** Token count of the ORIGINAL dropped region (before compaction). Drives the
57
+ * honest "tokens saved" = originalTokenEstimate − tokenEstimate (stored), or
58
+ * the full originalTokenEstimate when the region dedups (nothing new stored).
59
+ * Optional for back-compat with direct add() callers; defaults to stored. */
60
+ originalTokenEstimate?: number;
56
61
  /** Raw text of the compacted region — used to derive the regionHash + vector. */
57
62
  regionText: string;
58
63
  timestamp: number;
@@ -140,6 +145,10 @@ export class VectorStore {
140
145
  const sessionId = normalizeSessionId(input.sessionId);
141
146
  const regionHash = computeRegionHash(input.regionText);
142
147
  const all = listCheckpoints(sessionId, this.stateDir);
148
+ // Honest "tokens saved" base for this region. For a deduped add the whole
149
+ // original region is discarded (nothing new stored); for a new checkpoint
150
+ // we persist (orig − stored). Falls back to stored when orig is unknown.
151
+ const origTokens = input.originalTokenEstimate ?? input.tokenEstimate ?? 0;
143
152
  const cfg = this.cfg;
144
153
  // Tracks whether a tier matched while in MARK_ONLY (record-but-don't-collapse),
145
154
  // and which tier.
@@ -167,6 +176,8 @@ export class VectorStore {
167
176
  contentMatch.timestamp = input.timestamp;
168
177
  upsertCheckpoint(contentMatch, this.stateDir);
169
178
  bumpDedupStats(true, this.stateDir);
179
+ // Deduped: whole original region discarded, nothing new stored.
180
+ addTokensSaved(origTokens, this.stateDir);
170
181
  const r = { checkpoint: contentMatch, deduped: true, reason: "contentHash" };
171
182
  this.record("L0", "deduped", "contentHash", Date.now() - t0);
172
183
  return r;
@@ -182,6 +193,8 @@ export class VectorStore {
182
193
  markOnly = "L0"; // fall through
183
194
  } else {
184
195
  bumpDedupStats(true, this.stateDir);
196
+ // Deduped: whole original region discarded, nothing new stored.
197
+ addTokensSaved(origTokens, this.stateDir);
185
198
  const r = { checkpoint: regionMatch, deduped: true, reason: "regionHash" };
186
199
  this.record("L0", "deduped", "regionHash", Date.now() - t0);
187
200
  return r;
@@ -203,6 +216,8 @@ export class VectorStore {
203
216
  summaryMatch.timestamp = input.timestamp;
204
217
  upsertCheckpoint(summaryMatch, this.stateDir);
205
218
  bumpDedupStats(true, this.stateDir);
219
+ // Deduped: whole original region discarded, nothing new stored.
220
+ addTokensSaved(origTokens, this.stateDir);
206
221
  const r = { checkpoint: summaryMatch, deduped: true, reason: "summaryHash" };
207
222
  this.record("L0", "deduped", "summaryHash", Date.now() - t0);
208
223
  return r;
@@ -255,6 +270,8 @@ export class VectorStore {
255
270
  nearest.checkpoint.timestamp = input.timestamp;
256
271
  upsertCheckpoint(nearest.checkpoint, this.stateDir);
257
272
  bumpDedupStats(true, this.stateDir);
273
+ // Deduped: whole original region discarded, nothing new stored.
274
+ addTokensSaved(origTokens, this.stateDir);
258
275
  const r = { checkpoint: nearest.checkpoint, deduped: true, reason: "contentSimilarity" };
259
276
  this.record("L2", "deduped", "contentSimilarity", Date.now() - t0);
260
277
  return r;
@@ -275,6 +292,7 @@ export class VectorStore {
275
292
  nextSteps: input.nextSteps ?? [],
276
293
  filesModified: input.filesModified ?? [],
277
294
  tokenEstimate: input.tokenEstimate ?? 0,
295
+ originalTokenEstimate: input.originalTokenEstimate,
278
296
  regionHash,
279
297
  contentHash: digest.contentHash,
280
298
  contentHash2: digest.contentHash2,
@@ -287,9 +305,12 @@ export class VectorStore {
287
305
  // Persistence is SQLite (store/sqlite.ts). upsertCheckpoint keeps the
288
306
  // idempotent-by-id semantics the old JSON append implied.
289
307
  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);
308
+ // Cumulative "tokens saved" counter (per-repo SQLite meta). For a NEW
309
+ // checkpoint the saved amount is (original stored); for a deduped add the
310
+ // whole original region is discarded (handled in the deduped return paths
311
+ // below). Survives sessions and travels with the repo.
312
+ const stored = input.tokenEstimate ?? 0;
313
+ addTokensSaved(Math.max(0, origTokens - stored), this.stateDir);
293
314
  // L1: persist this checkpoint's MinHash signature + LSH buckets so future
294
315
  // near-duplicate inserts can find it. Deterministic given the seed.
295
316
  const sig = minhashSignature(input.regionText);
@@ -517,7 +538,8 @@ export class VectorStore {
517
538
  injectedCount: number;
518
539
  dedupHitRate: number; // injected / checkpoints, 0..1
519
540
  storageDedupRate: number; // deduped adds / total adds, 0..1 (cumulative)
520
- tokensSaved: number; // cumulative stored checkpoint tokens (per-repo SQLite)
541
+ tokensSaved: number; // Σ(original stored) for this session's checkpoints
542
+ originalTokens: number; // Σ original region size for this session's checkpoints
521
543
  dedupAttempts: number; // cumulative add() calls (store-wide)
522
544
  dedupCollapsed: number; // cumulative deduped collapses (store-wide)
523
545
  } {
@@ -530,10 +552,16 @@ export class VectorStore {
530
552
  const last = ordered[ordered.length - 1];
531
553
  const injected = state.injectedCheckpointIds.length;
532
554
  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
555
  const sessionTok = cps.reduce((s, c) => s + (c.tokenEstimate ?? 0), 0);
556
+ const sessionOrig = cps.reduce((s, c) => s + (c.originalTokenEstimate ?? 0), 0);
557
+ // Per-session "tokens saved" = Σ(original − stored) over this session's
558
+ // stored checkpoints. Deduped adds (whole region discarded, nothing stored)
559
+ // are counted in the repo-wide meta counter via repoStats(); the per-session
560
+ // DB sum here covers the rows that exist.
561
+ const sessionSaved = cps.reduce(
562
+ (s, c) => s + Math.max(0, (c.originalTokenEstimate ?? 0) - (c.tokenEstimate ?? 0)),
563
+ 0,
564
+ );
537
565
  return {
538
566
  checkpointCount: cps.length,
539
567
  totalTokenEstimate: sessionTok,
@@ -542,7 +570,8 @@ export class VectorStore {
542
570
  injectedCount: injected,
543
571
  dedupHitRate: cps.length === 0 ? 0 : injected / cps.length,
544
572
  storageDedupRate: ds.attempts === 0 ? 0 : ds.deduped / ds.attempts,
545
- tokensSaved: sessionTok,
573
+ tokensSaved: sessionSaved,
574
+ originalTokens: sessionOrig,
546
575
  dedupAttempts: ds.attempts,
547
576
  dedupCollapsed: ds.deduped,
548
577
  };