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
@@ -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
@@ -15,7 +17,7 @@
15
17
  * All queries are parameterized (PREVENT-002) — never string-concatenated.
16
18
  */
17
19
 
18
- import Database from "better-sqlite3";
20
+ import { DatabaseSync } from "node:sqlite";
19
21
  import { existsSync, mkdirSync } from "node:fs";
20
22
  import { homedir, tmpdir } from "node:os";
21
23
  import { join } from "node:path";
@@ -31,12 +33,15 @@ function encodeEmbedding(v: number[]): Buffer {
31
33
  for (let i = 0; i < v.length; i++) buf.writeFloatLE(v[i] ?? 0, i * 4);
32
34
  return buf;
33
35
  }
34
- /** Decode a Float32 BLOB back to a number[]. */
35
- function decodeEmbedding(buf: Buffer | null | undefined): number[] {
36
+ /** Decode a Float32 BLOB back to a number[]. node:sqlite returns BLOBs as
37
+ * Uint8Array, so decode via DataView (Buffer is a Uint8Array subclass both
38
+ * work). */
39
+ function decodeEmbedding(buf: Uint8Array | null | undefined): number[] {
36
40
  if (!buf || buf.length === 0) return [];
41
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
37
42
  const n = buf.length / 4;
38
43
  const out = new Array<number>(n);
39
- for (let i = 0; i < n; i++) out[i] = buf.readFloatLE(i * 4);
44
+ for (let i = 0; i < n; i++) out[i] = dv.getFloat32(i * 4, true);
40
45
  return out;
41
46
  }
42
47
 
@@ -47,17 +52,17 @@ function jsonText(v: unknown): string {
47
52
  // In-process cache so the same stateDir reuses one connection (and so a fresh
48
53
  // VectorStore over the same dir shares the open DB). Cross-process durability
49
54
  // comes from reopening the same file path — proven by the integration test.
50
- const cache = new Map<string, Database.Database>();
55
+ const cache = new Map<string, DatabaseSync>();
51
56
 
52
57
  /** Open (or reuse) the SQLite store for a state dir. */
53
- export function openStore(stateDir: string = getStateDir()): Database.Database {
58
+ export function openStore(stateDir: string = getStateDir()): DatabaseSync {
54
59
  const existing = cache.get(stateDir);
55
60
  if (existing) return existing;
56
61
 
57
62
  if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
58
- const db = new Database(join(stateDir, "sqlite.db"));
59
- db.pragma("journal_mode = WAL");
60
- db.pragma("foreign_keys = ON");
63
+ const db = new DatabaseSync(join(stateDir, "sqlite.db"));
64
+ db.exec("PRAGMA journal_mode = WAL");
65
+ db.exec("PRAGMA foreign_keys = ON");
61
66
  initSchema(db);
62
67
  cache.set(stateDir, db);
63
68
  return db;
@@ -87,17 +92,17 @@ export function getIndexDir(): string {
87
92
  }
88
93
  }
89
94
 
90
- let indexCache: Database.Database | undefined;
95
+ let indexCache: DatabaseSync | undefined;
91
96
  let indexCacheDir: string | undefined;
92
97
 
93
98
  /** Open (or reuse) the machine-wide index DB. WAL for concurrent writers. */
94
- export function openIndexStore(indexDir: string = getIndexDir()): Database.Database {
99
+ export function openIndexStore(indexDir: string = getIndexDir()): DatabaseSync {
95
100
  if (indexCache && indexCacheDir === indexDir) return indexCache;
96
101
  if (!existsSync(indexDir)) mkdirSync(indexDir, { recursive: true });
97
- const db = new Database(join(indexDir, "index.sqlite"));
98
- db.pragma("journal_mode = WAL");
99
- db.pragma("busy_timeout = 3000"); // tolerate brief cross-process write contention
100
- db.exec(`
102
+ const iddb = new DatabaseSync(join(indexDir, "index.sqlite"));
103
+ iddb.exec("PRAGMA journal_mode = WAL");
104
+ iddb.exec("PRAGMA busy_timeout = 3000"); // tolerate brief cross-process write contention
105
+ iddb.exec(`
101
106
  CREATE TABLE IF NOT EXISTS repo_registry (
102
107
  repo_root TEXT PRIMARY KEY,
103
108
  display_name TEXT,
@@ -117,9 +122,9 @@ export function openIndexStore(indexDir: string = getIndexDir()): Database.Datab
117
122
  );
118
123
  CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
119
124
  `);
120
- indexCache = db;
125
+ indexCache = iddb;
121
126
  indexCacheDir = indexDir;
122
- return db;
127
+ return iddb;
123
128
  }
124
129
 
125
130
  /** One row of the global repo registry (multi-repo dashboard source). */
@@ -276,7 +281,7 @@ export function closeIndexStore(): void {
276
281
  }
277
282
  }
278
283
 
279
- function initSchema(db: Database.Database): void {
284
+ function initSchema(db: DatabaseSync): void {
280
285
  db.exec(`
281
286
  CREATE TABLE IF NOT EXISTS context_chunks (
282
287
  id TEXT NOT NULL,
@@ -415,6 +420,20 @@ function initSchema(db: Database.Database): void {
415
420
  ts INTEGER
416
421
  );
417
422
 
423
+ -- Durable "save to memory" store (taken over from memory extensions).
424
+ -- One row per saved memory; scoped by repo so memory travels with the
425
+ -- clone. All params are parameterized (PREVENT-002).
426
+ CREATE TABLE IF NOT EXISTS memories (
427
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
428
+ repo TEXT,
429
+ kind TEXT DEFAULT 'note', -- note | fact | decision | preference
430
+ content TEXT NOT NULL,
431
+ tags TEXT, -- JSON array of strings
432
+ created_at INTEGER,
433
+ last_recalled_at INTEGER
434
+ );
435
+ CREATE INDEX IF NOT EXISTS idx_memories_repo ON memories(repo);
436
+
418
437
  -- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
419
438
  CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
420
439
  id UNINDEXED,
@@ -443,7 +462,7 @@ function initSchema(db: Database.Database): void {
443
462
  * input), so the unavoidable identifier interpolation here does not violate
444
463
  * PREVENT-002 (no external data reaches this SQL).
445
464
  */
446
- function ensureColumn(db: Database.Database, table: string, column: string, decl: string): void {
465
+ function ensureColumn(db: DatabaseSync, table: string, column: string, decl: string): void {
447
466
  const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
448
467
  if (cols.some((c) => c.name === column)) return;
449
468
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
@@ -582,6 +601,94 @@ export function addLesson(
582
601
  ).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
583
602
  }
584
603
 
604
+ // --- Durable memory (save-to-memory takeover) ---------------------------------
605
+ // One SQLite store for user-saved memories, scoped by repo. Mirrors the
606
+ // lessons/sessions pattern: all state lives in SQLite from day one.
607
+
608
+ export interface MemoryRecord {
609
+ id: number;
610
+ repo: string | null;
611
+ kind: string;
612
+ content: string;
613
+ tags: string[];
614
+ createdAt: number;
615
+ lastRecalledAt: number | null;
616
+ }
617
+
618
+ /** Save a memory to the current repo's store. Returns the new row id. */
619
+ export function addMemory(
620
+ memory: { kind?: string; content: string; tags?: string[] },
621
+ repo: string | null,
622
+ stateDir: string = getStateDir(),
623
+ ): number {
624
+ const db = openStore(stateDir);
625
+ const now = Math.floor(Date.now() / 1000);
626
+ const res = db
627
+ .prepare(
628
+ `INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
629
+ VALUES(?, ?, ?, ?, ?, NULL)`,
630
+ )
631
+ .run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
632
+ return Number(res.lastInsertRowid);
633
+ }
634
+
635
+ /** List recent memories for a repo (or all repos when repo is null). */
636
+ export function listMemories(repo: string | null, limit = 50, stateDir: string = getStateDir()): MemoryRecord[] {
637
+ const db = openStore(stateDir);
638
+ const rows = repo
639
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
640
+ : db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
641
+ return (rows as any[]).map(mapMemoryRow);
642
+ }
643
+
644
+ /** Substring search across content + tags. */
645
+ export function searchMemories(query: string, repo: string | null = null, limit = 50, stateDir: string = getStateDir()): MemoryRecord[] {
646
+ const db = openStore(stateDir);
647
+ const like = `%${query}%`;
648
+ const rows = repo
649
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
650
+ : db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
651
+ return (rows as any[]).map(mapMemoryRow);
652
+ }
653
+
654
+ /** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
655
+ export function recallMemory(id: number, stateDir: string = getStateDir()): boolean {
656
+ const db = openStore(stateDir);
657
+ const now = Math.floor(Date.now() / 1000);
658
+ const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
659
+ return res.changes > 0;
660
+ }
661
+
662
+ function mapMemoryRow(row: any): MemoryRecord {
663
+ return {
664
+ id: row.id,
665
+ repo: row.repo ?? null,
666
+ kind: row.kind ?? "note",
667
+ content: row.content ?? "",
668
+ tags: row.tags ? JSON.parse(row.tags) : [],
669
+ createdAt: row.created_at ?? 0,
670
+ lastRecalledAt: row.last_recalled_at ?? null,
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Run `fn` atomically. Uses SAVEPOINT so it nests safely under an outer
676
+ * transaction (unlike `BEGIN`, which SQLite rejects when one is already open).
677
+ * Mirrors better-sqlite3's `db.transaction(fn)` semantics — callers that wrap a
678
+ * batch in withTx (e.g. backfill) can still call helpers that also use withTx.
679
+ */
680
+ export function withTx(db: DatabaseSync, fn: () => void): void {
681
+ db.exec("SAVEPOINT mc_tx");
682
+ try {
683
+ fn();
684
+ db.exec("RELEASE mc_tx");
685
+ } catch (e) {
686
+ db.exec("ROLLBACK TO mc_tx");
687
+ db.exec("RELEASE mc_tx");
688
+ throw e;
689
+ }
690
+ }
691
+
585
692
  /** Map a DB row to the public StoredCheckpoint shape. */
586
693
  function rowToCheckpoint(row: any): StoredCheckpoint {
587
694
  return {
@@ -600,7 +707,9 @@ function rowToCheckpoint(row: any): StoredCheckpoint {
600
707
  contentHash2: row.content_hash2 ?? undefined,
601
708
  contentHashVersion: row.content_hash_version ?? undefined,
602
709
  normalizedText: row.normalized_text ?? undefined,
603
- compressedOriginal: row.compressed_original ?? undefined,
710
+ // node:sqlite returns BLOBs as Uint8Array; normalize to Buffer so callers
711
+ // (e.g. decompressSmart → Buffer.toString) behave as under better-sqlite3.
712
+ compressedOriginal: row.compressed_original ? Buffer.from(row.compressed_original) : undefined,
604
713
  embedding: decodeEmbedding(row.embedding_blob),
605
714
  timestamp: Number(row.timestamp ?? 0),
606
715
  dedupStatus: row.dedup_status ?? undefined,
@@ -611,7 +720,7 @@ function rowToCheckpoint(row: any): StoredCheckpoint {
611
720
  export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getStateDir()): void {
612
721
  const db = openStore(stateDir);
613
722
  const sid = normalizeSessionId(cp.sessionId);
614
- const tx = db.transaction(() => {
723
+ withTx(db, () => {
615
724
  db.prepare(
616
725
  `INSERT INTO context_chunks
617
726
  (id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
@@ -636,25 +745,25 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
636
745
  dedup_status=excluded.dedup_status,
637
746
  compressed_original=excluded.compressed_original`,
638
747
  ).run({
639
- id: cp.checkpointId,
640
- sid,
641
- region_hash: cp.regionHash ?? null,
642
- content_hash: cp.contentHash ?? null,
643
- content_hash2: cp.contentHash2 ?? null,
644
- content_hash_version: cp.contentHashVersion ?? null,
645
- normalized_text: cp.normalizedText ?? null,
646
- summary: cp.summary ?? "",
647
- topic_summary: cp.topicSummary ?? null,
648
- summary_hash: cp.summaryHash ?? null,
649
- key_decisions: jsonText(cp.keyDecisions),
650
- next_steps: jsonText(cp.nextSteps),
651
- files_modified: jsonText(cp.filesModified),
652
- embedding_blob: encodeEmbedding(cp.embedding ?? []),
653
- token_estimate: cp.tokenEstimate ?? 0,
654
- original_token_estimate: cp.originalTokenEstimate ?? null,
655
- timestamp: cp.timestamp ?? 0,
656
- dedup_status: "active",
657
- compressed_original: cp.compressedOriginal ?? null,
748
+ "@id": cp.checkpointId,
749
+ "@sid": sid,
750
+ "@region_hash": cp.regionHash ?? null,
751
+ "@content_hash": cp.contentHash ?? null,
752
+ "@content_hash2": cp.contentHash2 ?? null,
753
+ "@content_hash_version": cp.contentHashVersion ?? null,
754
+ "@normalized_text": cp.normalizedText ?? null,
755
+ "@summary": cp.summary ?? "",
756
+ "@topic_summary": cp.topicSummary ?? null,
757
+ "@summary_hash": cp.summaryHash ?? null,
758
+ "@key_decisions": jsonText(cp.keyDecisions),
759
+ "@next_steps": jsonText(cp.nextSteps),
760
+ "@files_modified": jsonText(cp.filesModified),
761
+ "@embedding_blob": encodeEmbedding(cp.embedding ?? []),
762
+ "@token_estimate": cp.tokenEstimate ?? 0,
763
+ "@original_token_estimate": cp.originalTokenEstimate ?? null,
764
+ "@timestamp": cp.timestamp ?? 0,
765
+ "@dedup_status": "active",
766
+ "@compressed_original": cp.compressedOriginal ?? null,
658
767
  });
659
768
 
660
769
  // FTS5 virtual tables don't support UPSERT — delete any prior row, reinsert.
@@ -665,7 +774,6 @@ export function upsertCheckpoint(cp: StoredCheckpoint, stateDir: string = getSta
665
774
  "INSERT INTO context_chunks_trgm(id, normalized_text) VALUES(?, ?)",
666
775
  ).run(cp.checkpointId, cp.normalizedText ?? cp.summary ?? "");
667
776
  });
668
- tx();
669
777
  }
670
778
 
671
779
  // --- Sprint 11: MinHash signatures + LSH buckets --------------------------
@@ -702,11 +810,10 @@ export function insertLshBuckets(
702
810
  const ins = db.prepare(
703
811
  "INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)",
704
812
  );
705
- const tx = db.transaction(() => {
813
+ withTx(db, () => {
706
814
  del.run(chunkId);
707
815
  for (const key of bucketKeys) ins.run(key, chunkId, sid, signatureVersion);
708
816
  });
709
- tx();
710
817
  }
711
818
 
712
819
  /**
@@ -780,7 +887,7 @@ export function setDedupStatus(
780
887
 
781
888
  // --- Session state (injection tracking) ------------------------------------
782
889
 
783
- function loadSessionStateRow(sid: string, db: Database.Database): SessionState {
890
+ function loadSessionStateRow(sid: string, db: DatabaseSync): SessionState {
784
891
  const row = db.prepare("SELECT * FROM session_state WHERE session_id = ?").get(sid) as any;
785
892
  if (!row) {
786
893
  return { injectedCheckpointIds: [], storedRegionHashes: [] };
package/src/store.test.ts CHANGED
@@ -130,6 +130,28 @@ test("compression tier: GZIP-1 and GZIP-6 tiers produce valid, smaller output",
130
130
  assert.deepEqual(decompressSmart(big), Buffer.from("compress me ".repeat(1800)));
131
131
  });
132
132
 
133
+ test("Fix E: pressure escalates gzip tier strength (sync, no zstd)", () => {
134
+ const small = Buffer.from("compress me ".repeat(200)); // 512B–4KB band
135
+ const medium = Buffer.from("compress me ".repeat(1800)); // 4KB–32KB band
136
+
137
+ // Low pressure → cheap levels (gzip-1 / gzip-6).
138
+ const lowSmall = compressSmart(small, 0);
139
+ const lowMedium = compressSmart(medium, 0);
140
+ assert.equal(lowSmall[3], 0x01, "small tier tag");
141
+ assert.equal(lowMedium[3], 0x02, "medium tier tag");
142
+ assert.ok(decompressSmart(lowSmall).equals(small), "low-pressure small roundtrips");
143
+ assert.ok(decompressSmart(lowMedium).equals(medium), "low-pressure medium roundtrips");
144
+
145
+ // High pressure → stronger levels (gzip-9 / gzip-9); tag unchanged, output
146
+ // must still decode to the exact original (versioned header preserved).
147
+ const highSmall = compressSmart(small, 1);
148
+ const highMedium = compressSmart(medium, 1);
149
+ assert.equal(highSmall[3], 0x01, "tag unchanged under pressure");
150
+ assert.equal(highMedium[3], 0x02, "tag unchanged under pressure");
151
+ assert.ok(decompressSmart(highSmall).equals(small), "high-pressure small roundtrips");
152
+ assert.ok(decompressSmart(highMedium).equals(medium), "high-pressure medium roundtrips");
153
+ });
154
+
133
155
  // ---------------------------------------------------------------------------
134
156
  // normalizeSessionId
135
157
  // ---------------------------------------------------------------------------
@@ -38,6 +38,8 @@ import {
38
38
  repoStats as repoStatsFromStore,
39
39
  dataInvariantStats,
40
40
  } from "./store/sqlite.js";
41
+ import { rehydrateRaptorTree } from "./dedup/raptor/index.js";
42
+ import { stagedExpansion } from "./dedup/raptor/retrieval.js";
41
43
  import { migrateJsonToSqlite } from "./store/migrate.js";
42
44
 
43
45
  export interface SearchHit {
@@ -66,6 +68,9 @@ export interface AddInput {
66
68
  * Lets the UI render live per-tier progress during compaction. Never awaited;
67
69
  * must be cheap. Optional for back-compat. */
68
70
  onTier?: (ev: { tier: "L0" | "L1" | "L2" | "new"; status: "scanning" | "deduped" | "passed" | "stored"; detail?: string }) => void;
71
+ /** Context-window pressure (0–1) — escalates the stored checkpoint's sync
72
+ * compression strength (Fix E). Optional; defaults to 0 (brotli-4). */
73
+ compressionPressure?: number;
69
74
  }
70
75
 
71
76
  /** Default L2 semantic-dedup enable flag (trigram embedder is local, zero-network). */
@@ -318,7 +323,10 @@ export class VectorStore {
318
323
  contentHash2: digest.contentHash2,
319
324
  contentHashVersion: digest.contentHashVersion,
320
325
  normalizedText: digest.normalizedText,
321
- compressedOriginal: compressSmart(Buffer.from(input.regionText, "utf-8")),
326
+ compressedOriginal: compressSmart(
327
+ Buffer.from(input.regionText, "utf-8"),
328
+ input.compressionPressure,
329
+ ),
322
330
  embedding,
323
331
  timestamp: input.timestamp,
324
332
  };
@@ -432,6 +440,29 @@ export class VectorStore {
432
440
  // MMR (QA #10) is part of the L2 semantic tier: skip it when L2 is disabled
433
441
  // (Sprint 14 flag), returning the plain relevance-ranked window instead.
434
442
  if (!this.cfg.L2_ENABLED) return window.slice(0, k);
443
+
444
+ // Fix D: when RAPTOR is promoted, ALSO recall high-level tree summaries and
445
+ // merge them with the flat hits via MMR so RAPTOR + flat don't double-cover.
446
+ // RAPTOR returns fewer, broader hits (O(log n) high-level nodes) than the
447
+ // O(n) flat leaves, tightening the block at read time.
448
+ if (this.cfg.RAPTOR_ENABLED) {
449
+ const raptorHits = this.raptorSearchHits(sid, query, k);
450
+ if (raptorHits.length > 0) {
451
+ const merged: SearchHit[] = [...window];
452
+ for (const rh of raptorHits) {
453
+ if (!merged.some((m) => m.checkpoint.checkpointId === rh.checkpoint.checkpointId)) {
454
+ merged.push(rh);
455
+ }
456
+ }
457
+ const mmrItems: MmrItem<SearchHit>[] = merged.map((h) => ({
458
+ item: h,
459
+ vector: h.checkpoint.embedding,
460
+ relevance: h.score,
461
+ }));
462
+ return mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
463
+ }
464
+ }
465
+
435
466
  const mmrItems: MmrItem<SearchHit>[] = window.map((h) => ({
436
467
  item: h,
437
468
  vector: h.checkpoint.embedding,
@@ -441,6 +472,37 @@ export class VectorStore {
441
472
  return ranked;
442
473
  }
443
474
 
475
+ /**
476
+ * Serve the RAPTOR tree for a query (Fix D): rehydrate the persisted tree and
477
+ * return its staged-expansion leaf hits as SearchHits. Returns [] when no tree
478
+ * exists (small sessions — flat search remains the path). Best-effort/non-fatal.
479
+ */
480
+ private raptorSearchHits(sid: string, query: string, k: number): SearchHit[] {
481
+ try {
482
+ const tree = rehydrateRaptorTree(sid, this.stateDir);
483
+ if (!tree || !tree.rootId) return [];
484
+ const leafIds = stagedExpansion(query, tree, {
485
+ embedder: this.embedder,
486
+ k,
487
+ topM: this.cfg.RAPTOR_CLUSTERS_PER_LEVEL,
488
+ mmrLambda: this.cfg.MMR_LAMBDA,
489
+ });
490
+ if (leafIds.length === 0) return [];
491
+ const all = listCheckpoints(sid, this.stateDir).filter(
492
+ (cp) => cp.dedupStatus !== "removed",
493
+ );
494
+ const qv = this.embedder.embed(query);
495
+ const hits: SearchHit[] = [];
496
+ for (const id of leafIds) {
497
+ const cp = all.find((c) => c.checkpointId === id);
498
+ if (cp) hits.push({ checkpoint: cp, score: cosineSimilarity(qv, cp.embedding) });
499
+ }
500
+ return hits;
501
+ } catch {
502
+ return [];
503
+ }
504
+ }
505
+
444
506
  /**
445
507
  * SemDeDup offline cleanup (Sprint 12, QA #17): within a session, mark the
446
508
  * lower-quality row of any pair scoring cosine > `threshold` as