pi-mega-compact 0.4.21 → 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 (43) hide show
  1. package/dist/extensions/dashboard-server.js +3 -3
  2. package/dist/extensions/mega-compact-driver.js +79 -0
  3. package/dist/extensions/mega-compact.test.js +54 -18
  4. package/dist/extensions/mega-config.js +10 -0
  5. package/dist/extensions/mega-events.js +45 -23
  6. package/dist/extensions/mega-pipeline.js +77 -3
  7. package/dist/src/config/dedup.js +4 -1
  8. package/dist/src/config.js +21 -0
  9. package/dist/src/dedup/raptor/index.js +28 -6
  10. package/dist/src/dedup/raptor/promote.test.js +69 -0
  11. package/dist/src/engine.js +1 -0
  12. package/dist/src/recall.js +30 -4
  13. package/dist/src/recall.test.js +28 -0
  14. package/dist/src/store/backfill.js +5 -6
  15. package/dist/src/store/compression.js +47 -7
  16. package/dist/src/store/compression.test.js +48 -0
  17. package/dist/src/store/sqlite.js +64 -41
  18. package/dist/src/store.test.js +19 -0
  19. package/dist/src/vectorStore.js +56 -1
  20. package/extensions/DASHBOARD.md +3 -3
  21. package/extensions/dashboard-server.ts +4 -4
  22. package/extensions/mega-compact-driver.ts +105 -0
  23. package/extensions/mega-compact.test.ts +65 -18
  24. package/extensions/mega-config.ts +25 -0
  25. package/extensions/mega-events.ts +43 -24
  26. package/extensions/mega-pipeline.ts +83 -4
  27. package/package.json +6 -7
  28. package/src/config/dedup.ts +4 -1
  29. package/src/config.ts +26 -0
  30. package/src/dedup/raptor/index.ts +42 -7
  31. package/src/dedup/raptor/promote.test.ts +82 -0
  32. package/src/engine.ts +5 -0
  33. package/src/recall.test.ts +44 -0
  34. package/src/recall.ts +43 -4
  35. package/src/store/backfill.ts +10 -11
  36. package/src/store/compression.test.ts +58 -0
  37. package/src/store/compression.ts +48 -7
  38. package/src/store/sqlite.ts +72 -49
  39. package/src/store.test.ts +22 -0
  40. package/src/vectorStore.ts +63 -1
  41. package/dist/extensions/openclaw-mega-compact.js +0 -291
  42. package/dist/src/minilm.js +0 -92
  43. package/dist/src/wordpiece.js +0 -129
