pi-mega-compact 0.4.0

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/LICENSE +24 -0
  2. package/README.md +375 -0
  3. package/extensions/DASHBOARD.md +160 -0
  4. package/extensions/dashboard-server.test.ts +124 -0
  5. package/extensions/dashboard-server.ts +459 -0
  6. package/extensions/error-patterns.ts +175 -0
  7. package/extensions/mega-compact.test.ts +351 -0
  8. package/extensions/mega-compact.ts +846 -0
  9. package/extensions/openclaw-mega-compact.ts +370 -0
  10. package/package.json +61 -0
  11. package/src/adapt.ts +120 -0
  12. package/src/boundary.test.ts +61 -0
  13. package/src/boundary.ts +94 -0
  14. package/src/canary.ts +126 -0
  15. package/src/compact.test.ts +99 -0
  16. package/src/compact.ts +262 -0
  17. package/src/config/dedup.ts +120 -0
  18. package/src/config.ts +15 -0
  19. package/src/dedup/dedup.test.ts +46 -0
  20. package/src/dedup/digest.ts +40 -0
  21. package/src/dedup/l1-lsh.ts +67 -0
  22. package/src/dedup/l1-minhash.ts +90 -0
  23. package/src/dedup/l1-verify.ts +55 -0
  24. package/src/dedup/l1.test.ts +57 -0
  25. package/src/dedup/mmr.ts +54 -0
  26. package/src/dedup/normalize.ts +41 -0
  27. package/src/dedup/raptor/guardrails.ts +112 -0
  28. package/src/dedup/raptor/index.ts +118 -0
  29. package/src/dedup/raptor/kmeans.ts +156 -0
  30. package/src/dedup/raptor/raptor.test.ts +238 -0
  31. package/src/dedup/raptor/retrieval.ts +102 -0
  32. package/src/dedup/raptor/summarizer.ts +91 -0
  33. package/src/dedup/raptor/tree.ts +254 -0
  34. package/src/dedup/sprint12.test.ts +242 -0
  35. package/src/dedup/topk.ts +61 -0
  36. package/src/dedup-engine.test.ts +609 -0
  37. package/src/e2e.test.ts +843 -0
  38. package/src/embedder.ts +111 -0
  39. package/src/engine.test.ts +123 -0
  40. package/src/engine.ts +192 -0
  41. package/src/extractive.test.ts +156 -0
  42. package/src/extractive.ts +265 -0
  43. package/src/httpEmbedder.ts +154 -0
  44. package/src/log.test.ts +47 -0
  45. package/src/log.ts +60 -0
  46. package/src/monitoring.ts +171 -0
  47. package/src/ratio.bench.test.ts +1316 -0
  48. package/src/recall.integration.test.ts +96 -0
  49. package/src/recall.test.ts +59 -0
  50. package/src/recall.ts +100 -0
  51. package/src/sprint14.test.ts +245 -0
  52. package/src/store/backfill.ts +263 -0
  53. package/src/store/bloom.ts +122 -0
  54. package/src/store/compression.test.ts +83 -0
  55. package/src/store/compression.ts +203 -0
  56. package/src/store/integrity.ts +65 -0
  57. package/src/store/migrate.test.ts +158 -0
  58. package/src/store/migrate.ts +108 -0
  59. package/src/store/sprint10.test.ts +182 -0
  60. package/src/store/sqlite.ts +519 -0
  61. package/src/store.test.ts +169 -0
  62. package/src/store.ts +192 -0
  63. package/src/supersede.test.ts +42 -0
  64. package/src/supersede.ts +67 -0
  65. package/src/tokens.ts +35 -0
  66. package/src/types.test.ts +10 -0
  67. package/src/types.ts +49 -0
  68. package/src/vectorStore.test.ts +480 -0
  69. package/src/vectorStore.ts +544 -0
