pi-mega-compact 0.4.20 → 0.4.23

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 (51) hide show
  1. package/dist/extensions/conflict-scan.js +201 -0
  2. package/dist/extensions/dashboard-server.js +3 -3
  3. package/dist/extensions/mega-compact-driver.js +79 -0
  4. package/dist/extensions/mega-compact.js +2 -0
  5. package/dist/extensions/mega-compact.test.js +54 -18
  6. package/dist/extensions/mega-config.js +10 -0
  7. package/dist/extensions/mega-conflict-cmds.js +121 -0
  8. package/dist/extensions/mega-events.js +45 -23
  9. package/dist/extensions/mega-pipeline.js +80 -8
  10. package/dist/extensions/mega-runtime.js +14 -20
  11. package/dist/src/config/dedup.js +4 -1
  12. package/dist/src/config.js +21 -0
  13. package/dist/src/dedup/raptor/index.js +28 -6
  14. package/dist/src/dedup/raptor/promote.test.js +69 -0
  15. package/dist/src/engine.js +1 -0
  16. package/dist/src/recall.js +30 -4
  17. package/dist/src/recall.test.js +28 -0
  18. package/dist/src/store/backfill.js +5 -6
  19. package/dist/src/store/compression.js +47 -7
  20. package/dist/src/store/compression.test.js +48 -0
  21. package/dist/src/store/sqlite.js +123 -41
  22. package/dist/src/store.test.js +19 -0
  23. package/dist/src/vectorStore.js +56 -1
  24. package/extensions/DASHBOARD.md +3 -3
  25. package/extensions/conflict-scan.ts +209 -0
  26. package/extensions/dashboard-server.ts +4 -4
  27. package/extensions/mega-compact-driver.ts +105 -0
  28. package/extensions/mega-compact.test.ts +65 -18
  29. package/extensions/mega-compact.ts +2 -0
  30. package/extensions/mega-config.ts +25 -0
  31. package/extensions/mega-conflict-cmds.ts +129 -0
  32. package/extensions/mega-events.ts +43 -24
  33. package/extensions/mega-pipeline.ts +86 -9
  34. package/extensions/mega-runtime.ts +14 -18
  35. package/package.json +6 -7
  36. package/src/config/dedup.ts +4 -1
  37. package/src/config.ts +26 -0
  38. package/src/dedup/raptor/index.ts +42 -7
  39. package/src/dedup/raptor/promote.test.ts +82 -0
  40. package/src/engine.ts +5 -0
  41. package/src/recall.test.ts +44 -0
  42. package/src/recall.ts +43 -4
  43. package/src/store/backfill.ts +10 -11
  44. package/src/store/compression.test.ts +58 -0
  45. package/src/store/compression.ts +48 -7
  46. package/src/store/sqlite.ts +156 -49
  47. package/src/store.test.ts +22 -0
  48. package/src/vectorStore.ts +63 -1
  49. package/dist/extensions/openclaw-mega-compact.js +0 -291
  50. package/dist/src/minilm.js +0 -92
  51. package/dist/src/wordpiece.js +0 -129
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 1. `compressSmart` / `decompressSmart` — SYNCHRONOUS, zlib-based. Used by the
7
7
  * VectorStore write path (which must stay synchronous — see Sprint 8 plan:
8
- * better-sqlite3 replaced PGlite precisely to avoid an async cascade).
8
+ * node:sqlite replaced PGlite precisely to avoid an async cascade).
9
9
  *
10
10
  * 2. `compressZstd` / `decompressZstd` — ASYNCHRONOUS, via @mongodb-js/zstd.
11
11
  * Optional, used for DR-export / large-blob paths where an await is fine.
@@ -25,7 +25,13 @@
25
25
  * decompressSmart detects the magic first, so all three eras roundtrip together.
26
26
  */
27
27
  import { gzipSync, gunzipSync, brotliCompressSync, brotliDecompressSync, constants as zlibConstants, } from "node:zlib";
28
- import zstd from "@mongodb-js/zstd";
28
+ // zstd is loaded lazily (see compressZstdWithLevel / decompressZstd). It is an
29
+ // OPTIONAL async DR-export dependency: its native addon (`zstd.node`) is not in
30
+ // the npm tarball and may be absent on a clean/allowScripts-blocked install, so
31
+ // a static import here would crash the whole extension at load time. Lazy
32
+ // import keeps the extension loadable even when the binary is missing; the DR
33
+ // path throws a clear error only if it is actually used. (Fix A.)
34
+ // import zstd from "@mongodb-js/zstd";
29
35
  // --- Versioned format markers ----------------------------------------------