@@ -89,10 +89,22 @@ export function recallRaptor(
89
89
  opts: { embedder?: Embedder; stateDir: string; k?: number; topM?: number },
90
90
  ): string[] {
91
91
  const embedder = opts.embedder ?? defaultEmbedder();
92
- const nodes = listRaptorNodes(sessionId, opts.stateDir);
93
- if (nodes.length === 0) return [];
94
- // Rehydrate a minimal in-memory tree (parent links reconstructed from children).
95
- const byId = new Map(nodes.map((n) => [n.id, n]));
92
+ const tree = rehydrateRaptorTree(sessionId, opts.stateDir);
93
+ if (!tree) return [];
94
+ return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
95
+ }
96
+
97
+ /**
98
+ * Rehydrate a persisted RAPTOR tree from raptor_nodes (Fix D): rebuild the
99
+ * in-memory RaptorTree + parent links so vectorStore.search can serve it live.
100
+ * Returns null when no tree exists (caller falls back to the flat path).
101
+ */
102
+ export function rehydrateRaptorTree(
103
+ sessionId: string,
104
+ stateDir: string,
105
+ ): RaptorTree | null {
106
+ const nodes = listRaptorNodes(sessionId, stateDir);
107
+ if (nodes.length === 0) return null;
96
108
  const tree: RaptorTree = {
97
109
  nodes: new Map(
98
110
  nodes.map((n) => [
@@ -109,10 +121,33 @@ export function recallRaptor(
109
121
  },
110
122
  ]),
111
123
  ),
112
- rootId: nodes.reduce<typeof nodes[number] | null>((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null)?.id ?? null,
124
+ rootId:
125
+ nodes.reduce<typeof nodes[number] | null>(
126
+ (best, n) => (!best || n.level > (best?.level ?? -1) ? n : best),
127
+ null,
128
+ )?.id ?? null,
113
129
  levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
114
130
  timedOut: false,
115
131
  };
116
- void byId;
117
- return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
132
+ return tree;
133
+ }
134
+
135
+ /**
136
+ * Return the RAPTOR root summary for a session, if a tree has been built.
137
+ * Used by the durable-trim driver (Fix B/D) to supply pi a session-level
138
+ * compressed summary instead of one slice's extractive summary. Returns
139
+ * undefined when no tree exists yet (caller falls back to the slice summary).
140
+ */
141
+ export function recallRaptorRootSummary(
142
+ sessionId: string,
143
+ stateDir: string,
144
+ ): string | undefined {
145
+ const nodes = listRaptorNodes(sessionId, stateDir);
146
+ if (nodes.length === 0) return undefined;
147
+ // Highest-level node = the root (covers all leaves).
148
+ const root = nodes.reduce<(typeof nodes)[number] | null>(
149
+ (best, n) => (!best || n.level > best.level ? n : best),
150
+ null,
151
+ );
152
+ return root?.summary || undefined;
118
153
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * promote.test.ts — Fix D: RAPTOR tree served by vectorStore.search.
3
+ *
4
+ * Asserts that, when a RAPTOR tree has been built + persisted for a session,
5
+ * VectorStore.search returns the tree's staged-expansion hits (broader, O(log n)
6
+ * coverage) merged with the flat hits — so the dormant tree becomes the live
7
+ * recall surface. No network: default extractive summarizer + trigram embedder.
8
+ */
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { VectorStore } from "../../vectorStore.js";
16
+ import { runRaptor } from "./index.js";
17
+ import { compactSession } from "../../engine.js";
18
+ import { Logger } from "../../log.js";
19
+ import { loadDedupConfig } from "../../config/dedup.js";
20
+ import { listRaptorNodes } from "../../store/sqlite.js";
21
+ import type { EngineMessage } from "../../types.js";
22
+
23
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-promote-"));
24
+ let counter = 0;
25
+ function raptorConfig() {
26
+ return { ...loadDedupConfig(), RAPTOR_ENABLED: true };
27
+ }
28
+ function msg(text: string, toolName?: string): EngineMessage {
29
+ return toolName ? { role: "assistant", text, toolName, input: text, output: text } : { role: "user", text };
30
+ }
31
+ const SESS = "sess_promote";
32
+
33
+ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)", () => {
34
+ const stateDir = join(baseTmp, `run-${counter++}`);
35
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
36
+
37
+ // Persist several distinct checkpoints.
38
+ for (let i = 1; i <= 5; i++) {
39
+ compactSession(
40
+ { sessionId: SESS, messages: [msg(`topic alpha wire ${i} and bootstrap sequence`), msg(`ok ${i}`, "Edit")], keepFrom: 2, timestamp: i },
41
+ s,
42
+ );
43
+ }
44
+
45
+ // No tree yet → flat search only, returns hits, no RAPTOR coverage.
46
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
47
+ const flat = s.search(SESS, "alpha wire bootstrap", 3);
48
+ assert.ok(flat.length > 0, "flat search returns hits");
49
+
50
+ // Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
51
+ const all = s.list(SESS);
52
+ const leaves = all.map((cp) => ({
53
+ id: cp.checkpointId,
54
+ messages: [],
55
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
56
+ embedding: cp.embedding,
57
+ }));
58
+ const tree = runRaptor(leaves, { stateDir, sessionId: SESS, logger: new Logger() });
59
+ assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
60
+
61
+ // With the tree live + RAPTOR_ENABLED, search still returns hits and now
62
+ // exercises the RAPTOR-served path without regression.
63
+ const withTree = s.search(SESS, "alpha wire bootstrap", 3);
64
+ assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
65
+ // Every returned hit is a real checkpoint in the session.
66
+ for (const h of withTree) {
67
+ assert.ok(all.some((cp) => cp.checkpointId === h.checkpoint.checkpointId), "hit is a real checkpoint");
68
+ }
69
+ });
70
+
71
+ test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
72
+ const stateDir = join(baseTmp, `run-${counter++}`);
73
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
74
+ compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
75
+ const r = s.search(SESS, "only one topic", 3);
76
+ assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
77
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
78
+ });
79
+
80
+ test("cleanup", () => {
81
+ rmSync(baseTmp, { recursive: true, force: true });
82
+ });
package/src/engine.ts CHANGED
@@ -38,6 +38,10 @@ export interface CompactInput {
38
38
  timestamp?: number;
39
39
  /** When true (default), use extractive summary instead of raw concatenation. */
40
40
  useExtractiveSummary?: boolean;
41
+ /** Context-window pressure (0–1): how close the session is to the model
42
+ * limit. Drives adaptive compression strength in the stored checkpoint
43
+ * (Fix E). 0/undefined = room to spare; 1 = at the limit. */
44
+ compressionPressure?: number;
41
45
  /** Sync progress callback fired by the store as each dedup tier is evaluated
42
46
  * (L0→L1→L2→new). Lets the UI render a live "L0 ✓ → L1 ✓ → L2 0.91 → stored"
43
47
  * progress line during compaction. Never awaited; must be side-effect-free-ish
@@ -174,6 +178,7 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
174
178
  originalTokenEstimate,
175
179
  timestamp: input.timestamp ?? 0,
176
180
  onTier: input.onTier,
181
+ compressionPressure: input.compressionPressure,
177
182
  });
178
183
 
179
184
  return {
@@ -54,6 +54,50 @@ test("recallAndInline empty when store has nothing for query", () => {
54
54
  assert.equal(r.block, "");
55
55
  });
56
56
 
57
+ test("Fix C: recallMaxTokens caps the injected block", () => {
58
+ const s = store();
59
+ // Three distinct checkpoints so we can observe the cap bite mid-stream.
60
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
61
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
62
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
63
+
64
+ // A ceiling of 100 tokens fits the first checkpoint (~82) but stops before the
65
+ // second (~163 cumulative) — proving the cap bites mid-stream.
66
+ const r = recallAndInline(
67
+ { sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 100, skipInjected: false },
68
+ s as any,
69
+ );
70
+ assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
71
+ assert.ok(r.toInject.length < 3, "cap prevented all three from injecting");
72
+ assert.ok(r.block.length > 0, "block non-empty");
73
+ });
74
+
75
+ test("Fix C: inline dedupe drops a hit already resident in the live window", () => {
76
+ const s = store();
77
+ const resident = "alpha module wiring and bootstrap sequence";
78
+ compactSession({ sessionId: SESS, messages: [msg("user", resident), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
79
+ compactSession({ sessionId: SESS, messages: [msg("user", "omega module telemetry and tracing spans"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
80
+
81
+ // Baseline: with dedupe OFF, both checkpoints are candidates.
82
+ const rNoDedup = recallAndInline(
83
+ { sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false },
84
+ s as any,
85
+ );
86
+ // The live window contains the exact summary of the first checkpoint — as it
87
+ // would be if a prior recall already injected it. Inline dedupe must drop it
88
+ // (strictly fewer injected than the no-dedupe baseline).
89
+ const residentSummary = rNoDedup.toInject[0].checkpoint.summary;
90
+ const rDedup = recallAndInline(
91
+ { sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false, windowDedupe: true, liveWindow: [residentSummary], dedupSim: 0.9 },
92
+ s as any,
93
+ );
94
+ assert.ok(rDedup.toInject.length <= rNoDedup.toInject.length, "dedupe never adds hits");
95
+ assert.ok(
96
+ rDedup.toInject.length < rNoDedup.toInject.length,
97
+ "inline dedupe dropped a resident hit",
98
+ );
99
+ });
100
+
57
101
  test("cleanup", () => {
58
102
  rmSync(baseTmp, { recursive: true, force: true });
59
103
  });
package/src/recall.ts CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  import { recall as searchRecall } from "./engine.js";
18
18
  import type { SearchHit, VectorStore } from "./vectorStore.js";
19
+ import { estimateBlockTokens } from "./tokens.js";
20
+ import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
19
21
 
20
22
  export type RecallSource = "resume" | "command" | "sentinel";
21
23
 
@@ -26,6 +28,16 @@ export interface RecallInjectOptions {
26
28
  source: RecallSource;
27
29
  /** Skip checkpoints already injected this session (recall dedup). */
28
30
  skipInjected?: boolean;
31
+ /** Token ceiling for the re-injected block (Fix C). Recall stops adding once
32
+ * the block would exceed this, so the read path can never net-inflate. */
33
+ recallMaxTokens?: number;
34
+ /** Inline-dedupe hits against the live window (Fix C): drop a hit whose
35
+ * summary is ≥ `dedupSim` similar to a live message. */
36
+ windowDedupe?: boolean;
37
+ /** Live window text (from the session manager) used for inline dedupe. */
38
+ liveWindow?: string[];
39
+ /** Similarity threshold for inline dedupe (defaults to 0.9). */
40
+ dedupSim?: number;
29
41
  }