@@ -0,0 +1,519 @@
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
+
18
+ import Database from "better-sqlite3";
19
+ import { existsSync, mkdirSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { getStateDir } from "../store.js";
22
+ import type { StoredCheckpoint, SessionState } from "../store.js";
23
+ import { normalizeSessionId } from "../store.js";
24
+
25
+ const SCHEMA_VERSION = 1;
26
+
27
+ /** Encode a float vector as a little-endian Float32 BLOB for cosine scanning. */
28
+ function encodeEmbedding(v: number[]): Buffer {
29
+ const buf = Buffer.allocUnsafe(v.length * 4);
30
+ for (let i = 0; i < v.length; i++) buf.writeFloatLE(v[i] ?? 0, i * 4);
31
+ return buf;
32
+ }
33
+ /** Decode a Float32 BLOB back to a number[]. */
34
+ function decodeEmbedding(buf: Buffer | null | undefined): number[] {
35
+ if (!buf || buf.length === 0) return [];
36
+ const n = buf.length / 4;
37
+ const out = new Array<number>(n);
38
+ for (let i = 0; i < n; i++) out[i] = buf.readFloatLE(i * 4);
39
+ return out;
40
+ }
41
+
42
+ function jsonText(v: unknown): string {
43
+ return JSON.stringify(v ?? []);
44
+ }
45
+
46
+ // In-process cache so the same stateDir reuses one connection (and so a fresh
47
+ // VectorStore over the same dir shares the open DB). Cross-process durability
48
+ // comes from reopening the same file path — proven by the integration test.
49
+ const cache = new Map<string, Database.Database>();
50
+
51
+ /** Open (or reuse) the SQLite store for a state dir. */
52
+ export function openStore(stateDir: string = getStateDir()): Database.Database {
53
+ const existing = cache.get(stateDir);
54
+ if (existing) return existing;
55
+
56
+ if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
57
+ const db = new Database(join(stateDir, "sqlite.db"));
58
+ db.pragma("journal_mode = WAL");
59
+ db.pragma("foreign_keys = ON");
60
+ initSchema(db);
61
+ cache.set(stateDir, db);
62
+ return db;
63
+ }
64
+
65
+ function initSchema(db: Database.Database): void {
66
+ db.exec(`
67
+ CREATE TABLE IF NOT EXISTS context_chunks (
68
+ id TEXT NOT NULL,
69
+ session_id TEXT NOT NULL,
70
+ region_hash TEXT,
71
+ content_hash TEXT,
72
+ content_hash2 TEXT,
73
+ content_hash_version INTEGER,
74
+ normalized_text TEXT,
75
+ summary TEXT,
76
+ topic_summary TEXT,
77
+ summary_hash TEXT,
78
+ key_decisions TEXT, -- JSON array
79
+ next_steps TEXT, -- JSON array
80
+ files_modified TEXT, -- JSON array
81
+ embedding_blob BLOB, -- float32 vector
82
+ token_estimate INTEGER,
83
+ timestamp INTEGER,
84
+ dedup_status TEXT DEFAULT 'active',
85
+ compressed_original BLOB -- optional DR copy
86
+ );
87
+ -- Primary key is (session_id, id): checkpoint ids are unique per session
88
+ -- (chkpt_001 per session), not globally, so a bare id PK would collide
89
+ -- across sessions on the nextCheckpointId sequence.
90
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_pk
91
+ ON context_chunks(session_id, id);
92
+ CREATE INDEX IF NOT EXISTS idx_chunks_session ON context_chunks(session_id);
93
+ CREATE INDEX IF NOT EXISTS idx_chunks_region ON context_chunks(region_hash);
94
+ CREATE INDEX IF NOT EXISTS idx_chunks_content ON context_chunks(content_hash);
95
+ -- Partial UNIQUE (QA #1): null content_hash rows never violate the constraint;
96
+ -- ON CONFLICT DO NOTHING makes backfill + L0 inserts safe.
97
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_content_hash
98
+ ON context_chunks(session_id, content_hash) WHERE content_hash IS NOT NULL;
99
+
100
+ -- Sprint 11: MinHash signature + LSH bucket tables for L1 near-dup dedup.
101
+ CREATE TABLE IF NOT EXISTS minhash_signatures (
102
+ chunk_id TEXT NOT NULL,
103
+ session_id TEXT NOT NULL,
104
+ signature_version INTEGER NOT NULL,
105
+ signatures TEXT NOT NULL, -- JSON array of 256 uint32
106
+ PRIMARY KEY (chunk_id, signature_version)
107
+ );
108
+ CREATE INDEX IF NOT EXISTS idx_minhash_session ON minhash_signatures(session_id);
109
+
110
+ CREATE TABLE IF NOT EXISTS dedup_lsh_buckets (
111
+ bucket_key TEXT NOT NULL,
112
+ chunk_id TEXT NOT NULL,
113
+ session_id TEXT NOT NULL,
114
+ signature_version INTEGER NOT NULL,
115
+ PRIMARY KEY (bucket_key, chunk_id)
116
+ );
117
+ CREATE INDEX IF NOT EXISTS idx_lsh_bucket ON dedup_lsh_buckets(bucket_key, session_id);
118
+
119
+ CREATE TABLE IF NOT EXISTS session_state (
120
+ session_id TEXT PRIMARY KEY,
121
+ injected_checkpoint_ids TEXT, -- JSON array
122
+ stored_region_hashes TEXT -- JSON array
123
+ );
124
+
125
+ CREATE TABLE IF NOT EXISTS meta (
126
+ key TEXT PRIMARY KEY,
127
+ value TEXT
128
+ );
129
+
130
+ -- Sprint 13 (RAPTOR): hierarchical summary tree nodes. children are a JSON
131
+ -- array of child node ids (or raw leaf ids at the bottom); embedding_blob
132
+ -- is the node centroid. Additive; retrieval ignores this table until
133
+ -- Sprint 14 promotes RAPTOR out of shadow mode.
134
+ CREATE TABLE IF NOT EXISTS raptor_nodes (
135
+ id TEXT NOT NULL,
136
+ session_id TEXT NOT NULL,
137
+ level INTEGER NOT NULL,
138
+ parent_id TEXT,
139
+ children TEXT, -- JSON array of child ids
140
+ summary TEXT,
141
+ embedding_blob BLOB, -- float32 centroid
142
+ quality_marker TEXT DEFAULT 'low',
143
+ token_estimate INTEGER,
144
+ PRIMARY KEY (session_id, id)
145
+ );
146
+ CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
147
+ CREATE INDEX IF NOT EXISTS idx_raptor_parent ON raptor_nodes(parent_id);
148
+ `);
149
+ const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
150
+ | { value: string }
151
+ | undefined;
152
+ if (!v) {
153
+ db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", String(SCHEMA_VERSION));
154
+ }
155
+
156
+ // FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
157
+ db.exec(`
158
+ CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
159
+ id UNINDEXED,
160
+ normalized_text,
161
+ tokenize='trigram'
162
+ );
163
+ `);
164
+ }
165
+
166
+ /** Map a DB row to the public StoredCheckpoint shape. */
167
+ function rowToCheckpoint(row: any): StoredCheckpoint {
168
+ return {
169
+ checkpointId: row.id,
170
+ sessionId: row.session_id,
171
+ summary: row.summary ?? "",
172
+ topicSummary: row.topic_summary ?? undefined,
173
+ summaryHash: row.summary_hash ?? undefined,
174
+ keyDecisions: row.key_decisions ? JSON.parse(row.key_decisions) : [],
175
+ nextSteps: row.next_steps ? JSON.parse(row.next_steps) : [],
176
+ filesModified: row.files_modified ? JSON.parse(row.files_modified) : [],
177
+ tokenEstimate: row.token_estimate ?? 0,
178
+ regionHash: row.region_hash ?? "",
179
+ contentHash: row.content_hash ?? undefined,
180
+ contentHash2: row.content_hash2 ?? undefined,
181
+ contentHashVersion: row.content_hash_version ?? undefined,
182
+ normalizedText: row.normalized_text ?? undefined,
183
+ compressedOriginal: row.compressed_original ?? undefined,
184
+ embedding: decodeEmbedding(row.embedding_blob),
185
+ timestamp: Number(row.timestamp ?? 0),
186
+ dedupStatus: row.dedup_status ?? undefined,
187
+ };
188
+ }
189
+
190
+ /** Insert or replace a checkpoint (idempotent by id). */
191
+ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getStateDir()): void {
192
+ const db = openStore(stateDir);
193
+ const sid = normalizeSessionId(cp.sessionId);
194
+ const tx = db.transaction(() => {
195
+ db.prepare(
196
+ `INSERT INTO context_chunks
197
+ (id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
198
+ normalized_text, summary, topic_summary, summary_hash,
199
+ key_decisions, next_steps, files_modified, embedding_blob,
200
+ token_estimate, timestamp, dedup_status, compressed_original)
201
+ VALUES (@id, @sid, @region_hash, @content_hash, @content_hash2, @content_hash_version,
202
+ @normalized_text, @summary, @topic_summary, @summary_hash,
203
+ @key_decisions, @next_steps, @files_modified, @embedding_blob,
204
+ @token_estimate, @timestamp, @dedup_status, @compressed_original)
205
+ ON CONFLICT(session_id, id) DO UPDATE SET
206
+ summary=excluded.summary,
207
+ topic_summary=excluded.topic_summary,
208
+ summary_hash=excluded.summary_hash,
209
+ key_decisions=excluded.key_decisions,
210
+ next_steps=excluded.next_steps,
211
+ files_modified=excluded.files_modified,
212
+ embedding_blob=excluded.embedding_blob,
213
+ token_estimate=excluded.token_estimate,
214
+ timestamp=excluded.timestamp,
215
+ dedup_status=excluded.dedup_status,
216
+ compressed_original=excluded.compressed_original`,
217
+ ).run({
218
+ id: cp.checkpointId,
219
+ sid,
220
+ region_hash: cp.regionHash ?? null,
221
+ content_hash: cp.contentHash ?? null,
222
+ content_hash2: cp.contentHash2 ?? null,
223
+ content_hash_version: cp.contentHashVersion ?? null,
224
+ normalized_text: cp.normalizedText ?? null,
225
+ summary: cp.summary ?? "",
226
+ topic_summary: cp.topicSummary ?? null,
227
+ summary_hash: cp.summaryHash ?? null,
228
+ key_decisions: jsonText(cp.keyDecisions),
229
+ next_steps: jsonText(cp.nextSteps),
230
+ files_modified: jsonText(cp.filesModified),
231
+ embedding_blob: encodeEmbedding(cp.embedding ?? []),
232
+ token_estimate: cp.tokenEstimate ?? 0,
233
+ timestamp: cp.timestamp ?? 0,
234
+ dedup_status: "active",
235
+ compressed_original: cp.compressedOriginal ?? null,
236
+ });
237
+
238
+ // FTS5 virtual tables don't support UPSERT — delete any prior row, reinsert.
239
+ // Store normalized_text (the L1 verify key); fall back to summary for rows
240
+ // that predate normalized_text population.
241
+ db.prepare("DELETE FROM context_chunks_trgm WHERE id = ?").run(cp.checkpointId);
242
+ db.prepare(
243
+ "INSERT INTO context_chunks_trgm(id, normalized_text) VALUES(?, ?)",
244
+ ).run(cp.checkpointId, cp.normalizedText ?? cp.summary ?? "");
245
+ });
246
+ tx();
247
+ }
248
+
249
+ // --- Sprint 11: MinHash signatures + LSH buckets --------------------------
250
+
251
+ /** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
252
+ export function upsertMinhashSignature(
253
+ chunkId: string,
254
+ sessionId: string,
255
+ signatureVersion: number,
256
+ signatures: number[],
257
+ stateDir: string = getStateDir(),
258
+ ): void {
259
+ const db = openStore(stateDir);
260
+ const sid = normalizeSessionId(sessionId);
261
+ db.prepare(
262
+ `INSERT INTO minhash_signatures(chunk_id, session_id, signature_version, signatures)
263
+ VALUES(?, ?, ?, ?)
264
+ ON CONFLICT(chunk_id, signature_version) DO UPDATE SET
265
+ session_id=excluded.session_id, signatures=excluded.signatures`,
266
+ ).run(chunkId, sid, signatureVersion, JSON.stringify(signatures));
267
+ }
268
+
269
+ /** Persist LSH bucket memberships for a chunk (one row per bucket key). */
270
+ export function insertLshBuckets(
271
+ chunkId: string,
272
+ sessionId: string,
273
+ signatureVersion: number,
274
+ bucketKeys: string[],
275
+ stateDir: string = getStateDir(),
276
+ ): void {
277
+ const db = openStore(stateDir);
278
+ const sid = normalizeSessionId(sessionId);
279
+ const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
280
+ const ins = db.prepare(
281
+ "INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)",
282
+ );
283
+ const tx = db.transaction(() => {
284
+ del.run(chunkId);
285
+ for (const key of bucketKeys) ins.run(key, chunkId, sid, signatureVersion);
286
+ });
287
+ tx();
288
+ }
289
+
290
+ /**
291
+ * Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
292
+ * session, capped at `limit`. Single query (no N loops) — QA #15 amplification
293
+ * guard. Returns DISTINCT chunk_ids excluding `excludeChunkId` (the new row).
294
+ */
295
+ export function lshCandidateChunks(
296
+ bucketKeys: string[],
297
+ sessionId: string,
298
+ excludeChunkId: string,
299
+ stateDir: string = getStateDir(),
300
+ limit = 100,
301
+ ): string[] {
302
+ if (bucketKeys.length === 0) return [];
303
+ const db = openStore(stateDir);
304
+ const sid = normalizeSessionId(sessionId);
305
+ const placeholders = bucketKeys.map(() => "?").join(",");
306
+ const rows = db
307
+ .prepare(
308
+ `SELECT DISTINCT chunk_id FROM dedup_lsh_buckets
309
+ WHERE bucket_key IN (${placeholders}) AND session_id = ? AND chunk_id != ?
310
+ LIMIT ?`,
311
+ )
312
+ .all(...bucketKeys, sid, excludeChunkId, limit) as { chunk_id: string }[];
313
+ return rows.map((r) => r.chunk_id);
314
+ }
315
+
316
+ /** All checkpoints for a session, sorted by id. */
317
+ export function listCheckpoints(sessionId: string, stateDir: string = getStateDir()): StoredCheckpoint[] {
318
+ const db = openStore(stateDir);
319
+ const sid = normalizeSessionId(sessionId);
320
+ const rows = db
321
+ .prepare("SELECT * FROM context_chunks WHERE session_id = ? ORDER BY id ASC")
322
+ .all(sid) as any[];
323
+ return rows.map(rowToCheckpoint);
324
+ }
325
+
326
+ /** Next sequential checkpoint id (chkpt_001 …) for a session. */
327
+ export function nextCheckpointId(sessionId: string, stateDir: string = getStateDir()): string {
328
+ const db = openStore(stateDir);
329
+ const sid = normalizeSessionId(sessionId);
330
+ const row = db
331
+ .prepare("SELECT MAX(CAST(SUBSTR(id, 7) AS INTEGER)) AS n FROM context_chunks WHERE session_id = ?")
332
+ .get(sid) as { n: number | null };
333
+ const next = (row.n ?? 0) + 1;
334
+ return `chkpt_${String(next).padStart(3, "0")}`;
335
+ }
336
+
337
+ /** True if a checkpoint id already exists for a session. */
338
+ export function hasCheckpoint(sessionId: string, checkpointId: string, stateDir: string = getStateDir()): boolean {
339
+ const db = openStore(stateDir);
340
+ const row = db
341
+ .prepare("SELECT 1 FROM context_chunks WHERE session_id = ? AND id = ? LIMIT 1")
342
+ .get(normalizeSessionId(sessionId), checkpointId);
343
+ return row !== undefined;
344
+ }
345
+
346
+ /** Mark a checkpoint's dedup_status (e.g. 'removed' by SemDeDup). */
347
+ export function setDedupStatus(
348
+ checkpointId: string,
349
+ sessionId: string,
350
+ status: string,
351
+ stateDir: string = getStateDir(),
352
+ ): void {
353
+ const db = openStore(stateDir);
354
+ db.prepare(
355
+ "UPDATE context_chunks SET dedup_status = ? WHERE id = ? AND session_id = ?",
356
+ ).run(status, checkpointId, normalizeSessionId(sessionId));
357
+ }
358
+
359
+ // --- Session state (injection tracking) ------------------------------------
360
+
361
+ function loadSessionStateRow(sid: string, db: Database.Database): SessionState {
362
+ const row = db.prepare("SELECT * FROM session_state WHERE session_id = ?").get(sid) as any;
363
+ if (!row) {
364
+ return { injectedCheckpointIds: [], storedRegionHashes: [] };
365
+ }
366
+ return {
367
+ injectedCheckpointIds: row.injected_checkpoint_ids ? JSON.parse(row.injected_checkpoint_ids) : [],
368
+ storedRegionHashes: row.stored_region_hashes ? JSON.parse(row.stored_region_hashes) : [],
369
+ };
370
+ }
371
+
372
+ export function loadSessionState(sessionId: string, stateDir: string = getStateDir()): SessionState {
373
+ return loadSessionStateRow(normalizeSessionId(sessionId), openStore(stateDir));
374
+ }
375
+
376
+ export function saveSessionState(sessionId: string, state: SessionState, stateDir: string = getStateDir()): void {
377
+ const db = openStore(stateDir);
378
+ const sid = normalizeSessionId(sessionId);
379
+ db.prepare(
380
+ `INSERT INTO session_state(session_id, injected_checkpoint_ids, stored_region_hashes)
381
+ VALUES(@sid, @inj, @reg)
382
+ ON CONFLICT(session_id) DO UPDATE SET
383
+ injected_checkpoint_ids=excluded.injected_checkpoint_ids,
384
+ stored_region_hashes=excluded.stored_region_hashes`,
385
+ ).run({
386
+ sid,
387
+ inj: jsonText(state.injectedCheckpointIds),
388
+ reg: jsonText(state.storedRegionHashes),
389
+ });
390
+ }
391
+
392
+ // --- Stats -----------------------------------------------------------------
393
+
394
+ export interface StoreStats {
395
+ checkpointCount: number;
396
+ totalTokenEstimate: number;
397
+ lastCheckpointId: string | undefined;
398
+ lastSummary: string | undefined;
399
+ }
400
+
401
+ export function storeStats(sessionId: string, stateDir: string = getStateDir()): StoreStats {
402
+ const db = openStore(stateDir);
403
+ const sid = normalizeSessionId(sessionId);
404
+ const row = db
405
+ .prepare(
406
+ `SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
407
+ MAX(id) AS lastId
408
+ FROM context_chunks WHERE session_id = ?`,
409
+ )
410
+ .get(sid) as { c: number; tok: number; lastId: string | null };
411
+ let lastSummary: string | undefined;
412
+ if (row.lastId) {
413
+ const s = db.prepare("SELECT summary FROM context_chunks WHERE id = ?").get(row.lastId) as
414
+ | { summary: string }
415
+ | undefined;
416
+ lastSummary = s?.summary;
417
+ }
418
+ return {
419
+ checkpointCount: row.c,
420
+ totalTokenEstimate: row.tok,
421
+ lastCheckpointId: row.lastId ?? undefined,
422
+ lastSummary,
423
+ };
424
+ }
425
+
426
+ /** Close and evict a cached connection (test teardown only). */
427
+ export function closeStore(stateDir: string): void {
428
+ const db = cache.get(stateDir);
429
+ if (db) {
430
+ db.close();
431
+ cache.delete(stateDir);
432
+ }
433
+ }
434
+
435
+ // ---- Sprint 13: RAPTOR node persistence ----------------------------------
436
+
437
+ export interface StoredRaptorNode {
438
+ id: string;
439
+ sessionId: string;
440
+ level: number;
441
+ parentId: string | null;
442
+ children: string[];
443
+ summary: string;
444
+ embedding: number[];
445
+ qualityMarker: string;
446
+ tokenEstimate: number;
447
+ }
448
+
449
+ /** Persist a single RAPTOR node (upsert by (session_id, id)). */
450
+ export function upsertRaptorNode(node: StoredRaptorNode, stateDir: string = getStateDir()): void {
451
+ const db = openStore(stateDir);
452
+ db.prepare(
453
+ `INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate)
454
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
455
+ ON CONFLICT(session_id, id) DO UPDATE SET
456
+ level=excluded.level, parent_id=excluded.parent_id, children=excluded.children,
457
+ summary=excluded.summary, embedding_blob=excluded.embedding_blob,
458
+ quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate`,
459
+ ).run(
460
+ node.id,
461
+ node.sessionId,
462
+ node.level,
463
+ node.parentId,
464
+ jsonText(node.children),
465
+ node.summary,
466
+ encodeEmbedding(node.embedding),
467
+ node.qualityMarker,
468
+ node.tokenEstimate,
469
+ );
470
+ }
471
+
472
+ /** Persist an entire built RAPTOR tree for a session (shadow or live). */
473
+ export function saveRaptorTree(
474
+ sessionId: string,
475
+ tree: { nodes: Map<string, { id: string; level: number; parentId: string | null; children: string[]; summary: string; embedding: number[]; qualityMarker: string; tokenEstimate: number }> },
476
+ stateDir: string = getStateDir(),
477
+ ): void {
478
+ for (const node of tree.nodes.values()) {
479
+ upsertRaptorNode(
480
+ {
481
+ id: node.id,
482
+ sessionId,
483
+ level: node.level,
484
+ parentId: node.parentId,
485
+ children: node.children,
486
+ summary: node.summary,
487
+ embedding: node.embedding,
488
+ qualityMarker: node.qualityMarker,
489
+ tokenEstimate: node.tokenEstimate,
490
+ },
491
+ stateDir,
492
+ );
493
+ }
494
+ }
495
+
496
+ /** Load all RAPTOR nodes for a session. */
497
+ export function listRaptorNodes(sessionId: string, stateDir: string = getStateDir()): StoredRaptorNode[] {
498
+ const db = openStore(stateDir);
499
+ const rows = db
500
+ .prepare("SELECT * FROM raptor_nodes WHERE session_id = ? ORDER BY level ASC, id ASC")
501
+ .all(normalizeSessionId(sessionId)) as any[];
502
+ return rows.map((row) => ({
503
+ id: row.id,
504
+ sessionId: row.session_id,
505
+ level: row.level,
506
+ parentId: row.parent_id ?? null,
507
+ children: row.children ? JSON.parse(row.children) : [],
508
+ summary: row.summary ?? "",
509
+ embedding: decodeEmbedding(row.embedding_blob),
510
+ qualityMarker: row.quality_marker ?? "low",
511
+ tokenEstimate: row.token_estimate ?? 0,
512
+ }));
513
+ }
514
+
515
+ /** Delete all RAPTOR nodes for a session (rollback/cleanup). */
516
+ export function clearRaptorNodes(sessionId: string, stateDir: string = getStateDir()): void {
517
+ const db = openStore(stateDir);
518
+ db.prepare("DELETE FROM raptor_nodes WHERE session_id = ?").run(normalizeSessionId(sessionId));
519
+ }
@@ -0,0 +1,169 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { gzipSync } from "node:zlib";
7
+ import {
8
+ compressSmart,
9
+ decompressSmart,
10
+ readGzJson,
11
+ writeGzJson,
12
+ normalizeSessionId,
13
+ nextCheckpointId,
14
+ } from "./store.js";
15
+
16
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-store-"));
17
+ let counter = 0;
18
+ function tmpDir() {
19
+ return join(baseTmp, `d-${counter++}`);
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // compressSmart / decompressSmart
24
+ // ---------------------------------------------------------------------------
25
+
26
+ test("compressSmart: tiny payload (<512B) uses RAW tier", () => {
27
+ const data = Buffer.from(JSON.stringify({ hello: "world" }));
28
+ assert.ok(data.length < 512);
29
+ const compressed = compressSmart(data);
30
+ // Versioned header: 0xEC 0x01 [version=1] [tier=0x00 RAW]; payload after byte 4.
31
+ assert.equal(compressed[0], 0xec, "magic hi");
32
+ assert.equal(compressed[1], 0x01, "magic lo / version marker");
33
+ assert.equal(compressed[2], 0x01, "format version 1");
34
+ assert.equal(compressed[3], 0x00, "tag RAW");
35
+ // Payload after the 4-byte header should be identical to original
36
+ assert.deepEqual(compressed.subarray(4), data);
37
+ });
38
+
39
+ test("compressSmart: medium payload (4KB–32KB) uses GZIP-6 tier", () => {
40
+ // ~8KB of repetitive text
41
+ const data = Buffer.from("the quick brown fox jumps over the lazy dog. ".repeat(180));
42
+ assert.ok(data.length >= 4096 && data.length < 32768);
43
+ const compressed = compressSmart(data);
44
+ // Versioned header + tier tag 0x02 (GZIP-6) at byte 3.
45
+ assert.equal(compressed[0], 0xec);
46
+ assert.equal(compressed[3], 0x02, "tag GZIP-6");
47
+ // Compressed should be smaller
48
+ assert.ok(compressed.length < data.length, "compressed smaller than raw");
49
+ });
50
+
51
+ test("compressSmart: large payload (>32KB) uses BROTLI tier", () => {
52
+ // ~40KB of repetitive text
53
+ const data = Buffer.from("this is a long summary of a coding session. ".repeat(900));
54
+ assert.ok(data.length >= 32768);
55
+ const compressed = compressSmart(data);
56
+ // Versioned header + tier tag 0x05 (BROTLI_4) at byte 3.
57
+ assert.equal(compressed[0], 0xec);
58
+ assert.equal(compressed[3], 0x05, "tag BROTLI_4");
59
+ // Compressed should be smaller
60
+ assert.ok(compressed.length < data.length, "brotli compressed smaller than raw");
61
+ });
62
+
63
+ test("compressSmart: small payload (512B–4KB) uses GZIP-1 tier", () => {
64
+ // ~1.5KB
65
+ const data = Buffer.from("a moderately sized summary with some repetition. ".repeat(30));
66
+ assert.ok(data.length >= 512 && data.length < 4096);
67
+ const compressed = compressSmart(data);
68
+ assert.equal(compressed[0], 0xec);
69
+ assert.equal(compressed[3], 0x01, "tag GZIP-1");
70
+ });
71
+
72
+ test("decompressSmart roundtrips all tiers", () => {
73
+ const sizes = [
74
+ { label: "tiny", gen: () => Buffer.from("small") },
75
+ { label: "small", gen: () => Buffer.from("x".repeat(600)) },
76
+ { label: "medium", gen: () => Buffer.from("y".repeat(8000)) },
77
+ { label: "large", gen: () => Buffer.from("z".repeat(40000)) },
78
+ ];
79
+ for (const { label, gen } of sizes) {
80
+ const original = gen();
81
+ const compressed = compressSmart(original);
82
+ const decompressed = decompressSmart(compressed);
83
+ assert.deepEqual(decompressed, original, `roundtrip failed for ${label} (${original.length}B)`);
84
+ }
85
+ });
86
+
87
+ test("decompressSmart handles legacy untagged gzip files (backward compat)", () => {
88
+ const data = Buffer.from(JSON.stringify({ legacy: true }));
89
+ const legacyGzip = gzipSync(data); // no tag byte, starts with 0x1f
90
+ assert.equal(legacyGzip[0], 0x1f, "gzip magic byte present");
91
+ const result = decompressSmart(legacyGzip);
92
+ assert.deepEqual(JSON.parse(result.toString()), { legacy: true });
93
+ });
94
+
95
+ test("readGzJson / writeGzJson roundtrip with smart compression", () => {
96
+ const dir = tmpDir();
97
+ const path = join(dir, "test.json.gz");
98
+ const data = [{ id: "a", value: 42 }, { id: "b", value: 99 }];
99
+ writeGzJson(path, data);
100
+ const loaded = readGzJson<typeof data>(path, []);
101
+ assert.deepEqual(loaded, data);
102
+ });
103
+
104
+ test("readGzJson reads legacy gzip files written by old code", () => {
105
+ const dir = tmpDir();
106
+ const path = join(dir, "legacy.json.gz");
107
+ const data = { old: true };
108
+ // Simulate old writeGzJson (plain gzip, no tag)
109
+ mkdirSync(join(path, ".."), { recursive: true });
110
+ const buf = gzipSync(Buffer.from(JSON.stringify(data), "utf-8"));
111
+ writeFileSync(path, buf);
112
+
113
+ const loaded = readGzJson<typeof data>(path, { old: false });
114
+ assert.deepEqual(loaded, { old: true });
115
+ });
116
+
117
+ test("compression tier: GZIP-1 and GZIP-6 tiers produce valid, smaller output", () => {
118
+ // GZIP-1 tier (~600B input, 512B–4KB band)
119
+ const small = compressSmart(Buffer.from("compress me ".repeat(200)));
120
+ assert.equal(small[0], 0xec, "versioned magic");
121
+ assert.equal(small[3], 0x01, "GZIP-1 tag for small input");
122
+ assert.ok(small.length < 600, "GZIP-1 output smaller than input");
123
+ assert.deepEqual(decompressSmart(small), Buffer.from("compress me ".repeat(200)));
124
+
125
+ // GZIP-6 tier (~8KB input, 4KB–32KB band)
126
+ const big = compressSmart(Buffer.from("compress me ".repeat(1800)));
127
+ assert.equal(big[0], 0xec, "versioned magic");
128
+ assert.equal(big[3], 0x02, "GZIP-6 tag for medium input");
129
+ assert.ok(big.length < Buffer.from("compress me ".repeat(1800)).length, "GZIP-6 output smaller than input");
130
+ assert.deepEqual(decompressSmart(big), Buffer.from("compress me ".repeat(1800)));
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // normalizeSessionId
135
+ // ---------------------------------------------------------------------------
136
+
137
+ test("normalizeSessionId adds sess_ prefix when missing", () => {
138
+ assert.equal(normalizeSessionId("abc"), "sess_abc");
139
+ assert.equal(normalizeSessionId("sess_abc"), "sess_abc");
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // nextCheckpointId
144
+ // ---------------------------------------------------------------------------
145
+
146
+ test("nextCheckpointId returns chkpt_001 for new session", () => {
147
+ const dir = tmpDir();
148
+ assert.equal(nextCheckpointId("sess_nci1", dir), "chkpt_001");
149
+ });
150
+
151
+ test("nextCheckpointId increments highest existing id", () => {
152
+ const dir = tmpDir();
153
+ const sid = "sess_nci2";
154
+ // Write a checkpoints file with two entries to simulate existing state
155
+ const fakeCheckpoints = [
156
+ { checkpointId: "chkpt_001", sessionId: "sess_nci2", summary: "a", keyDecisions: [], nextSteps: [], filesModified: [], tokenEstimate: 100, regionHash: "h1", embedding: [], timestamp: 1 },
157
+ { checkpointId: "chkpt_003", sessionId: "sess_nci2", summary: "b", keyDecisions: [], nextSteps: [], filesModified: [], tokenEstimate: 100, regionHash: "h2", embedding: [], timestamp: 2 },
158
+ ];
159
+ writeGzJson(join(dir, "sess_nci2.checkpoints.json.gz"), fakeCheckpoints);
160
+ assert.equal(nextCheckpointId(sid, dir), "chkpt_004");
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // cleanup
165
+ // ---------------------------------------------------------------------------
166
+
167
+ test("cleanup", () => {
168
+ rmSync(baseTmp, { recursive: true, force: true });
169
+ });