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 +31 -15
- package/extensions/dashboard-server.ts +63 -2
- package/extensions/mega-compact.test.ts +8 -2
- package/extensions/mega-compact.ts +40 -2
- package/package.json +1 -1
- package/src/store/sqlite.ts +122 -8
- package/src/store.ts +4 -21
- package/src/vectorStore.test.ts +45 -0
- package/src/vectorStore.ts +32 -17
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
|
-
###
|
|
118
|
+
### Install from npm (recommended)
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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": ["
|
|
136
|
+
"extensions": ["pi-mega-compact/extensions/mega-compact.ts"]
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
Or
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/store/sqlite.ts
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
}
|
package/src/vectorStore.test.ts
CHANGED
|
@@ -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");
|
package/src/vectorStore.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
}
|