30
42
 
31
43
  export interface RecallInjectResult {
@@ -70,23 +82,50 @@ export function recallAndInline(
70
82
  ): RecallInjectResult {
71
83
  const limit = opts.limit ?? 3;
72
84
  const skip = opts.skipInjected ?? true;
85
+ const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)
86
+ const doWindowDedupe = opts.windowDedupe ?? false;
87
+ const dedupSim = opts.dedupSim ?? 0.9;
73
88
 
74
89
  const { hits } = searchRecall(
75
90
  { sessionId: opts.sessionId, query: opts.query, limit, skipInjected: false },
76
91
  store as VectorStore,
77
92
  );
78
93
 
79
- // Shared dedup: drop checkpoints already injected this session, then mark the
80
- // survivors so repeated triggers are free. (Cosine near-dup collapse already
81
- // happened inside store.search.)
94
+ // Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
95
+ // embedder is local + cheap; never a network call (PREVENT-PI-004).
96
+ let liveEmbeddings: number[][] = [];
97
+ if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
98
+ const embedder = defaultEmbedder();
99
+ liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
100
+ }
101
+
102
+ // Shared dedup + bounded/inline block assembly. We build the block
103
+ // incrementally so the token cap can stop mid-stream (Fix C).
82
104
  const toInject: SearchHit[] = [];
