pi-mega-compact 0.4.4 → 0.4.6

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.
Files changed (69) hide show
  1. package/dist/extensions/dashboard-server.js +450 -0
  2. package/dist/extensions/dashboard-server.test.js +111 -0
  3. package/dist/extensions/error-patterns.js +115 -0
  4. package/dist/extensions/mega-compact.js +782 -0
  5. package/dist/extensions/mega-compact.test.js +328 -0
  6. package/dist/extensions/openclaw-mega-compact.js +291 -0
  7. package/dist/src/adapt.js +106 -0
  8. package/dist/src/boundary.js +88 -0
  9. package/dist/src/boundary.test.js +53 -0
  10. package/dist/src/canary.js +118 -0
  11. package/dist/src/compact.js +250 -0
  12. package/dist/src/compact.test.js +78 -0
  13. package/dist/src/config/dedup.js +81 -0
  14. package/dist/src/config.js +12 -0
  15. package/dist/src/dedup/dedup.test.js +41 -0
  16. package/dist/src/dedup/digest.js +30 -0
  17. package/dist/src/dedup/l1-lsh.js +52 -0
  18. package/dist/src/dedup/l1-minhash.js +91 -0
  19. package/dist/src/dedup/l1-verify.js +54 -0
  20. package/dist/src/dedup/l1.test.js +50 -0
  21. package/dist/src/dedup/mmr.js +45 -0
  22. package/dist/src/dedup/normalize.js +39 -0
  23. package/dist/src/dedup/raptor/guardrails.js +83 -0
  24. package/dist/src/dedup/raptor/index.js +94 -0
  25. package/dist/src/dedup/raptor/kmeans.js +152 -0
  26. package/dist/src/dedup/raptor/raptor.test.js +205 -0
  27. package/dist/src/dedup/raptor/retrieval.js +81 -0
  28. package/dist/src/dedup/raptor/summarizer.js +85 -0
  29. package/dist/src/dedup/raptor/tree.js +177 -0
  30. package/dist/src/dedup/sprint12.test.js +219 -0
  31. package/dist/src/dedup/topk.js +60 -0
  32. package/dist/src/dedup-engine.test.js +447 -0
  33. package/dist/src/e2e.test.js +698 -0
  34. package/dist/src/embedder.js +102 -0
  35. package/dist/src/engine.js +137 -0
  36. package/dist/src/engine.test.js +111 -0
  37. package/dist/src/extractive.js +209 -0
  38. package/dist/src/extractive.test.js +130 -0
  39. package/dist/src/httpEmbedder.js +143 -0
  40. package/dist/src/log.js +47 -0
  41. package/dist/src/log.test.js +42 -0
  42. package/dist/src/minilm.js +92 -0
  43. package/dist/src/monitoring.js +131 -0
  44. package/dist/src/ratio.bench.test.js +897 -0
  45. package/dist/src/recall.integration.test.js +77 -0
  46. package/dist/src/recall.js +60 -0
  47. package/dist/src/recall.test.js +50 -0
  48. package/dist/src/sprint14.test.js +219 -0
  49. package/dist/src/store/backfill.js +189 -0
  50. package/dist/src/store/bloom.js +114 -0
  51. package/dist/src/store/compression.js +177 -0
  52. package/dist/src/store/compression.test.js +67 -0
  53. package/dist/src/store/integrity.js +44 -0
  54. package/dist/src/store/migrate.js +79 -0
  55. package/dist/src/store/migrate.test.js +139 -0
  56. package/dist/src/store/sprint10.test.js +186 -0
  57. package/dist/src/store/sqlite.js +574 -0
  58. package/dist/src/store.js +115 -0
  59. package/dist/src/store.test.js +142 -0
  60. package/dist/src/supersede.js +68 -0
  61. package/dist/src/supersede.test.js +36 -0
  62. package/dist/src/tokens.js +31 -0
  63. package/dist/src/types.js +8 -0
  64. package/dist/src/types.test.js +9 -0
  65. package/dist/src/vectorStore.js +465 -0
  66. package/dist/src/vectorStore.test.js +479 -0
  67. package/dist/src/wordpiece.js +129 -0
  68. package/extensions/mega-compact.ts +9 -3
  69. package/package.json +4 -2