30
36
  const MAGIC_HI = 0xec;
31
37
  const MAGIC_LO = 0x01; // format version 1
@@ -45,6 +51,12 @@ const SIZE_MEDIUM = 32768;
45
51
  function header(ver, tag) {
46
52
  return Buffer.from([MAGIC_HI, MAGIC_LO, ver, tag]);
47
53
  }
54
+ /** Clamp a value to the [0, 1] range (pressure bands). */
55
+ function clamp01(n) {
56
+ if (Number.isNaN(n))
57
+ return 0;
58
+ return n < 0 ? 0 : n > 1 ? 1 : n;
59
+ }
48
60
  /**
49
61
  * Compress synchronously using the best zlib tier for the payload size.
50
62
  *
@@ -54,21 +66,36 @@ function header(ver, tag) {
54
66
  * 4KB–32KB → gzip level 6 (tag 0x02)
55
67
  * > 32 KB → brotli 4 (tag 0x05)
56
68
  *
57
- * Writes the versioned header so readers disambiguate from legacy blobs.
69
+ * `pressure` (0–1, optional) escalates the brotli quality for the large tier
70
+ * when the session is near its context limit — the "variable compression as we
71
+ * approach the limit" design (Fix E). Low/undefined pressure keeps brotli-4;
72
+ * high pressure pushes toward brotli-11. Stays fully synchronous (brotli-11 is
73
+ * sync via brotliCompressSync) so the sync `add()` contract is preserved; zstd
74
+ * is reserved for the async DR-export path only. Same versioned header/tags for
75
+ * every pressure, so decompressSmart is unaffected.
58
76
  */
59
- export function compressSmart(data) {
77
+ export function compressSmart(data, pressure = 0) {
78
+ const p = clamp01(pressure);
60
79
  const len = data.length;
61
80
  if (len < SIZE_TINY) {
62
81
  return Buffer.concat([header(1, TAG_RAW), data]);
63
82
  }
64
83
  if (len < SIZE_SMALL) {
65
- return Buffer.concat([header(1, TAG_GZIP_1), gzipSync(data, { level: 1 })]);
84
+ // Small tier: escalate gzip level 1 → 9 with context pressure (Fix E) so
85
+ // the "variable compression as we approach the limit" dial bites for
86
+ // short sessions too, not just the >32KB brotli tier.
87
+ const level = Math.max(1, Math.min(9, Math.round(1 + 8 * p)));
88
+ return Buffer.concat([header(1, TAG_GZIP_1), gzipSync(data, { level })]);
66
89
  }
67
90
  if (len < SIZE_MEDIUM) {
68
- return Buffer.concat([header(1, TAG_GZIP_6), gzipSync(data, { level: 6 })]);
91
+ // Medium tier: escalate gzip level 6 → 9 with context pressure (Fix E).
92
+ const level = Math.max(6, Math.min(9, Math.round(6 + 3 * p)));
93
+ return Buffer.concat([header(1, TAG_GZIP_6), gzipSync(data, { level })]);
69
94
  }
95
+ // Large tier: escalate brotli quality 4 → 11 with context pressure (Fix E).
96
+ const quality = Math.max(4, Math.min(11, Math.round(4 + 7 * p)));
70
97
  const compressed = brotliCompressSync(data, {
71
- params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
98
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: quality },
72
99
  });
73
100
  return Buffer.concat([header(1, TAG_BROTLI_4), compressed]);
74
101
  }