105
+ const parts: string[] = [];
106
+ let blockTokens = 0;
107
+
83
108
  for (const h of hits) {
84
109
  if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId)) continue;
110
+
111
+ // Inline dedupe: skip a hit already resident in the live window (Fix C).
112
+ if (doWindowDedupe && liveEmbeddings.length > 0) {
113
+ const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
114
+ if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
115
+ }
116
+
117
+ const part = formatRecallBlock([h]);
118
+ const partTokens = estimateBlockTokens(part);
119
+ // Token cap: never push a chunk that would overrun the ceiling.
120
+ if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
121
+
122
+ parts.push(part);
85
123
  toInject.push(h);
124
+ blockTokens += partTokens;
86
125
  store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
87
126
  }
88
127
 
89
- const block = formatRecallBlock(toInject);
128
+ const block = parts.join("\n");
90
129
  const report = toInject.map(
91
130
  (h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
92
131
  );
@@ -16,12 +16,12 @@
16
16
  * SQLite is the source of truth; this touches no network (PREVENT-PI-004).
17
17
  */
18
18
 
19
- import type { Database } from "better-sqlite3";
19
+ import type { DatabaseSync } from "node:sqlite";
20
20
  import { openStore } from "./sqlite.js";
21
21
  import { computeContentDigest } from "../dedup/digest.js";
22
22
  import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "../dedup/l1-minhash.js";
23
23
  import { lshBands } from "../dedup/l1-lsh.js";
24
- import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree } from "./sqlite.js";
24
+ import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree, withTx } from "./sqlite.js";
25
25
  import { buildRaptorTree, type Leaf } from "../dedup/raptor/tree.js";