@@ -0,0 +1,574 @@
1
+ /**
2
+ * sqlite.ts — Sprint 8 storage backbone (the "one store").
3
+ *
4
+ * Replaces the per-session gzipped-JSON checkpoint files with a single local
5
+ * SQLite database (better-sqlite3, in-process, FS-backed, ZERO network calls —
6
+ * honors PREVENT-PI-004). Chosen over PGlite because PGlite is async-only in
7
+ * every published version, and VectorStore (engine.ts / recall.ts / the
8
+ * extension) is fully synchronous — adopting PGlite would have cascaded async
9
+ * through the whole call chain. SQLite keeps every VectorStore signature sync.
10
+ *
11
+ * FTS5 `trigram` tokenizer is created for the Sprint 9+ dedup tiers (MinHash/LSH
12
+ * / pg_trgm-equivalent verification). The default cosine path stays a linear
13
+ * scan over `embedding_blob` (checkpoint counts are small, no ANN index needed).
14
+ *
15
+ * All queries are parameterized (PREVENT-002) — never string-concatenated.
16
+ */
17
+ import Database from "better-sqlite3";
18
+ import { existsSync, mkdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { getStateDir } from "../store.js";
21
+ import { normalizeSessionId } from "../store.js";
22
+ const SCHEMA_VERSION = 1;
23
+ /** Encode a float vector as a little-endian Float32 BLOB for cosine scanning. */
24
+ function encodeEmbedding(v) {
25
+ const buf = Buffer.allocUnsafe(v.length * 4);
26
+ for (let i = 0; i < v.length; i++)
27
+ buf.writeFloatLE(v[i] ?? 0, i * 4);
28
+ return buf;
29
+ }
30
+ /** Decode a Float32 BLOB back to a number[]. */
31
+ function decodeEmbedding(buf) {
32
+ if (!buf || buf.length === 0)
33
+ return [];
34
+ const n = buf.length / 4;
35
+ const out = new Array(n);
36
+ for (let i = 0; i < n; i++)
37
+ out[i] = buf.readFloatLE(i * 4);
38
+ return out;
39
+ }
40
+ function jsonText(v) {
41
+ return JSON.stringify(v ?? []);
42
+ }
43
+ // In-process cache so the same stateDir reuses one connection (and so a fresh
44
+ // VectorStore over the same dir shares the open DB). Cross-process durability
45
+ // comes from reopening the same file path — proven by the integration test.
46
+ const cache = new Map();
47
+ /** Open (or reuse) the SQLite store for a state dir. */
48
+ export function openStore(stateDir = getStateDir()) {
49
+ const existing = cache.get(stateDir);
50
+ if (existing)
51
+ return existing;
52
+ if (!existsSync(stateDir))
53
+ mkdirSync(stateDir, { recursive: true });
54
+ const db = new Database(join(stateDir, "sqlite.db"));
55
+ db.pragma("journal_mode = WAL");
56
+ db.pragma("foreign_keys = ON");
57
+ initSchema(db);
58
+ cache.set(stateDir, db);
59
+ return db;
60
+ }
61
+ function initSchema(db) {
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS context_chunks (
64
+ id TEXT NOT NULL,
65
+ session_id TEXT NOT NULL,
66
+ region_hash TEXT,
67
+ content_hash TEXT,
68
+ content_hash2 TEXT,
69
+ content_hash_version INTEGER,
70
+ normalized_text TEXT,
71
+ summary TEXT,
72
+ topic_summary TEXT,
73
+ summary_hash TEXT,
74
+ key_decisions TEXT, -- JSON array
75
+ next_steps TEXT, -- JSON array
76
+ files_modified TEXT, -- JSON array
77
+ embedding_blob BLOB, -- float32 vector
78
+ token_estimate INTEGER,
79
+ original_token_estimate INTEGER, -- dropped region size (tokens saved = orig − stored)
80
+ timestamp INTEGER,
81
+ dedup_status TEXT DEFAULT 'active',
82
+ compressed_original BLOB -- optional DR copy
83
+ );
84
+ -- Primary key is (session_id, id): checkpoint ids are unique per session
85
+ -- (chkpt_001 per session), not globally, so a bare id PK would collide
86
+ -- across sessions on the nextCheckpointId sequence.
87
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_pk
88
+ ON context_chunks(session_id, id);
89
+ CREATE INDEX IF NOT EXISTS idx_chunks_session ON context_chunks(session_id);
90
+ CREATE INDEX IF NOT EXISTS idx_chunks_region ON context_chunks(region_hash);
91
+ CREATE INDEX IF NOT EXISTS idx_chunks_content ON context_chunks(content_hash);
92
+ -- Partial UNIQUE (QA #1): null content_hash rows never violate the constraint;
93
+ -- ON CONFLICT DO NOTHING makes backfill + L0 inserts safe.
94
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_content_hash
95
+ ON context_chunks(session_id, content_hash) WHERE content_hash IS NOT NULL;
96
+
97
+ -- Sprint 11: MinHash signature + LSH bucket tables for L1 near-dup dedup.
98
+ CREATE TABLE IF NOT EXISTS minhash_signatures (
99
+ chunk_id TEXT NOT NULL,
100
+ session_id TEXT NOT NULL,
101
+ signature_version INTEGER NOT NULL,
102
+ signatures TEXT NOT NULL, -- JSON array of 256 uint32
103
+ PRIMARY KEY (chunk_id, signature_version)
104
+ );
105
+ CREATE INDEX IF NOT EXISTS idx_minhash_session ON minhash_signatures(session_id);
106
+
107
+ CREATE TABLE IF NOT EXISTS dedup_lsh_buckets (
108
+ bucket_key TEXT NOT NULL,
109
+ chunk_id TEXT NOT NULL,
110
+ session_id TEXT NOT NULL,
111
+ signature_version INTEGER NOT NULL,
112
+ PRIMARY KEY (bucket_key, chunk_id)
113
+ );
114
+ CREATE INDEX IF NOT EXISTS idx_lsh_bucket ON dedup_lsh_buckets(bucket_key, session_id);
115
+
116
+ CREATE TABLE IF NOT EXISTS session_state (
117
+ session_id TEXT PRIMARY KEY,
118
+ injected_checkpoint_ids TEXT, -- JSON array
119
+ stored_region_hashes TEXT -- JSON array
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS meta (
123
+ key TEXT PRIMARY KEY,
124
+ value TEXT
125
+ );
126
+
127
+ -- Sprint 13 (RAPTOR): hierarchical summary tree nodes. children are a JSON
128
+ -- array of child node ids (or raw leaf ids at the bottom); embedding_blob
129
+ -- is the node centroid. Additive; retrieval ignores this table until
130
+ -- Sprint 14 promotes RAPTOR out of shadow mode.
131
+ CREATE TABLE IF NOT EXISTS raptor_nodes (
132
+ id TEXT NOT NULL,
133
+ session_id TEXT NOT NULL,
134
+ level INTEGER NOT NULL,
135
+ parent_id TEXT,
136
+ children TEXT, -- JSON array of child ids
137
+ summary TEXT,
138
+ embedding_blob BLOB, -- float32 centroid
139
+ quality_marker TEXT DEFAULT 'low',
140
+ token_estimate INTEGER,
141
+ PRIMARY KEY (session_id, id)
142
+ );
143
+ CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
144
+ CREATE INDEX IF NOT EXISTS idx_raptor_parent ON raptor_nodes(parent_id);
145
+
146
+ -- Foundation for future features (resume sessions, daily log, lessons
147
+ -- learned). Scaffolded now so all store data lives in SQLite from day one;
148
+ -- population is minimal (touchSession / logDaily on compact) and the full
149
+ -- UI/recall for these lands in later sprints.
150
+
151
+ -- Per-session registry (resume + per-repo session history).
152
+ CREATE TABLE IF NOT EXISTS sessions (
153
+ session_id TEXT PRIMARY KEY,
154
+ repo TEXT,
155
+ started_at INTEGER,
156
+ ended_at INTEGER,
157
+ last_compacted_at INTEGER,
158
+ status TEXT DEFAULT 'active'
159
+ );
160
+
161
+ -- Append-only daily activity log (the "daily log" feature seed).
162
+ CREATE TABLE IF NOT EXISTS daily_log (
163
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ day TEXT NOT NULL, -- YYYY-MM-DD
165
+ session_id TEXT,
166
+ event TEXT, -- e.g. 'compact'
167
+ detail TEXT,
168
+ tokens_saved INTEGER DEFAULT 0,
169
+ ts INTEGER
170
+ );
171
+ CREATE INDEX IF NOT EXISTS idx_daily_log_day ON daily_log(day);
172
+
173
+ -- Lessons learned (future recall/browse feature seed).
174
+ CREATE TABLE IF NOT EXISTS lessons (
175
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
176
+ session_id TEXT,
177
+ repo TEXT,
178
+ lesson TEXT,
179
+ ts INTEGER
180
+ );
181
+
182
+ -- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
183
+ CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
184
+ id UNINDEXED,
185
+ normalized_text,
186
+ tokenize='trigram'
187
+ );
188
+ `);
189
+ // Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
190
+ // pre-existing table, so new columns added to context_chunks after a store was
191
+ // first created (e.g. original_token_estimate in v0.4.2) must be ALTERed in for
192
+ // databases created by an older version — otherwise repoStats()/upsert crash
193
+ // with "no such column" and the extension fails to load. Additive only.
194
+ ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
195
+ const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
196
+ if (!v) {
197
+ db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", String(SCHEMA_VERSION));
198
+ }
199
+ }
200
+ /**
201
+ * Add `column` (with `decl`, e.g. "INTEGER") to `table` if it does not already
202
+ * exist. Idempotent: checks PRAGMA table_info first, so it is safe to run on
203
+ * every open. Table/column/decl are code-controlled constants (never user
204
+ * input), so the unavoidable identifier interpolation here does not violate
205
+ * PREVENT-002 (no external data reaches this SQL).
206
+ */
207
+ function ensureColumn(db, table, column, decl) {
208
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all();
209
+ if (cols.some((c) => c.name === column))
210
+ return;
211
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
212
+ }
213
+ /** Read a string-valued meta key (or undefined). Used for cumulative counters. */
214
+ export function getMeta(key, stateDir = getStateDir()) {
215
+ const db = openStore(stateDir);
216
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
217
+ return row?.value;
218
+ }
219
+ /**
220
+ * Cumulative "tokens saved" — the sum of stored checkpoint token estimates across
221
+ * all compactions in this store (one per repo). Persisted in the SQLite `meta`
222
+ * table so it survives session restarts and travels with the repo's state dir,
223
+ * mirroring how `storageDedupRate` is cumulative. Incremented in VectorStore.add()
224
+ * when a new (non-deduped) checkpoint is persisted.
225
+ */
226
+ export function getTokensSaved(stateDir = getStateDir()) {
227
+ const raw = getMeta("tokens_saved", stateDir);
228
+ const n = raw == null ? 0 : Number(raw);
229
+ return Number.isFinite(n) ? n : 0;
230
+ }
231
+ /** Add `delta` (>=0) to the cumulative tokens-saved counter. */
232
+ export function addTokensSaved(delta, stateDir = getStateDir()) {
233
+ if (!(delta > 0))
234
+ return;
235
+ const db = openStore(stateDir);
236
+ db.prepare(`INSERT INTO meta(key, value) VALUES('tokens_saved', ?)
237
+ ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`).run(String(delta), delta);
238
+ }
239
+ /** Read a store-wide integer counter from the meta table (0 if absent). */
240
+ export function getMetaNumber(key, stateDir = getStateDir()) {
241
+ const raw = getMeta(key, stateDir);
242
+ const n = raw == null ? 0 : Number(raw);
243
+ return Number.isFinite(n) ? n : 0;
244
+ }
245
+ /** Atomically add `delta` to an integer meta counter. */
246
+ function incMeta(key, delta, stateDir = getStateDir()) {
247
+ if (!(delta > 0))
248
+ return;
249
+ const db = openStore(stateDir);
250
+ db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?)
251
+ ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`).run(key, String(delta), delta);
252
+ }
253
+ /** Read the cumulative store-wide dedup counters. */
254
+ export function getDedupStats(stateDir = getStateDir()) {
255
+ return {
256
+ attempts: getMetaNumber("dedup_attempts", stateDir),
257
+ deduped: getMetaNumber("deduped", stateDir),
258
+ };
259
+ }
260
+ /** Increment the store-wide dedup counters for one add() call. */
261
+ export function bumpDedupStats(deduped, stateDir = getStateDir()) {
262
+ incMeta("dedup_attempts", 1, stateDir);
263
+ if (deduped)
264
+ incMeta("deduped", 1, stateDir);
265
+ }
266
+ // --- Future-feature foundation (resume sessions / daily log / lessons) -------
267
+ // Scaffolded tables + minimal helpers so all store data lives in SQLite from
268
+ // day one. Full UI/recall for these lands in later sprints.
269
+ /** Upsert a `sessions` row (resume + per-repo session history). */
270
+ export function touchSession(sessionId, repo, stateDir = getStateDir()) {
271
+ const db = openStore(stateDir);
272
+ const sid = normalizeSessionId(sessionId);
273
+ const existing = db
274
+ .prepare("SELECT started_at FROM sessions WHERE session_id = ?")
275
+ .get(sid);
276
+ const now = Math.floor(Date.now() / 1000);
277
+ if (!existing) {
278
+ db.prepare(`INSERT INTO sessions(session_id, repo, started_at, last_compacted_at, status)
279
+ VALUES(?, ?, ?, ?, 'active')`).run(sid, repo ?? null, now, now);
280
+ }
281
+ else {
282
+ db.prepare("UPDATE sessions SET last_compacted_at = ?, repo = COALESCE(?, repo), status = 'active' WHERE session_id = ?").run(now, repo ?? null, sid);
283
+ }
284
+ }
285
+ /** Append a `daily_log` entry (day = YYYY-MM-DD, local-naive from Date). */
286
+ export function logDaily(sessionId, event, detail, tokensSaved, stateDir = getStateDir()) {
287
+ const db = openStore(stateDir);
288
+ const day = new Date().toISOString().slice(0, 10);
289
+ const now = Math.floor(Date.now() / 1000);
290
+ db.prepare(`INSERT INTO daily_log(day, session_id, event, detail, tokens_saved, ts)
291
+ VALUES(?, ?, ?, ?, ?, ?)`).run(day, normalizeSessionId(sessionId), event, detail ?? null, tokensSaved, now);
292
+ }
293
+ /** Append a `lessons` entry (future lessons-learned browse/recall). */
294
+ export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
295
+ const db = openStore(stateDir);
296
+ const now = Math.floor(Date.now() / 1000);
297
+ db.prepare(`INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
298
+ }
299
+ /** Map a DB row to the public StoredCheckpoint shape. */
300
+ function rowToCheckpoint(row) {
301
+ return {
302
+ checkpointId: row.id,
303
+ sessionId: row.session_id,
304
+ summary: row.summary ?? "",
305
+ topicSummary: row.topic_summary ?? undefined,
306
+ summaryHash: row.summary_hash ?? undefined,
307
+ keyDecisions: row.key_decisions ? JSON.parse(row.key_decisions) : [],
308
+ nextSteps: row.next_steps ? JSON.parse(row.next_steps) : [],
309
+ filesModified: row.files_modified ? JSON.parse(row.files_modified) : [],
310
+ tokenEstimate: row.token_estimate ?? 0,
311
+ originalTokenEstimate: row.original_token_estimate ?? undefined,
312
+ regionHash: row.region_hash ?? "",
313
+ contentHash: row.content_hash ?? undefined,
314
+ contentHash2: row.content_hash2 ?? undefined,
315
+ contentHashVersion: row.content_hash_version ?? undefined,
316
+ normalizedText: row.normalized_text ?? undefined,
317
+ compressedOriginal: row.compressed_original ?? undefined,
318
+ embedding: decodeEmbedding(row.embedding_blob),
319
+ timestamp: Number(row.timestamp ?? 0),
320
+ dedupStatus: row.dedup_status ?? undefined,
321
+ };
322
+ }
323
+ /** Insert or replace a checkpoint (idempotent by id). */
324
+ export function upsertCheckpoint(cp, stateDir = getStateDir()) {
325
+ const db = openStore(stateDir);
326
+ const sid = normalizeSessionId(cp.sessionId);
327
+ const tx = db.transaction(() => {
328
+ db.prepare(`INSERT INTO context_chunks
329
+ (id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
330
+ normalized_text, summary, topic_summary, summary_hash,
331
+ key_decisions, next_steps, files_modified, embedding_blob,
332
+ token_estimate, original_token_estimate, timestamp, dedup_status, compressed_original)
333
+ VALUES (@id, @sid, @region_hash, @content_hash, @content_hash2, @content_hash_version,
334
+ @normalized_text, @summary, @topic_summary, @summary_hash,
335
+ @key_decisions, @next_steps, @files_modified, @embedding_blob,
336
+ @token_estimate, @original_token_estimate, @timestamp, @dedup_status, @compressed_original)
337
+ ON CONFLICT(session_id, id) DO UPDATE SET
338
+ summary=excluded.summary,
339
+ topic_summary=excluded.topic_summary,
340
+ summary_hash=excluded.summary_hash,
341
+ key_decisions=excluded.key_decisions,
342
+ next_steps=excluded.next_steps,
343
+ files_modified=excluded.files_modified,
344
+ embedding_blob=excluded.embedding_blob,
345
+ token_estimate=excluded.token_estimate,
346
+ original_token_estimate=excluded.original_token_estimate,
347
+ timestamp=excluded.timestamp,
348
+ dedup_status=excluded.dedup_status,
349
+ compressed_original=excluded.compressed_original`).run({
350
+ id: cp.checkpointId,
351
+ sid,
352
+ region_hash: cp.regionHash ?? null,
353
+ content_hash: cp.contentHash ?? null,
354
+ content_hash2: cp.contentHash2 ?? null,
355
+ content_hash_version: cp.contentHashVersion ?? null,
356
+ normalized_text: cp.normalizedText ?? null,
357
+ summary: cp.summary ?? "",
358
+ topic_summary: cp.topicSummary ?? null,
359
+ summary_hash: cp.summaryHash ?? null,
360
+ key_decisions: jsonText(cp.keyDecisions),
361
+ next_steps: jsonText(cp.nextSteps),
362
+ files_modified: jsonText(cp.filesModified),
363
+ embedding_blob: encodeEmbedding(cp.embedding ?? []),
364
+ token_estimate: cp.tokenEstimate ?? 0,
365
+ original_token_estimate: cp.originalTokenEstimate ?? null,
366
+ timestamp: cp.timestamp ?? 0,
367
+ dedup_status: "active",
368
+ compressed_original: cp.compressedOriginal ?? null,
369
+ });
370
+ // FTS5 virtual tables don't support UPSERT — delete any prior row, reinsert.
371
+ // Store normalized_text (the L1 verify key); fall back to summary for rows
372
+ // that predate normalized_text population.
373
+ db.prepare("DELETE FROM context_chunks_trgm WHERE id = ?").run(cp.checkpointId);
374
+ db.prepare("INSERT INTO context_chunks_trgm(id, normalized_text) VALUES(?, ?)").run(cp.checkpointId, cp.normalizedText ?? cp.summary ?? "");
375
+ });
376
+ tx();
377
+ }
378
+ // --- Sprint 11: MinHash signatures + LSH buckets --------------------------
379
+ /** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
380
+ export function upsertMinhashSignature(chunkId, sessionId, signatureVersion, signatures, stateDir = getStateDir()) {
381
+ const db = openStore(stateDir);
382
+ const sid = normalizeSessionId(sessionId);
383
+ db.prepare(`INSERT INTO minhash_signatures(chunk_id, session_id, signature_version, signatures)
384
+ VALUES(?, ?, ?, ?)
385
+ ON CONFLICT(chunk_id, signature_version) DO UPDATE SET
386
+ session_id=excluded.session_id, signatures=excluded.signatures`).run(chunkId, sid, signatureVersion, JSON.stringify(signatures));
387
+ }
388
+ /** Persist LSH bucket memberships for a chunk (one row per bucket key). */
389
+ export function insertLshBuckets(chunkId, sessionId, signatureVersion, bucketKeys, stateDir = getStateDir()) {
390
+ const db = openStore(stateDir);
391
+ const sid = normalizeSessionId(sessionId);
392
+ const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
393
+ const ins = db.prepare("INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)");
394
+ const tx = db.transaction(() => {
395
+ del.run(chunkId);
396
+ for (const key of bucketKeys)
397
+ ins.run(key, chunkId, sid, signatureVersion);
398
+ });
399
+ tx();
400
+ }
401
+ /**
402
+ * Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
403
+ * session, capped at `limit`. Single query (no N loops) — QA #15 amplification
404
+ * guard. Returns DISTINCT chunk_ids excluding `excludeChunkId` (the new row).
405
+ */
406
+ export function lshCandidateChunks(bucketKeys, sessionId, excludeChunkId, stateDir = getStateDir(), limit = 100) {
407
+ if (bucketKeys.length === 0)
408
+ return [];
409
+ const db = openStore(stateDir);
410
+ const sid = normalizeSessionId(sessionId);
411
+ const placeholders = bucketKeys.map(() => "?").join(",");
412
+ const rows = db
413
+ .prepare(`SELECT DISTINCT chunk_id FROM dedup_lsh_buckets
414
+ WHERE bucket_key IN (${placeholders}) AND session_id = ? AND chunk_id != ?
415
+ LIMIT ?`)
416
+ .all(...bucketKeys, sid, excludeChunkId, limit);
417
+ return rows.map((r) => r.chunk_id);
418
+ }
419
+ /** All checkpoints for a session, sorted by id. */
420
+ export function listCheckpoints(sessionId, stateDir = getStateDir()) {
421
+ const db = openStore(stateDir);
422
+ const sid = normalizeSessionId(sessionId);
423
+ const rows = db
424
+ .prepare("SELECT * FROM context_chunks WHERE session_id = ? ORDER BY id ASC")
425
+ .all(sid);
426
+ return rows.map(rowToCheckpoint);
427
+ }
428
+ /** Next sequential checkpoint id (chkpt_001 …) for a session. */
429
+ export function nextCheckpointId(sessionId, stateDir = getStateDir()) {
430
+ const db = openStore(stateDir);
431
+ const sid = normalizeSessionId(sessionId);
432
+ const row = db
433
+ .prepare("SELECT MAX(CAST(SUBSTR(id, 7) AS INTEGER)) AS n FROM context_chunks WHERE session_id = ?")
434
+ .get(sid);
435
+ const next = (row.n ?? 0) + 1;
436
+ return `chkpt_${String(next).padStart(3, "0")}`;
437
+ }
438
+ /** True if a checkpoint id already exists for a session. */
439
+ export function hasCheckpoint(sessionId, checkpointId, stateDir = getStateDir()) {
440
+ const db = openStore(stateDir);
441
+ const row = db
442
+ .prepare("SELECT 1 FROM context_chunks WHERE session_id = ? AND id = ? LIMIT 1")
443
+ .get(normalizeSessionId(sessionId), checkpointId);
444
+ return row !== undefined;
445
+ }
446
+ /** Mark a checkpoint's dedup_status (e.g. 'removed' by SemDeDup). */
447
+ export function setDedupStatus(checkpointId, sessionId, status, stateDir = getStateDir()) {
448
+ const db = openStore(stateDir);
449
+ db.prepare("UPDATE context_chunks SET dedup_status = ? WHERE id = ? AND session_id = ?").run(status, checkpointId, normalizeSessionId(sessionId));
450
+ }
451
+ // --- Session state (injection tracking) ------------------------------------
452
+ function loadSessionStateRow(sid, db) {
453
+ const row = db.prepare("SELECT * FROM session_state WHERE session_id = ?").get(sid);
454
+ if (!row) {
455
+ return { injectedCheckpointIds: [], storedRegionHashes: [] };
456
+ }
457
+ return {
458
+ injectedCheckpointIds: row.injected_checkpoint_ids ? JSON.parse(row.injected_checkpoint_ids) : [],
459
+ storedRegionHashes: row.stored_region_hashes ? JSON.parse(row.stored_region_hashes) : [],
460
+ };
461
+ }
462
+ export function loadSessionState(sessionId, stateDir = getStateDir()) {
463
+ return loadSessionStateRow(normalizeSessionId(sessionId), openStore(stateDir));
464
+ }
465
+ export function saveSessionState(sessionId, state, stateDir = getStateDir()) {
466
+ const db = openStore(stateDir);
467
+ const sid = normalizeSessionId(sessionId);
468
+ db.prepare(`INSERT INTO session_state(session_id, injected_checkpoint_ids, stored_region_hashes)
469
+ VALUES(@sid, @inj, @reg)
470
+ ON CONFLICT(session_id) DO UPDATE SET
471
+ injected_checkpoint_ids=excluded.injected_checkpoint_ids,
472
+ stored_region_hashes=excluded.stored_region_hashes`).run({
473
+ sid,
474
+ inj: jsonText(state.injectedCheckpointIds),
475
+ reg: jsonText(state.storedRegionHashes),
476
+ });
477
+ }
478
+ export function storeStats(sessionId, stateDir = getStateDir()) {
479
+ const db = openStore(stateDir);
480
+ const sid = normalizeSessionId(sessionId);
481
+ const row = db
482
+ .prepare(`SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
483
+ MAX(id) AS lastId
484
+ FROM context_chunks WHERE session_id = ?`)
485
+ .get(sid);
486
+ let lastSummary;
487
+ if (row.lastId) {
488
+ const s = db.prepare("SELECT summary FROM context_chunks WHERE id = ?").get(row.lastId);
489
+ lastSummary = s?.summary;
490
+ }
491
+ return {
492
+ checkpointCount: row.c,
493
+ totalTokenEstimate: row.tok,
494
+ lastCheckpointId: row.lastId ?? undefined,
495
+ lastSummary,
496
+ };
497
+ }
498
+ export function repoStats(stateDir = getStateDir()) {
499
+ const db = openStore(stateDir);
500
+ const row = db
501
+ .prepare(`SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
502
+ COALESCE(SUM(original_token_estimate),0) AS orig,
503
+ COUNT(DISTINCT session_id) AS sessions
504
+ FROM context_chunks WHERE dedup_status != 'removed'`)
505
+ .get();
506
+ const ds = getDedupStats(stateDir);
507
+ return {
508
+ checkpointCount: row.c,
509
+ totalTokenEstimate: row.tok,
510
+ originalTokens: row.orig,
511
+ sessionCount: row.sessions,
512
+ tokensSaved: getMetaNumber("tokens_saved", stateDir),
513
+ dedupAttempts: ds.attempts,
514
+ dedupCollapsed: ds.deduped,
515
+ storageDedupRate: ds.attempts === 0 ? 0 : ds.deduped / ds.attempts,
516
+ };
517
+ }
518
+ /** Close and evict a cached connection (test teardown only). */
519
+ export function closeStore(stateDir) {
520
+ const db = cache.get(stateDir);
521
+ if (db) {
522
+ db.close();
523
+ cache.delete(stateDir);
524
+ }
525
+ }
526
+ /** Persist a single RAPTOR node (upsert by (session_id, id)). */
527
+ export function upsertRaptorNode(node, stateDir = getStateDir()) {
528
+ const db = openStore(stateDir);
529
+ db.prepare(`INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate)
530
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
531
+ ON CONFLICT(session_id, id) DO UPDATE SET
532
+ level=excluded.level, parent_id=excluded.parent_id, children=excluded.children,
533
+ summary=excluded.summary, embedding_blob=excluded.embedding_blob,
534
+ quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate`).run(node.id, node.sessionId, node.level, node.parentId, jsonText(node.children), node.summary, encodeEmbedding(node.embedding), node.qualityMarker, node.tokenEstimate);
535
+ }
536
+ /** Persist an entire built RAPTOR tree for a session (shadow or live). */
537
+ export function saveRaptorTree(sessionId, tree, stateDir = getStateDir()) {
538
+ for (const node of tree.nodes.values()) {
539
+ upsertRaptorNode({
540
+ id: node.id,
541
+ sessionId,
542
+ level: node.level,
543
+ parentId: node.parentId,
544
+ children: node.children,
545
+ summary: node.summary,
546
+ embedding: node.embedding,
547
+ qualityMarker: node.qualityMarker,
548
+ tokenEstimate: node.tokenEstimate,
549
+ }, stateDir);
550
+ }
551
+ }
552
+ /** Load all RAPTOR nodes for a session. */
553
+ export function listRaptorNodes(sessionId, stateDir = getStateDir()) {
554
+ const db = openStore(stateDir);
555
+ const rows = db
556
+ .prepare("SELECT * FROM raptor_nodes WHERE session_id = ? ORDER BY level ASC, id ASC")
557
+ .all(normalizeSessionId(sessionId));
558
+ return rows.map((row) => ({
559
+ id: row.id,
560
+ sessionId: row.session_id,
561
+ level: row.level,
562
+ parentId: row.parent_id ?? null,
563
+ children: row.children ? JSON.parse(row.children) : [],
564
+ summary: row.summary ?? "",
565
+ embedding: decodeEmbedding(row.embedding_blob),
566
+ qualityMarker: row.quality_marker ?? "low",
567
+ tokenEstimate: row.token_estimate ?? 0,
568
+ }));
569
+ }
570
+ /** Delete all RAPTOR nodes for a session (rollback/cleanup). */
571
+ export function clearRaptorNodes(sessionId, stateDir = getStateDir()) {
572
+ const db = openStore(stateDir);
573
+ db.prepare("DELETE FROM raptor_nodes WHERE session_id = ?").run(normalizeSessionId(sessionId));
574
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * store.ts — persistence primitives for checkpoints + session state.
3
+ *
4
+ * Mirrors memory-mcp session_context.py: sessions normalize to `sess_xxx`,
5
+ * checkpoints are sequential `chkpt_001` per session. State lives under
6
+ * ~/.pi/agent/extensions/mega-compact/ as gzipped JSON.
7
+ */
8
+ import { randomBytes } from "node:crypto";
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { compressSmart, decompressSmart } from "./store/compression.js";
13
+ // Re-export the compression primitives from their extracted home so existing
14
+ // imports (`import { compressSmart } from "./store.js"`) keep working.
15
+ export { compressSmart, decompressSmart, compressZstd, compressZstdMax, decompressZstd, isVersioned, isZstd, detectFormat, decompressSyncAuto, } from "./store/compression.js";
16
+ /**
17
+ * State directory. Read lazily (per call) so tests can redirect it via
18
+ * MEGACOMPACT_STATE_DIR without re-importing the module. Defaults to
19
+ * ~/.pi/agent/extensions/mega-compact/.
20
+ */
21
+ export function getStateDir() {
22
+ return process.env.MEGACOMPACT_STATE_DIR ?? join(homedir(), ".pi", "agent", "extensions", "mega-compact");
23
+ }
24
+ /** Normalize an arbitrary session id to the `sess_xxx` form (port of memory-mcp). */
25
+ export function normalizeSessionId(sessionId) {
26
+ if (!sessionId)
27
+ return `sess_${randomBytes(8).toString("hex")}`;
28
+ if (sessionId.startsWith("sess_"))
29
+ return sessionId;
30
+ if (sessionId.length >= 32 && sessionId.includes("-")) {
31
+ return `sess_${sessionId.replace(/-/g, "").slice(0, 16)}`;
32
+ }
33
+ return `sess_${sessionId}`;
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // JSON persistence (DR snapshots + migration source).
37
+ // Compression lives in ./store/compression.ts; compressSmart/decompressSmart
38
+ // are imported above and re-exported for backward-compatible call sites.
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Read smart-compressed JSON, returning `fallback` on missing/corrupt file.
42
+ *
43
+ * Backward-compatible: detects legacy gzip files (magic byte 0x1f) and
44
+ * decompresses them correctly alongside new tagged files.
45
+ */
46
+ export function readGzJson(path, fallback) {
47
+ try {
48
+ if (!existsSync(path))
49
+ return fallback;
50
+ const buf = readFileSync(path);
51
+ const out = decompressSmart(buf);
52
+ return JSON.parse(out.toString("utf-8"));
53
+ }
54
+ catch {
55
+ return fallback;
56
+ }
57
+ }
58
+ /** Write JSON with dynamic compression; creates parent dirs. */
59
+ export function writeGzJson(path, data) {
60
+ mkdirSync(join(path, ".."), { recursive: true });
61
+ const jsonBuf = Buffer.from(JSON.stringify(data), "utf-8");
62
+ const compressed = compressSmart(jsonBuf);
63
+ writeFileSync(path, compressed);
64
+ }
65
+ /** Append a checkpoint to the per-session checkpoint file (gzipped). */
66
+ export function appendCheckpoint(cp, stateDir = getStateDir()) {
67
+ const file = join(stateDir, `${cp.sessionId}.checkpoints.json.gz`);
68
+ const existing = readGzJson(file, []);
69
+ existing.push(cp);
70
+ writeGzJson(file, existing);
71
+ }
72
+ /** All checkpoints for a session (across branches). */
73
+ export function listCheckpoints(sessionId, stateDir = getStateDir()) {
74
+ const file = join(stateDir, `${normalizeSessionId(sessionId)}.checkpoints.json.gz`);
75
+ return readGzJson(file, []);
76
+ }
77
+ /**
78
+ * Rewrite ALL checkpoints for a session (in-place update).
79
+ *
80
+ * Used by VectorStore when summaryHash or contentSimilarity dedup updates an
81
+ * existing checkpoint's timestamp/metadata instead of creating a new one.
82
+ */
83
+ export function rewriteCheckpoints(sessionId, checkpoints, stateDir = getStateDir()) {
84
+ const file = join(stateDir, `${normalizeSessionId(sessionId)}.checkpoints.json.gz`);
85
+ writeGzJson(file, checkpoints);
86
+ }
87
+ /**
88
+ * Generate the next sequential checkpoint id for a session (chkpt_001 ...).
89
+ */
90
+ export function nextCheckpointId(sessionId, stateDir = getStateDir()) {
91
+ const list = listCheckpoints(sessionId, stateDir);
92
+ const max = list.reduce((m, c) => {
93
+ const n = parseInt(c.checkpointId.replace("chkpt_", ""), 10);
94
+ return Number.isFinite(n) && n > m ? n : m;
95
+ }, 0);
96
+ return `chkpt_${String(max + 1).padStart(3, "0")}`;
97
+ }
98
+ /** Load mutable session state (created on demand). */
99
+ export function loadSessionState(sessionId, stateDir = getStateDir()) {
100
+ const file = join(stateDir, `${normalizeSessionId(sessionId)}.state.json.gz`);
101
+ return readGzJson(file, {
102
+ injectedCheckpointIds: [],
103
+ storedRegionHashes: [],
104
+ });
105
+ }
106
+ export function saveSessionState(sessionId, state, stateDir = getStateDir()) {
107
+ const file = join(stateDir, `${normalizeSessionId(sessionId)}.state.json.gz`);
108
+ writeGzJson(file, state);
109
+ }
110
+ /**
111
+ * Cumulative store-wide dedup accounting now lives in the SQLite `meta` table
112
+ * (see store/sqlite.ts: getDedupStats / bumpDedupStats). All store stats are
113
+ * SQLite-backed so they survive session restarts and travel with the repo's
114
+ * state dir. The legacy JSON `dedup-stats.json` path was removed.
115
+ */