@@ -141,6 +168,17 @@ export function decompressSmart(buf) {
141
168
  const ZSTD_MAGIC_HI = 0x5a; // 'Z'
142
169
  const ZSTD_MAGIC_LO = 0x53; // 'S'
143
170
  async function compressZstdWithLevel(data, level) {
171
+ // Lazy import: the native addon may be absent (clean/allowScripts install).
172
+ // Throws a clear, actionable error instead of a load-time crash.
173
+ let zstd;
174
+ try {
175
+ zstd = await import("@mongodb-js/zstd");
176
+ }
177
+ catch {
178
+ throw new Error("zstd is not available — the @mongodb-js/zstd native addon (zstd.node) " +
179
+ "was not built. Run the extension's native install step (or allow npm " +
180
+ "install scripts) to enable DR-export compression.");
181
+ }
144
182
  const compressed = await zstd.compress(data, level);
145
183
  return Buffer.concat([Buffer.from([ZSTD_MAGIC_HI, ZSTD_MAGIC_LO]), compressed]);
146
184
  }
@@ -163,6 +201,8 @@ export async function decompressZstd(buf) {
163
201
  if (!isZstd(buf)) {
164
202
  throw new Error("decompressZstd: buffer is not a zstd blob (missing ZS marker)");
165
203
  }
204
+ // Lazy import (see compressZstdWithLevel for rationale).
205
+ const zstd = await import("@mongodb-js/zstd");
166
206
  return zstd.decompress(buf.subarray(2));
167
207
  }
168
208
  /**
@@ -65,3 +65,51 @@ test("zstd helper roundtrips (async) and is not sync-decoded", async () => {
65
65
  assert.equal(auto.isZstd, true, "flagged as zstd");
66
66
  assert.deepEqual(await decompressZstd(c), data, "zstd roundtrip");
67
67
  });
68
+ test("module loads without a top-level zstd import (Fix A: no load crash)", async () => {
69
+ // The extension must load even when the @mongodb-js/zstd native addon is
70
+ // absent (clean/allowScripts-blocked install). The dynamic import() lives
71
+ // inside the helpers, so importing this module must never throw.
72
+ const mod = await import("./compression.js");
73
+ assert.equal(typeof mod.compressSmart, "function", "compressSmart exported");
74
+ assert.equal(typeof mod.compressZstd, "function", "compressZstd exported");
75
+ // The real invariant: no STATIC `import ... from "@mongodb-js/zstd"` at the
76
+ // top level (that's what crashed the whole extension). zstd must be loaded
77
+ // lazily inside the helpers only. Check the source text.
78
+ const { readFileSync } = await import("node:fs");
79
+ const { join } = await import("node:path");
80
+ // Tests run with cwd at repo root (`node --test`), so resolve the source.
81
+ const src = readFileSync(join(process.cwd(), "src/store/compression.ts"), "utf-8");
82
+ const staticImport = /^import\s+.+\s+from\s+["']@mongodb-js\/zstd["'];?$/m;
83
+ assert.equal(staticImport.test(src), false, "no static top-level import of @mongodb-js/zstd (would crash load if binary absent)");
84
+ assert.ok(src.includes('await import("@mongodb-js/zstd")'), "zstd is loaded lazily via dynamic import() inside the helpers");
85
+ });
86
+ test("compressSmart escalates brotli quality with pressure (Fix E)", () => {
87
+ // Large (>32KB) payloads hit the brotli tier; higher pressure → brotli-11
88
+ // → smaller output than the default brotli-4, and still decodes.
89
+ const words = Array.from({ length: 6000 }, (_, i) => "word" + ((i * 2654435761) % 9973));
90
+ const big = Buffer.from(words.join(" "));
91
+ const low = compressSmart(big, 0);
92
+ const high = compressSmart(big, 1);
93
+ assert.equal(isVersioned(low), true, "versioned header preserved at p=0");
94
+ assert.equal(isVersioned(high), true, "versioned header preserved at p=1");
95
+ assert.ok(high.length < low.length, "high pressure compresses smaller");
96
+ assert.deepEqual(decompressSmart(low), big, "p=0 roundtrip");
97
+ assert.deepEqual(decompressSmart(high), big, "p=1 roundtrip");
98
+ // Small payloads ignore pressure (gzip tier) but still roundtrip.
99
+ const small = buf("hello world ", 300);
100
+ assert.deepEqual(decompressSmart(compressSmart(small, 1)), small, "small ignores pressure");
101
+ // pressure out of range is clamped (no throw, still versioned + decodable).
102
+ assert.deepEqual(decompressSmart(compressSmart(big, 5)), big, "over-pressure clamped");
103
+ assert.deepEqual(decompressSmart(compressSmart(big, -1)), big, "under-pressure clamped");
104
+ });
105
+ test("pressureFromPct + preserveRecentForPressure scale with context (Fix E)", async () => {
106
+ const { pressureFromPct, preserveRecentForPressure } = await import("../config.js");
107
+ assert.equal(pressureFromPct(50), 0.5, "pct→pressure");
108
+ assert.equal(pressureFromPct(null), 0, "null pct → 0");
109
+ assert.equal(pressureFromPct(150), 1, "pct clamped");
110
+ // low pressure keeps preserveRecent; high pressure compacts deeper (min floor).
111
+ assert.equal(preserveRecentForPressure(0, 4, 2), 4, "p=0 → preserveRecent");
112
+ assert.equal(preserveRecentForPressure(1, 4, 2), 2, "p=1 → preserveRecentMin");
113
+ assert.equal(preserveRecentForPressure(0.5, 4, 2), 3, "p=0.5 → interpolates");
114
+ assert.ok(preserveRecentForPressure(1, 4, 2) >= 2, "never below floor");
115
+ });
@@ -2,11 +2,13 @@
2
2
  * sqlite.ts — Sprint 8 storage backbone (the "one store").
3
3
  *
4
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.
5
+ * SQLite database (node:sqlite — the Node built-in, in-process, FS-backed,
6
+ * ZERO network calls — honors PREVENT-PI-004). No native build and no install
7
+ * scripts, so it survives pi's npm blocked-install-scripts gate (better-sqlite3's
8
+ * native binary could not be built under pi, which crashed every `pi update
9
+ * --extensions`). node:sqlite is synchronous, so every VectorStore signature
10
+ * stays sync. PGlite + pgvector (async) is layered on in vectorIndex.ts for
11
+ * real HNSW indexing (Slice 2 of the dual-backend plan).
10
12
  *
11
13
  * FTS5 `trigram` tokenizer is created for the Sprint 9+ dedup tiers (MinHash/LSH
12
14
  * / pg_trgm-equivalent verification). The default cosine path stays a linear
@@ -14,7 +16,7 @@
14
16
  *
15
17
  * All queries are parameterized (PREVENT-002) — never string-concatenated.
16
18
  */
17
- import Database from "better-sqlite3";
19
+ import { DatabaseSync } from "node:sqlite";
18
20
  import { existsSync, mkdirSync } from "node:fs";
19
21
  import { homedir, tmpdir } from "node:os";
20
22
  import { join } from "node:path";
@@ -28,14 +30,17 @@ function encodeEmbedding(v) {
28
30
  buf.writeFloatLE(v[i] ?? 0, i * 4);
29
31
  return buf;
30
32
  }
31
- /** Decode a Float32 BLOB back to a number[]. */
33
+ /** Decode a Float32 BLOB back to a number[]. node:sqlite returns BLOBs as
34
+ * Uint8Array, so decode via DataView (Buffer is a Uint8Array subclass — both
35
+ * work). */
32
36
  function decodeEmbedding(buf) {
33
37
  if (!buf || buf.length === 0)
34
38
  return [];
39
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
35
40
  const n = buf.length / 4;
36
41
  const out = new Array(n);
37
42
  for (let i = 0; i < n; i++)
38
- out[i] = buf.readFloatLE(i * 4);
43
+ out[i] = dv.getFloat32(i * 4, true);
39
44
  return out;
40
45
  }
41
46
  function jsonText(v) {
@@ -52,9 +57,9 @@ export function openStore(stateDir = getStateDir()) {
52
57
  return existing;
53
58
  if (!existsSync(stateDir))
54
59
  mkdirSync(stateDir, { recursive: true });
55
- const db = new Database(join(stateDir, "sqlite.db"));
56
- db.pragma("journal_mode = WAL");
57
- db.pragma("foreign_keys = ON");
60
+ const db = new DatabaseSync(join(stateDir, "sqlite.db"));
61
+ db.exec("PRAGMA journal_mode = WAL");
62
+ db.exec("PRAGMA foreign_keys = ON");
58
63
  initSchema(db);
59
64
  cache.set(stateDir, db);
60
65
  return db;
@@ -91,10 +96,10 @@ export function openIndexStore(indexDir = getIndexDir()) {
91
96
  return indexCache;
92
97
  if (!existsSync(indexDir))
93
98
  mkdirSync(indexDir, { recursive: true });
94
- const db = new Database(join(indexDir, "index.sqlite"));
95
- db.pragma("journal_mode = WAL");
96
- db.pragma("busy_timeout = 3000"); // tolerate brief cross-process write contention
97
- db.exec(`
99
+ const iddb = new DatabaseSync(join(indexDir, "index.sqlite"));
100
+ iddb.exec("PRAGMA journal_mode = WAL");
101
+ iddb.exec("PRAGMA busy_timeout = 3000"); // tolerate brief cross-process write contention
102
+ iddb.exec(`
98
103
  CREATE TABLE IF NOT EXISTS repo_registry (
99
104
  repo_root TEXT PRIMARY KEY,
100
105
  display_name TEXT,
@@ -114,9 +119,9 @@ export function openIndexStore(indexDir = getIndexDir()) {
114
119
  );
115
120
  CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
116
121
  `);
117
- indexCache = db;
122
+ indexCache = iddb;
118
123
  indexCacheDir = indexDir;
119
- return db;
124
+ return iddb;
120
125
  }
121
126
  /**
122
127
  * Upsert a repo's aggregate stats into the global index. Called on repo-switch
@@ -359,6 +364,20 @@ function initSchema(db) {
359
364
  ts INTEGER
360
365
  );
361
366
 
367
+ -- Durable "save to memory" store (taken over from memory extensions).
368
+ -- One row per saved memory; scoped by repo so memory travels with the
369
+ -- clone. All params are parameterized (PREVENT-002).
370
+ CREATE TABLE IF NOT EXISTS memories (
371
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
372
+ repo TEXT,
373
+ kind TEXT DEFAULT 'note', -- note | fact | decision | preference
374
+ content TEXT NOT NULL,
375
+ tags TEXT, -- JSON array of strings
376
+ created_at INTEGER,
377
+ last_recalled_at INTEGER
378
+ );
379
+ CREATE INDEX IF NOT EXISTS idx_memories_repo ON memories(repo);
380
+
362
381
  -- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
363
382
  CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
364
383
  id UNINDEXED,
@@ -476,6 +495,69 @@ export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
476
495
  const now = Math.floor(Date.now() / 1000);
477
496
  db.prepare(`INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
478
497
  }
498
+ /** Save a memory to the current repo's store. Returns the new row id. */
499
+ export function addMemory(memory, repo, stateDir = getStateDir()) {
500
+ const db = openStore(stateDir);
501
+ const now = Math.floor(Date.now() / 1000);
502
+ const res = db
503
+ .prepare(`INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
504
+ VALUES(?, ?, ?, ?, ?, NULL)`)
505
+ .run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
506
+ return Number(res.lastInsertRowid);
507
+ }
508
+ /** List recent memories for a repo (or all repos when repo is null). */
509
+ export function listMemories(repo, limit = 50, stateDir = getStateDir()) {
510
+ const db = openStore(stateDir);
511
+ const rows = repo
512
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
513
+ : db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
514
+ return rows.map(mapMemoryRow);
515
+ }
516
+ /** Substring search across content + tags. */
517
+ export function searchMemories(query, repo = null, limit = 50, stateDir = getStateDir()) {
518
+ const db = openStore(stateDir);
519
+ const like = `%${query}%`;
520
+ const rows = repo
521
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
522
+ : db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
523
+ return rows.map(mapMemoryRow);
524
+ }
525
+ /** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
526
+ export function recallMemory(id, stateDir = getStateDir()) {
527
+ const db = openStore(stateDir);
528
+ const now = Math.floor(Date.now() / 1000);
529
+ const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
530
+ return res.changes > 0;
531
+ }
532
+ function mapMemoryRow(row) {
533
+ return {
534
+ id: row.id,
535
+ repo: row.repo ?? null,
536
+ kind: row.kind ?? "note",
537
+ content: row.content ?? "",
538
+ tags: row.tags ? JSON.parse(row.tags) : [],
539
+ createdAt: row.created_at ?? 0,
540
+ lastRecalledAt: row.last_recalled_at ?? null,
541
+ };
542
+ }
543
+ /**
544
+ * Run `fn` atomically. Uses SAVEPOINT so it nests safely under an outer
545
+ * transaction (unlike `BEGIN`, which SQLite rejects when one is already open).
546
+ * Mirrors better-sqlite3's `db.transaction(fn)` semantics — callers that wrap a
547
+ * batch in withTx (e.g. backfill) can still call helpers that also use withTx.
548
+ */
549
+ export function withTx(db, fn) {
550
+ db.exec("SAVEPOINT mc_tx");
551
+ try {
552
+ fn();
553
+ db.exec("RELEASE mc_tx");
554
+ }
555
+ catch (e) {
556
+ db.exec("ROLLBACK TO mc_tx");
557
+ db.exec("RELEASE mc_tx");
558
+ throw e;
559
+ }
560
+ }
479
561
  /** Map a DB row to the public StoredCheckpoint shape. */
480
562
  function rowToCheckpoint(row) {
481
563
  return {
@@ -494,7 +576,9 @@ function rowToCheckpoint(row) {
494
576
  contentHash2: row.content_hash2 ?? undefined,
495
577
  contentHashVersion: row.content_hash_version ?? undefined,
496
578
  normalizedText: row.normalized_text ?? undefined,
497
- compressedOriginal: row.compressed_original ?? undefined,
579
+ // node:sqlite returns BLOBs as Uint8Array; normalize to Buffer so callers
580
+ // (e.g. decompressSmart → Buffer.toString) behave as under better-sqlite3.
581
+ compressedOriginal: row.compressed_original ? Buffer.from(row.compressed_original) : undefined,
498
582
  embedding: decodeEmbedding(row.embedding_blob),
499
583
  timestamp: Number(row.timestamp ?? 0),
500
584
  dedupStatus: row.dedup_status ?? undefined,
@@ -504,7 +588,7 @@ function rowToCheckpoint(row) {
504
588
  export function upsertCheckpoint(cp, stateDir = getStateDir()) {
505
589
  const db = openStore(stateDir);
506
590
  const sid = normalizeSessionId(cp.sessionId);
507
- const tx = db.transaction(() => {
591
+ withTx(db, () => {
508
592
  db.prepare(`INSERT INTO context_chunks
509
593
  (id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
510
594
  normalized_text, summary, topic_summary, summary_hash,
@@ -527,25 +611,25 @@ export function upsertCheckpoint(cp, stateDir = getStateDir()) {
527
611
  timestamp=excluded.timestamp,
528
612
  dedup_status=excluded.dedup_status,
529
613
  compressed_original=excluded.compressed_original`).run({
530
- id: cp.checkpointId,
531
- sid,
532
- region_hash: cp.regionHash ?? null,
533
- content_hash: cp.contentHash ?? null,
534
- content_hash2: cp.contentHash2 ?? null,
535
- content_hash_version: cp.contentHashVersion ?? null,
536
- normalized_text: cp.normalizedText ?? null,
537
- summary: cp.summary ?? "",
538
- topic_summary: cp.topicSummary ?? null,
539
- summary_hash: cp.summaryHash ?? null,
540
- key_decisions: jsonText(cp.keyDecisions),
541
- next_steps: jsonText(cp.nextSteps),
542
- files_modified: jsonText(cp.filesModified),
543
- embedding_blob: encodeEmbedding(cp.embedding ?? []),
544
- token_estimate: cp.tokenEstimate ?? 0,
545
- original_token_estimate: cp.originalTokenEstimate ?? null,
546
- timestamp: cp.timestamp ?? 0,
547
- dedup_status: "active",
548
- compressed_original: cp.compressedOriginal ?? null,
614
+ "@id": cp.checkpointId,
615
+ "@sid": sid,
616
+ "@region_hash": cp.regionHash ?? null,
617
+ "@content_hash": cp.contentHash ?? null,
618
+ "@content_hash2": cp.contentHash2 ?? null,
619
+ "@content_hash_version": cp.contentHashVersion ?? null,
620
+ "@normalized_text": cp.normalizedText ?? null,
621
+ "@summary": cp.summary ?? "",
622
+ "@topic_summary": cp.topicSummary ?? null,
623
+ "@summary_hash": cp.summaryHash ?? null,
624
+ "@key_decisions": jsonText(cp.keyDecisions),
625
+ "@next_steps": jsonText(cp.nextSteps),
626
+ "@files_modified": jsonText(cp.filesModified),
627
+ "@embedding_blob": encodeEmbedding(cp.embedding ?? []),
628
+ "@token_estimate": cp.tokenEstimate ?? 0,
629
+ "@original_token_estimate": cp.originalTokenEstimate ?? null,
630
+ "@timestamp": cp.timestamp ?? 0,
631
+ "@dedup_status": "active",
632
+ "@compressed_original": cp.compressedOriginal ?? null,
549
633
  });
550
634
  // FTS5 virtual tables don't support UPSERT — delete any prior row, reinsert.
551
635
  // Store normalized_text (the L1 verify key); fall back to summary for rows
@@ -553,7 +637,6 @@ export function upsertCheckpoint(cp, stateDir = getStateDir()) {
553
637
  db.prepare("DELETE FROM context_chunks_trgm WHERE id = ?").run(cp.checkpointId);
554
638
  db.prepare("INSERT INTO context_chunks_trgm(id, normalized_text) VALUES(?, ?)").run(cp.checkpointId, cp.normalizedText ?? cp.summary ?? "");
555
639
  });
556
- tx();
557
640
  }
558
641
  // --- Sprint 11: MinHash signatures + LSH buckets --------------------------
559
642
  /** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
@@ -571,12 +654,11 @@ export function insertLshBuckets(chunkId, sessionId, signatureVersion, bucketKey
571
654
  const sid = normalizeSessionId(sessionId);
572
655
  const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
573
656
  const ins = db.prepare("INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)");
574
- const tx = db.transaction(() => {
657
+ withTx(db, () => {
575
658
  del.run(chunkId);
576
659
  for (const key of bucketKeys)
577
660
  ins.run(key, chunkId, sid, signatureVersion);
578
661
  });
579
- tx();
580
662
  }
581
663
  /**
582
664
  * Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
@@ -109,6 +109,25 @@ test("compression tier: GZIP-1 and GZIP-6 tiers produce valid, smaller output",
109
109
  assert.ok(big.length < Buffer.from("compress me ".repeat(1800)).length, "GZIP-6 output smaller than input");
110
110
  assert.deepEqual(decompressSmart(big), Buffer.from("compress me ".repeat(1800)));
111
111
  });
112
+ test("Fix E: pressure escalates gzip tier strength (sync, no zstd)", () => {
113
+ const small = Buffer.from("compress me ".repeat(200)); // 512B–4KB band
114
+ const medium = Buffer.from("compress me ".repeat(1800)); // 4KB–32KB band
115
+ // Low pressure → cheap levels (gzip-1 / gzip-6).
116
+ const lowSmall = compressSmart(small, 0);
117
+ const lowMedium = compressSmart(medium, 0);
118
+ assert.equal(lowSmall[3], 0x01, "small tier tag");
119
+ assert.equal(lowMedium[3], 0x02, "medium tier tag");
120
+ assert.ok(decompressSmart(lowSmall).equals(small), "low-pressure small roundtrips");
121
+ assert.ok(decompressSmart(lowMedium).equals(medium), "low-pressure medium roundtrips");
122
+ // High pressure → stronger levels (gzip-9 / gzip-9); tag unchanged, output
123
+ // must still decode to the exact original (versioned header preserved).
124
+ const highSmall = compressSmart(small, 1);
125
+ const highMedium = compressSmart(medium, 1);
126
+ assert.equal(highSmall[3], 0x01, "tag unchanged under pressure");
127
+ assert.equal(highMedium[3], 0x02, "tag unchanged under pressure");
128
+ assert.ok(decompressSmart(highSmall).equals(small), "high-pressure small roundtrips");
129
+ assert.ok(decompressSmart(highMedium).equals(medium), "high-pressure medium roundtrips");
130
+ });
112
131
  // ---------------------------------------------------------------------------
113
132
  // normalizeSessionId
114
133
  // ---------------------------------------------------------------------------
@@ -20,6 +20,8 @@ import { mmrRerank } from "./dedup/mmr.js";
20
20
  import { topK } from "./dedup/topk.js";
21
21
  import { openBloom, saveBloom } from "./store/bloom.js";
22
22
  import { listCheckpoints, nextCheckpointId, upsertCheckpoint, loadSessionState, saveSessionState, upsertMinhashSignature, insertLshBuckets, lshCandidateChunks, setDedupStatus, addTokensSaved, getDedupStats, bumpDedupStats, repoStats as repoStatsFromStore, dataInvariantStats, } from "./store/sqlite.js";
23
+ import { rehydrateRaptorTree } from "./dedup/raptor/index.js";
24
+ import { stagedExpansion } from "./dedup/raptor/retrieval.js";
23
25
  import { migrateJsonToSqlite } from "./store/migrate.js";
24
26
  /** Default L2 semantic-dedup enable flag (trigram embedder is local, zero-network). */
25
27
  export const L2_ENABLED = true;
@@ -242,7 +244,7 @@ export class VectorStore {
242
244
  contentHash2: digest.contentHash2,
243
245
  contentHashVersion: digest.contentHashVersion,
244
246
  normalizedText: digest.normalizedText,
245
- compressedOriginal: compressSmart(Buffer.from(input.regionText, "utf-8")),
247
+ compressedOriginal: compressSmart(Buffer.from(input.regionText, "utf-8"), input.compressionPressure),
246
248
  embedding,
247
249
  timestamp: input.timestamp,
248
250
  };
@@ -339,6 +341,27 @@ export class VectorStore {
339
341
  // (Sprint 14 flag), returning the plain relevance-ranked window instead.
340
342
  if (!this.cfg.L2_ENABLED)
341
343
  return window.slice(0, k);
344
+ // Fix D: when RAPTOR is promoted, ALSO recall high-level tree summaries and
345
+ // merge them with the flat hits via MMR so RAPTOR + flat don't double-cover.
346
+ // RAPTOR returns fewer, broader hits (O(log n) high-level nodes) than the
347
+ // O(n) flat leaves, tightening the block at read time.
348
+ if (this.cfg.RAPTOR_ENABLED) {
349
+ const raptorHits = this.raptorSearchHits(sid, query, k);
350
+ if (raptorHits.length > 0) {
351
+ const merged = [...window];
352
+ for (const rh of raptorHits) {
353
+ if (!merged.some((m) => m.checkpoint.checkpointId === rh.checkpoint.checkpointId)) {
354
+ merged.push(rh);
355
+ }
356
+ }
357
+ const mmrItems = merged.map((h) => ({
358
+ item: h,
359
+ vector: h.checkpoint.embedding,
360
+ relevance: h.score,
361
+ }));
362
+ return mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
363
+ }
364
+ }
342
365
  const mmrItems = window.map((h) => ({
343
366
  item: h,
344
367
  vector: h.checkpoint.embedding,
@@ -347,6 +370,38 @@ export class VectorStore {
347
370
  const ranked = mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
348
371
  return ranked;
349
372
  }
373
+ /**
374
+ * Serve the RAPTOR tree for a query (Fix D): rehydrate the persisted tree and
375
+ * return its staged-expansion leaf hits as SearchHits. Returns [] when no tree
376
+ * exists (small sessions — flat search remains the path). Best-effort/non-fatal.
377
+ */
378
+ raptorSearchHits(sid, query, k) {
379
+ try {
380
+ const tree = rehydrateRaptorTree(sid, this.stateDir);
381
+ if (!tree || !tree.rootId)
382
+ return [];
383
+ const leafIds = stagedExpansion(query, tree, {
384
+ embedder: this.embedder,
385
+ k,
386
+ topM: this.cfg.RAPTOR_CLUSTERS_PER_LEVEL,
387
+ mmrLambda: this.cfg.MMR_LAMBDA,
388
+ });
389
+ if (leafIds.length === 0)
390
+ return [];
391
+ const all = listCheckpoints(sid, this.stateDir).filter((cp) => cp.dedupStatus !== "removed");
392
+ const qv = this.embedder.embed(query);
393
+ const hits = [];
394
+ for (const id of leafIds) {
395
+ const cp = all.find((c) => c.checkpointId === id);
396
+ if (cp)
397
+ hits.push({ checkpoint: cp, score: cosineSimilarity(qv, cp.embedding) });
398
+ }
399
+ return hits;
400
+ }
401
+ catch {
402
+ return [];
403
+ }
404
+ }
350
405
  /**
351
406
  * SemDeDup offline cleanup (Sprint 12, QA #17): within a session, mark the
352
407
  * lower-quality row of any pair scoring cosine > `threshold` as
@@ -2,8 +2,8 @@
2
2
 
3
3
  A lightweight local web dashboard for monitoring mega-compact's live state — compactions, context usage, checkpoints, and recall hits.
4
4
 
5
- Uses Node built-in modules (`http`, `fs`, `path`) plus `better-sqlite3` (the
6
- project's one-store DB backend) to read the machine-wide multi-repo index.
5
+ Uses Node built-in modules (`http`, `fs`, `path`, `node:sqlite` the project's
6
+ one-store DB backend) to read the machine-wide multi-repo index.
7
7
 
8
8
  ## Quick Start
9
9
 
@@ -74,7 +74,7 @@ The server runs as a detached child process, independent of the pi session. It:
74
74
  - Auto-discovers the state directory from the `port.pid` file
75
75
  - Cleans up stale `port.pid` files from dead processes
76
76
  - Supports `SIGTERM`/`SIGINT` for graceful shutdown
77
- - Serves static HTML; reads the multi-repo index from SQLite (`better-sqlite3`)
77
+ - Serves static HTML; reads the multi-repo index from SQLite (`node:sqlite`)
78
78
 
79
79
  ## Browser UI
80
80