26
26
  import type { Embedder } from "../embedder.js";
27
27
  import { defaultEmbedder } from "../embedder.js";
@@ -45,7 +45,7 @@ interface PhaseProgressRow {
45
45
  processed: number;
46
46
  }
47
47
 
48
- function ensureProgressTable(db: Database): void {
48
+ function ensureProgressTable(db: DatabaseSync): void {
49
49
  db.exec(`
50
50
  CREATE TABLE IF NOT EXISTS backfill_progress (
51
51
  name TEXT PRIMARY KEY,
@@ -57,7 +57,7 @@ function ensureProgressTable(db: Database): void {
57
57
  `);
58
58
  }
59
59
 
60
- function progress(db: Database): { lastSid: string | null; lastId: string | null; updated: number; dups: number } {
60
+ function progress(db: DatabaseSync): { lastSid: string | null; lastId: string | null; updated: number; dups: number } {
61
61
  const row = db
62
62
  .prepare("SELECT last_session_id, last_id, updated, duplicates_resolved FROM backfill_progress WHERE name='content_hashes'")
63
63
  .get() as { last_session_id: string | null; last_id: string | null; updated: number; duplicates_resolved: number } | undefined;
@@ -91,7 +91,7 @@ export function backfillContentHashes(stateDir: string = getStateDir()): Backfil
91
91
  let lastSid = start.lastSid;
92
92
  let lastId = start.lastId;
93
93
 
94
- const tx = db.transaction((rows: { id: string; session_id: string; summary: string }[]) => {
94
+ function applyRows(rows: { id: string; session_id: string; summary: string }[]): void {
95
95
  const lookup = db.prepare(
96
96
  "SELECT id FROM context_chunks WHERE session_id = ? AND content_hash = ? AND content_hash2 = ? AND id != ? LIMIT 1",
97
97
  );
@@ -123,10 +123,10 @@ export function backfillContentHashes(stateDir: string = getStateDir()): Backfil
123
123
  lastId = row.id;
124
124
  processed++;
125
125
  }
126
- });
126
+ }
127
127
 
128
128
  if (pending.length > 0) {
129
- tx(pending);
129
+ withTx(db, () => applyRows(pending));
130
130
  db.prepare(
131
131
  "INSERT INTO backfill_progress(name, last_session_id, last_id, updated, duplicates_resolved) VALUES('content_hashes',?,?,?,?) ON CONFLICT(name) DO UPDATE SET last_session_id=excluded.last_session_id, last_id=excluded.last_id, updated=excluded.updated, duplicates_resolved=excluded.duplicates_resolved",
132
132
  ).run(lastSid, lastId, updated, duplicatesResolved);
@@ -150,7 +150,7 @@ export function isBackfillComplete(stateDir: string = getStateDir()): boolean {
150
150
 
151
151
  // ---- Sprint 14: L1 / L2 / RAPTOR phase backfill (resumable) ---------------
152
152
 
153
- function phaseCursor(db: Database, phase: BackfillPhase): { lastId: string | null; processed: number } {
153
+ function phaseCursor(db: DatabaseSync, phase: BackfillPhase): { lastId: string | null; processed: number } {
154
154
  ensureProgressTable(db);
155
155
  const row = db
156
156
  .prepare("SELECT last_id, updated AS processed FROM backfill_progress WHERE name = ?")
@@ -158,7 +158,7 @@ function phaseCursor(db: Database, phase: BackfillPhase): { lastId: string | nul
158
158
  return { lastId: row?.last_id ?? null, processed: row?.processed ?? 0 };
159
159
  }
160
160
 
161
- function savePhaseCursor(db: Database, phase: BackfillPhase, lastId: string | null, processed: number): void {
161
+ function savePhaseCursor(db: DatabaseSync, phase: BackfillPhase, lastId: string | null, processed: number): void {
162
162
  db.prepare(
163
163
  `INSERT INTO backfill_progress(name, last_session_id, last_id, updated, duplicates_resolved)
164
164
  VALUES(?, NULL, ?, ?, 0)
@@ -201,7 +201,7 @@ export function backfillPhase(
201
201
 
202
202
  for (let i = Math.max(0, startIndex); i < all.length; i += batchSize) {
203
203
  const batch = all.slice(i, i + batchSize);
204
- const tx = db.transaction(() => {
204
+ withTx(db, () => {
205
205
  for (const cp of batch) {
206
206
  const sig = minhashSignature(cp.normalizedText ?? cp.summary ?? "");
207
207
  if (sig.length === NUM_HASHES) {
@@ -217,7 +217,6 @@ export function backfillPhase(
217
217
  processed++;
218
218
  }
219
219
  });
220
- tx();
221
220
  savePhaseCursor(db, phase, cursor ?? null, processed);
222
221
  batches++;
223
222
  if (THROTTLE_MS > 0) { const end = Date.now() + THROTTLE_MS; while (Date.now() < end) { /* throttle */ } }
@@ -81,3 +81,61 @@ test("zstd helper roundtrips (async) and is not sync-decoded", async () => {
81
81
  assert.equal(auto.isZstd, true, "flagged as zstd");
82
82
  assert.deepEqual(await decompressZstd(c), data, "zstd roundtrip");
83
83
  });
84
+
85
+ test("module loads without a top-level zstd import (Fix A: no load crash)", async () => {
86
+ // The extension must load even when the @mongodb-js/zstd native addon is
87
+ // absent (clean/allowScripts-blocked install). The dynamic import() lives
88
+ // inside the helpers, so importing this module must never throw.
89
+ const mod = await import("./compression.js");
90
+ assert.equal(typeof mod.compressSmart, "function", "compressSmart exported");
91
+ assert.equal(typeof mod.compressZstd, "function", "compressZstd exported");
92
+ // The real invariant: no STATIC `import ... from "@mongodb-js/zstd"` at the
93
+ // top level (that's what crashed the whole extension). zstd must be loaded
94
+ // lazily inside the helpers only. Check the source text.
95
+ const { readFileSync } = await import("node:fs");
96
+ const { join } = await import("node:path");
97
+ // Tests run with cwd at repo root (`node --test`), so resolve the source.
98
+ const src = readFileSync(join(process.cwd(), "src/store/compression.ts"), "utf-8");
99
+ const staticImport = /^import\s+.+\s+from\s+["']@mongodb-js\/zstd["'];?$/m;
100
+ assert.equal(
101
+ staticImport.test(src),
102
+ false,
103
+ "no static top-level import of @mongodb-js/zstd (would crash load if binary absent)",
104
+ );
105
+ assert.ok(
106
+ src.includes('await import("@mongodb-js/zstd")'),
107
+ "zstd is loaded lazily via dynamic import() inside the helpers",
108
+ );
109
+ });
110
+
111
+ test("compressSmart escalates brotli quality with pressure (Fix E)", () => {
112
+ // Large (>32KB) payloads hit the brotli tier; higher pressure → brotli-11
113
+ // → smaller output than the default brotli-4, and still decodes.
114
+ const words = Array.from({ length: 6000 }, (_, i) => "word" + ((i * 2654435761) % 9973));
115
+ const big = Buffer.from(words.join(" "));
116
+ const low = compressSmart(big, 0);
117
+ const high = compressSmart(big, 1);
118
+ assert.equal(isVersioned(low), true, "versioned header preserved at p=0");
119
+ assert.equal(isVersioned(high), true, "versioned header preserved at p=1");
120
+ assert.ok(high.length < low.length, "high pressure compresses smaller");
121
+ assert.deepEqual(decompressSmart(low), big, "p=0 roundtrip");
122
+ assert.deepEqual(decompressSmart(high), big, "p=1 roundtrip");
123
+ // Small payloads ignore pressure (gzip tier) but still roundtrip.
124
+ const small = buf("hello world ", 300);
125
+ assert.deepEqual(decompressSmart(compressSmart(small, 1)), small, "small ignores pressure");
126
+ // pressure out of range is clamped (no throw, still versioned + decodable).
127
+ assert.deepEqual(decompressSmart(compressSmart(big, 5)), big, "over-pressure clamped");
128
+ assert.deepEqual(decompressSmart(compressSmart(big, -1)), big, "under-pressure clamped");
129
+ });
130
+
131
+ test("pressureFromPct + preserveRecentForPressure scale with context (Fix E)", async () => {
132
+ const { pressureFromPct, preserveRecentForPressure } = await import("../config.js");
133
+ assert.equal(pressureFromPct(50), 0.5, "pct→pressure");
134
+ assert.equal(pressureFromPct(null), 0, "null pct → 0");
135
+ assert.equal(pressureFromPct(150), 1, "pct clamped");
136
+ // low pressure keeps preserveRecent; high pressure compacts deeper (min floor).
137
+ assert.equal(preserveRecentForPressure(0, 4, 2), 4, "p=0 → preserveRecent");
138
+ assert.equal(preserveRecentForPressure(1, 4, 2), 2, "p=1 → preserveRecentMin");
139
+ assert.equal(preserveRecentForPressure(0.5, 4, 2), 3, "p=0.5 → interpolates");
140
+ assert.ok(preserveRecentForPressure(1, 4, 2) >= 2, "never below floor");
141
+ });
@@ -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.
@@ -32,7 +32,13 @@ import {
32
32
  brotliDecompressSync,
33
33
  constants as zlibConstants,
34
34
  } from "node:zlib";
35
- import zstd from "@mongodb-js/zstd";
35
+ // zstd is loaded lazily (see compressZstdWithLevel / decompressZstd). It is an
36
+ // OPTIONAL async DR-export dependency: its native addon (`zstd.node`) is not in
37
+ // the npm tarball and may be absent on a clean/allowScripts-blocked install, so
38
+ // a static import here would crash the whole extension at load time. Lazy
39
+ // import keeps the extension loadable even when the binary is missing; the DR
40
+ // path throws a clear error only if it is actually used. (Fix A.)
41
+ // import zstd from "@mongodb-js/zstd";
36
42
 
37
43
  // --- Versioned format markers ----------------------------------------------
38
44
  const MAGIC_HI = 0xec;
@@ -59,6 +65,12 @@ function header(ver: number, tag: number): Buffer {
59
65
  return Buffer.from([MAGIC_HI, MAGIC_LO, ver, tag]);
60
66
  }
61
67
 
68
+ /** Clamp a value to the [0, 1] range (pressure bands). */
69
+ function clamp01(n: number): number {
70
+ if (Number.isNaN(n)) return 0;
71
+ return n < 0 ? 0 : n > 1 ? 1 : n;
72
+ }
73
+
62
74
  /**
63
75
  * Compress synchronously using the best zlib tier for the payload size.
64
76
  *
@@ -68,21 +80,36 @@ function header(ver: number, tag: number): Buffer {
68
80
  * 4KB–32KB → gzip level 6 (tag 0x02)
69
81
  * > 32 KB → brotli 4 (tag 0x05)
70
82
  *
71
- * Writes the versioned header so readers disambiguate from legacy blobs.
83
+ * `pressure` (0–1, optional) escalates the brotli quality for the large tier
84
+ * when the session is near its context limit — the "variable compression as we
85
+ * approach the limit" design (Fix E). Low/undefined pressure keeps brotli-4;
86
+ * high pressure pushes toward brotli-11. Stays fully synchronous (brotli-11 is
87
+ * sync via brotliCompressSync) so the sync `add()` contract is preserved; zstd
88
+ * is reserved for the async DR-export path only. Same versioned header/tags for
89
+ * every pressure, so decompressSmart is unaffected.
72
90
  */
73
- export function compressSmart(data: Buffer): Buffer {
91
+ export function compressSmart(data: Buffer, pressure = 0): Buffer {
92
+ const p = clamp01(pressure);
74
93
  const len = data.length;
75
94
  if (len < SIZE_TINY) {
76
95
  return Buffer.concat([header(1, TAG_RAW), data]);
77
96
  }
78
97
  if (len < SIZE_SMALL) {
79
- return Buffer.concat([header(1, TAG_GZIP_1), gzipSync(data, { level: 1 })]);
98
+ // Small tier: escalate gzip level 1 → 9 with context pressure (Fix E) so
99
+ // the "variable compression as we approach the limit" dial bites for
100
+ // short sessions too, not just the >32KB brotli tier.
101
+ const level = Math.max(1, Math.min(9, Math.round(1 + 8 * p)));
102
+ return Buffer.concat([header(1, TAG_GZIP_1), gzipSync(data, { level })]);
80
103
  }
81
104
  if (len < SIZE_MEDIUM) {
82
- return Buffer.concat([header(1, TAG_GZIP_6), gzipSync(data, { level: 6 })]);
105
+ // Medium tier: escalate gzip level 6 → 9 with context pressure (Fix E).
106
+ const level = Math.max(6, Math.min(9, Math.round(6 + 3 * p)));
107
+ return Buffer.concat([header(1, TAG_GZIP_6), gzipSync(data, { level })]);
83
108
  }
109
+ // Large tier: escalate brotli quality 4 → 11 with context pressure (Fix E).
110
+ const quality = Math.max(4, Math.min(11, Math.round(4 + 7 * p)));
84
111
  const compressed = brotliCompressSync(data, {
85
- params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
112
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: quality },
86
113
  });
87
114
  return Buffer.concat([header(1, TAG_BROTLI_4), compressed]);
88
115
  }
@@ -164,6 +191,18 @@ const ZSTD_MAGIC_HI = 0x5a; // 'Z'
164
191
  const ZSTD_MAGIC_LO = 0x53; // 'S'
165
192
 
166
193
  async function compressZstdWithLevel(data: Buffer, level: number): Promise<Buffer> {
194
+ // Lazy import: the native addon may be absent (clean/allowScripts install).
195
+ // Throws a clear, actionable error instead of a load-time crash.
196
+ let zstd: typeof import("@mongodb-js/zstd");
197
+ try {
198
+ zstd = await import("@mongodb-js/zstd");
199
+ } catch {
200
+ throw new Error(
201
+ "zstd is not available — the @mongodb-js/zstd native addon (zstd.node) " +
202
+ "was not built. Run the extension's native install step (or allow npm " +
203
+ "install scripts) to enable DR-export compression.",
204
+ );
205
+ }
167
206
  const compressed = await zstd.compress(data, level);
168
207
  return Buffer.concat([Buffer.from([ZSTD_MAGIC_HI, ZSTD_MAGIC_LO]), compressed]);
169
208
  }
@@ -189,6 +228,8 @@ export async function decompressZstd(buf: Buffer): Promise<Buffer> {
189
228
  if (!isZstd(buf)) {
190
229
  throw new Error("decompressZstd: buffer is not a zstd blob (missing ZS marker)");
191
230
  }
231
+ // Lazy import (see compressZstdWithLevel for rationale).
232
+ const zstd = await import("@mongodb-js/zstd");
192
233
  return zstd.decompress(buf.subarray(2));
193
234
  }
194
235