pi-mega-compact 0.4.1 → 0.4.3

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.3",
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
  }
@@ -174,6 +174,38 @@ test("checkAllIntegrity covers every session", () => {
174
174
  assert.ok(reports.every((r: { ok: boolean }) => r.ok));
175
175
  });
176
176
 
177
+ // --- schema migration (v0.4.2: original_token_estimate) -------------------
178
+
179
+ test("Sprint 10 migration: pre-0.4.2 db gains original_token_estimate and repoStats works", () => {
180
+ const dir = join(baseTmp, `run-${counter++}`);
181
+ // Simulate a v0.4.1-era store: openStore() builds the full current schema,
182
+ // then drop the original_token_estimate column that 0.4.2 added. CREATE TABLE
183
+ // IF NOT EXISTS is a no-op on an existing table, which is exactly why the
184
+ // shipped v0.4.2 crashed at runtime on old repos ("no such column").
185
+ const db = openStore(dir);
186
+ db.exec("ALTER TABLE context_chunks DROP COLUMN original_token_estimate;");
187
+ closeStore(dir);
188
+
189
+ // Re-open through the real code path — must ALTER the column in, not crash.
190
+ const vs = new VectorStore({ dedupSim: 0.9, stateDir: dir });
191
+ vs.add({
192
+ sessionId: "sess_mig",
193
+ summary: "legacy region",
194
+ regionText: "some region text to compact",
195
+ tokenEstimate: 100,
196
+ originalTokenEstimate: 500,
197
+ timestamp: 1,
198
+ });
199
+ const repo = vs.repoStats();
200
+ // No "no such column" crash = migration succeeded; totals reflect the new col.
201
+ assert.equal(repo.checkpointCount, 1);
202
+ assert.equal(repo.originalTokens, 500, "originalTokens read from migrated column");
203
+ // Re-open a second time to prove idempotency (column already exists).
204
+ closeStore(dir);
205
+ const vs2 = new VectorStore({ dedupSim: 0.9, stateDir: dir });
206
+ assert.equal(vs2.repoStats().originalTokens, 500, "stable across re-open");
207
+ });
208
+
177
209
  // --- cleanup ---------------------------------------------------------------
178
210
 
179
211
  test("Sprint 10 cleanup", () => {
@@ -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,
@@ -153,6 +190,12 @@ function initSchema(db: Database.Database): void {
153
190
  tokenize='trigram'
154
191
  );
155
192
  `);
193
+ // Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
194
+ // pre-existing table, so new columns added to context_chunks after a store was
195
+ // first created (e.g. original_token_estimate in v0.4.2) must be ALTERed in for
196
+ // databases created by an older version — otherwise repoStats()/upsert crash
197
+ // with "no such column" and the extension fails to load. Additive only.
198
+ ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
156
199
  const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
157
200
  | { value: string }
158
201
  | undefined;
@@ -161,6 +204,19 @@ function initSchema(db: Database.Database): void {
161
204
  }
162
205
  }
163
206
 
207
+ /**
208
+ * Add `column` (with `decl`, e.g. "INTEGER") to `table` if it does not already
209
+ * exist. Idempotent: checks PRAGMA table_info first, so it is safe to run on
210
+ * every open. Table/column/decl are code-controlled constants (never user
211
+ * input), so the unavoidable identifier interpolation here does not violate
212
+ * PREVENT-002 (no external data reaches this SQL).
213
+ */
214
+ function ensureColumn(db: Database.Database, table: string, column: string, decl: string): void {
215
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
216
+ if (cols.some((c) => c.name === column)) return;
217
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
218
+ }
219
+
164
220
  /** Read a string-valued meta key (or undefined). Used for cumulative counters. */
165
221
  export function getMeta(key: string, stateDir: string = getStateDir()): string | undefined {
166
222
  const db = openStore(stateDir);
@@ -235,6 +291,65 @@ export function bumpDedupStats(deduped: boolean, stateDir: string = getStateDir(
235
291
  if (deduped) incMeta("deduped", 1, stateDir);
236
292
  }
237
293
 
294
+ // --- Future-feature foundation (resume sessions / daily log / lessons) -------
295
+ // Scaffolded tables + minimal helpers so all store data lives in SQLite from
296
+ // day one. Full UI/recall for these lands in later sprints.
297
+
298
+ /** Upsert a `sessions` row (resume + per-repo session history). */
299
+ export function touchSession(
300
+ sessionId: string,
301
+ repo: string | undefined,
302
+ stateDir: string = getStateDir(),
303
+ ): void {
304
+ const db = openStore(stateDir);
305
+ const sid = normalizeSessionId(sessionId);
306
+ const existing = db
307
+ .prepare("SELECT started_at FROM sessions WHERE session_id = ?")
308
+ .get(sid) as { started_at: number | null } | undefined;
309
+ const now = Math.floor(Date.now() / 1000);
310
+ if (!existing) {
311
+ db.prepare(
312
+ `INSERT INTO sessions(session_id, repo, started_at, last_compacted_at, status)
313
+ VALUES(?, ?, ?, ?, 'active')`,
314
+ ).run(sid, repo ?? null, now, now);
315
+ } else {
316
+ db.prepare(
317
+ "UPDATE sessions SET last_compacted_at = ?, repo = COALESCE(?, repo), status = 'active' WHERE session_id = ?",
318
+ ).run(now, repo ?? null, sid);
319
+ }
320
+ }
321
+
322
+ /** Append a `daily_log` entry (day = YYYY-MM-DD, local-naive from Date). */
323
+ export function logDaily(
324
+ sessionId: string,
325
+ event: string,
326
+ detail: string | undefined,
327
+ tokensSaved: number,
328
+ stateDir: string = getStateDir(),
329
+ ): void {
330
+ const db = openStore(stateDir);
331
+ const day = new Date().toISOString().slice(0, 10);
332
+ const now = Math.floor(Date.now() / 1000);
333
+ db.prepare(
334
+ `INSERT INTO daily_log(day, session_id, event, detail, tokens_saved, ts)
335
+ VALUES(?, ?, ?, ?, ?, ?)`,
336
+ ).run(day, normalizeSessionId(sessionId), event, detail ?? null, tokensSaved, now);
337
+ }
338
+
339
+ /** Append a `lessons` entry (future lessons-learned browse/recall). */
340
+ export function addLesson(
341
+ sessionId: string,
342
+ repo: string | undefined,
343
+ lesson: string,
344
+ stateDir: string = getStateDir(),
345
+ ): void {
346
+ const db = openStore(stateDir);
347
+ const now = Math.floor(Date.now() / 1000);
348
+ db.prepare(
349
+ `INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`,
350
+ ).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
351
+ }
352
+
238
353
  /** Map a DB row to the public StoredCheckpoint shape. */
239
354
  function rowToCheckpoint(row: any): StoredCheckpoint {
240
355
  return {
@@ -247,6 +362,7 @@ function rowToCheckpoint(row: any): StoredCheckpoint {
247
362
  nextSteps: row.next_steps ? JSON.parse(row.next_steps) : [],
248
363
  filesModified: row.files_modified ? JSON.parse(row.files_modified) : [],
249
364
  tokenEstimate: row.token_estimate ?? 0,
365
+ originalTokenEstimate: row.original_token_estimate ?? undefined,
250
366
  regionHash: row.region_hash ?? "",
251
367
  contentHash: row.content_hash ?? undefined,
252
368
  contentHash2: row.content_hash2 ?? undefined,
@@ -269,11 +385,11 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
269
385
  (id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
270
386
  normalized_text, summary, topic_summary, summary_hash,
271
387
  key_decisions, next_steps, files_modified, embedding_blob,
272
- token_estimate, timestamp, dedup_status, compressed_original)
388
+ token_estimate, original_token_estimate, timestamp, dedup_status, compressed_original)
273
389
  VALUES (@id, @sid, @region_hash, @content_hash, @content_hash2, @content_hash_version,
274
390
  @normalized_text, @summary, @topic_summary, @summary_hash,
275
391
  @key_decisions, @next_steps, @files_modified, @embedding_blob,
276
- @token_estimate, @timestamp, @dedup_status, @compressed_original)
392
+ @token_estimate, @original_token_estimate, @timestamp, @dedup_status, @compressed_original)
277
393
  ON CONFLICT(session_id, id) DO UPDATE SET
278
394
  summary=excluded.summary,
279
395
  topic_summary=excluded.topic_summary,
@@ -283,6 +399,7 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
283
399
  files_modified=excluded.files_modified,
284
400
  embedding_blob=excluded.embedding_blob,
285
401
  token_estimate=excluded.token_estimate,
402
+ original_token_estimate=excluded.original_token_estimate,
286
403
  timestamp=excluded.timestamp,
287
404
  dedup_status=excluded.dedup_status,
288
405
  compressed_original=excluded.compressed_original`,
@@ -302,6 +419,7 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
302
419
  files_modified: jsonText(cp.filesModified),
303
420
  embedding_blob: encodeEmbedding(cp.embedding ?? []),
304
421
  token_estimate: cp.tokenEstimate ?? 0,
422
+ original_token_estimate: cp.originalTokenEstimate ?? null,
305
423
  timestamp: cp.timestamp ?? 0,
306
424
  dedup_status: "active",
307
425
  compressed_original: cp.compressedOriginal ?? null,
@@ -508,6 +626,8 @@ export interface RepoStats {
508
626
  sessionCount: number;
509
627
  /** Cumulative stored-summary tokens saved (Σ stored summaries). */
510
628
  tokensSaved: number;
629
+ /** Sum of original dropped-region token estimates (repo-wide). */
630
+ originalTokens: number;
511
631
  /** Cumulative dedup add() attempts (store-wide). */
512
632
  dedupAttempts: number;
513
633
  /** Cumulative deduped collapses (store-wide). */
@@ -521,14 +641,16 @@ export function repoStats(stateDir: string = getStateDir()): RepoStats {
521
641
  const row = db
522
642
  .prepare(
523
643
  `SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
644
+ COALESCE(SUM(original_token_estimate),0) AS orig,
524
645
  COUNT(DISTINCT session_id) AS sessions
525
646
  FROM context_chunks WHERE dedup_status != 'removed'`,
526
647
  )
527
- .get() as { c: number; tok: number; sessions: number };
648
+ .get() as { c: number; tok: number; orig: number; sessions: number };
528
649
  const ds = getDedupStats(stateDir);
529
650
  return {
530
651
  checkpointCount: row.c,
531
652
  totalTokenEstimate: row.tok,
653
+ originalTokens: row.orig,
532
654
  sessionCount: row.sessions,
533
655
  tokensSaved: getMetaNumber("tokens_saved", stateDir),
534
656
  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
  };