pi-mega-compact 0.4.21 → 0.4.24

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 (47) hide show
  1. package/dist/extensions/dashboard-server.js +60 -9
  2. package/dist/extensions/dashboard-server.test.js +77 -0
  3. package/dist/extensions/mega-compact-driver.js +79 -0
  4. package/dist/extensions/mega-compact.test.js +54 -18
  5. package/dist/extensions/mega-config.js +10 -0
  6. package/dist/extensions/mega-dashboard-cmds.js +32 -2
  7. package/dist/extensions/mega-events.js +45 -23
  8. package/dist/extensions/mega-pipeline.js +77 -3
  9. package/dist/src/config/dedup.js +4 -1
  10. package/dist/src/config.js +21 -0
  11. package/dist/src/dedup/raptor/index.js +28 -6
  12. package/dist/src/dedup/raptor/promote.test.js +69 -0
  13. package/dist/src/engine.js +1 -0
  14. package/dist/src/recall.js +30 -4
  15. package/dist/src/recall.test.js +28 -0
  16. package/dist/src/store/backfill.js +5 -6
  17. package/dist/src/store/compression.js +47 -7
  18. package/dist/src/store/compression.test.js +48 -0
  19. package/dist/src/store/sqlite.js +64 -41
  20. package/dist/src/store.test.js +19 -0
  21. package/dist/src/vectorStore.js +56 -1
  22. package/extensions/DASHBOARD.md +3 -3
  23. package/extensions/dashboard-server.test.ts +77 -0
  24. package/extensions/dashboard-server.ts +57 -11
  25. package/extensions/mega-compact-driver.ts +105 -0
  26. package/extensions/mega-compact.test.ts +65 -18
  27. package/extensions/mega-config.ts +25 -0
  28. package/extensions/mega-dashboard-cmds.ts +23 -2
  29. package/extensions/mega-events.ts +43 -24
  30. package/extensions/mega-pipeline.ts +83 -4
  31. package/package.json +6 -7
  32. package/src/config/dedup.ts +4 -1
  33. package/src/config.ts +26 -0
  34. package/src/dedup/raptor/index.ts +42 -7
  35. package/src/dedup/raptor/promote.test.ts +82 -0
  36. package/src/engine.ts +5 -0
  37. package/src/recall.test.ts +44 -0
  38. package/src/recall.ts +43 -4
  39. package/src/store/backfill.ts +10 -11
  40. package/src/store/compression.test.ts +58 -0
  41. package/src/store/compression.ts +48 -7
  42. package/src/store/sqlite.ts +72 -49
  43. package/src/store.test.ts +22 -0
  44. package/src/vectorStore.ts +63 -1
  45. package/dist/extensions/openclaw-mega-compact.js +0 -291
  46. package/dist/src/minilm.js +0 -92
  47. package/dist/src/wordpiece.js +0 -129
@@ -6,12 +6,15 @@
6
6
  * the shared MegaRuntime (token accounting, ticker, status, events) and are
7
7
  * driven by the event + command handlers in mega-events.ts / mega-commands.ts.
8
8
  */
9
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
9
10
  import { compactSession } from "../src/engine.js";
10
11
  import { recallAndInline } from "../src/recall.js";
11
12
  import { normalizeSessionId } from "../src/store.js";
12
13
  import { touchSession, logDaily } from "../src/store/sqlite.js";
13
14
  import { C, MARKER_TYPE, } from "./mega-runtime.js";
14
- import { resolveRepoRoot } from "./mega-config.js";
15
+ import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
16
+ import { runRaptor } from "../src/dedup/raptor/index.js";
17
+ import { loadDedupConfig } from "../src/config/dedup.js";
15
18
  /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
16
19
  export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
17
20
  runtime.bindRepo(ctx.cwd);
@@ -19,7 +22,10 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
19
22
  runtime.resetRuntime(sid);
20
23
  runtime.rt.sessionId = sid;
21
24
  const view = runtime.engineView(messages);
22
- const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
25
+ // keepFrom deepens with context pressure (Fix E): under high pressure we
26
+ // compact more of the session, down to the preserveRecentMin floor.
27
+ const preserve = preserveRecentForPressure(opts.compressionPressure ?? 0, config.preserveRecent, config.preserveRecentMin);
28
+ const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
23
29
  if (keepFrom <= 0)
24
30
  return { skipped: true };
25
31
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
@@ -30,6 +36,7 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
30
36
  summary: opts.summary,
31
37
  timestamp: Date.now(),
32
38
  onTier: runtime.makeTierCallback(ctx),
39
+ compressionPressure: opts.compressionPressure,
33
40
  }, runtime.store);
34
41
  runtime.pulsing = false;
35
42
  if (result.skipped)
@@ -96,6 +103,34 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
96
103
  tokenEstimate: result.tokenEstimate,
97
104
  deduped: result.deduped,
98
105
  });
106
+ // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
107
+ // serve high-level summaries. Best-effort + non-fatal: never block compaction.
108
+ // Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
109
+ if (config.raptorEnabled && !result.deduped) {
110
+ try {
111
+ const dd = loadDedupConfig();
112
+ const all = runtime.store.list(sid);
113
+ const leaves = all.map((cp) => ({
114
+ id: cp.checkpointId,
115
+ messages: [],
116
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
117
+ embedding: cp.embedding,
118
+ }));
119
+ if (leaves.length >= 2) {
120
+ runRaptor(leaves, {
121
+ stateDir: runtime.currentStateDir,
122
+ sessionId: sid,
123
+ budgetMs: dd.RAPTOR_BUDGET_MS,
124
+ clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
125
+ consistencyThreshold: dd.RAPTOR_CONSISTENCY,
126
+ logger: runtime.logger,
127
+ });
128
+ }
129
+ }
130
+ catch {
131
+ /* non-fatal: tree refresh never blocks a compaction */
132
+ }
133
+ }
99
134
  runtime.setStatus(ctx, runtime.rt.persistedThisSession
100
135
  ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
101
136
  : `mega-compact: ready`);
@@ -124,7 +159,21 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
124
159
  export function doRecall(runtime, config, ctx, query, source) {
125
160
  runtime.bindRepo(ctx.cwd);
126
161
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
127
- const result = recallAndInline({ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true }, runtime.store);
162
+ // Live window text for inline dedupe (Fix C): drop recalled checkpoints that
163
+ // are already resident in the session, so recall never re-injects context the
164
+ // model can already see. Best-effort — an empty window just skips dedupe.
165
+ const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
166
+ const result = recallAndInline({
167
+ sessionId: sid,
168
+ query,
169
+ limit: config.autoInlineK,
170
+ source,
171
+ skipInjected: true,
172
+ recallMaxTokens: config.recallMaxTokens,
173
+ windowDedupe: config.windowDedupe,
174
+ liveWindow,
175
+ dedupSim: config.dedupSim,
176
+ }, runtime.store);
128
177
  runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
129
178
  if (!result.empty && result.toInject.length > 0) {
130
179
  const top = result.toInject[0];
@@ -136,3 +185,28 @@ export function doRecall(runtime, config, ctx, query, source) {
136
185
  }
137
186
  return result;
138
187
  }
188
+ /**
189
+ * Extract the live-window message texts from the session manager (Fix C),
190
+ * for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
191
+ * error so recall falls back to unbounded (still correct, just no dedupe).
192
+ * Mirrors recentUserQuery's use of sessionEntryToContextMessages.
193
+ */
194
+ function extractLiveWindow(ctx) {
195
+ try {
196
+ const entries = ctx.sessionManager.getEntries();
197
+ const texts = [];
198
+ for (const e of entries) {
199
+ for (const m of sessionEntryToContextMessages(e)) {
200
+ const c = m.content;
201
+ if (typeof c === "string")
202
+ texts.push(c);
203
+ else if (Array.isArray(c))
204
+ texts.push(c.map((b) => b.text).join(" "));
205
+ }
206
+ }
207
+ return texts;
208
+ }
209
+ catch {
210
+ return [];
211
+ }
212
+ }
@@ -33,7 +33,10 @@ export function loadDedupConfig() {
33
33
  L0_ENABLED: envBool("MEGACOMPACT_L0_ENABLED", true),
34
34
  L1_ENABLED: envBool("MEGACOMPACT_L1_ENABLED", true),
35
35
  L2_ENABLED: envBool("MEGACOMPACT_L2_ENABLED", true),
36
- RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", false), // shadow by default
36
+ // Fix D: RAPTOR promoted to live recall. Default ON; canary.ts sequences it
37
+ // last (L0→L1→L2→RAPTOR) and auto-disables on p95 breach, so promotion is
38
+ // safe. `RAPTOR_SHADOW_MODE=false` still gates serving during transition.
39
+ RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
37
40
  MARK_ONLY_L0: envBool("MEGACOMPACT_MARK_ONLY_L0", false),
38
41
  MARK_ONLY_L1: envBool("MEGACOMPACT_MARK_ONLY_L1", false),
39
42
  MARK_ONLY_L2: envBool("MEGACOMPACT_MARK_ONLY_L2", false),
@@ -10,3 +10,24 @@ import { homedir } from "node:os";
10
10
  export const STATE_DIR_DEFAULT = join(homedir(), ".pi", "agent", "extensions", "pi-mega-compact");
11
11
  /** Pi custom message / entry type used as the dedup sentinel. */
12
12
  export const MARKER_TYPE = "mega-compact-marker";
13
+ /**
14
+ * Derive context-window pressure (0–1) from a usage percentage. Used to scale
15
+ * compression strength + keepFrom depth (Fix E): low pct = room to spare,
16
+ * high pct = near the limit. Deterministic; clamps to [0,1].
17
+ */
18
+ export function pressureFromPct(pct) {
19
+ if (pct == null || Number.isNaN(pct))
20
+ return 0;
21
+ return pct < 0 ? 0 : pct > 100 ? 1 : pct / 100;
22
+ }
23
+ /**
24
+ * Map pressure → how many recent messages to preserve verbatim. Under low
25
+ * pressure we keep `preserveRecent`; under high pressure we compact deeper,
26
+ * down to `preserveRecentMin`. Never splits a tool pair / anchor floor — the
27
+ * boundary guard (computeDropRange) enforces that downstream.
28
+ */
29
+ export function preserveRecentForPressure(pressure, preserveRecent, preserveRecentMin) {
30
+ const p = pressure < 0 ? 0 : pressure > 1 ? 1 : pressure;
31
+ const v = Math.round(preserveRecent - (preserveRecent - preserveRecentMin) * p);
32
+ return Math.max(preserveRecentMin, Math.min(preserveRecent, v));
33
+ }
@@ -66,11 +66,20 @@ export function runRaptor(leaves, opts) {
66
66
  */
67
67
  export function recallRaptor(query, sessionId, opts) {
68
68
  const embedder = opts.embedder ?? defaultEmbedder();
69
- const nodes = listRaptorNodes(sessionId, opts.stateDir);
70
- if (nodes.length === 0)
69
+ const tree = rehydrateRaptorTree(sessionId, opts.stateDir);
70
+ if (!tree)
71
71
  return [];
72
- // Rehydrate a minimal in-memory tree (parent links reconstructed from children).
73
- const byId = new Map(nodes.map((n) => [n.id, n]));
72
+ return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
73
+ }
74
+ /**
75
+ * Rehydrate a persisted RAPTOR tree from raptor_nodes (Fix D): rebuild the
76
+ * in-memory RaptorTree + parent links so vectorStore.search can serve it live.
77
+ * Returns null when no tree exists (caller falls back to the flat path).
78
+ */
79
+ export function rehydrateRaptorTree(sessionId, stateDir) {
80
+ const nodes = listRaptorNodes(sessionId, stateDir);
81
+ if (nodes.length === 0)
82
+ return null;
74
83
  const tree = {
75
84
  nodes: new Map(nodes.map((n) => [
76
85
  n.id,
@@ -89,6 +98,19 @@ export function recallRaptor(query, sessionId, opts) {
89
98
  levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
90
99
  timedOut: false,
91
100
  };
92
- void byId;
93
- return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
101
+ return tree;
102
+ }
103
+ /**
104
+ * Return the RAPTOR root summary for a session, if a tree has been built.
105
+ * Used by the durable-trim driver (Fix B/D) to supply pi a session-level
106
+ * compressed summary instead of one slice's extractive summary. Returns
107
+ * undefined when no tree exists yet (caller falls back to the slice summary).
108
+ */
109
+ export function recallRaptorRootSummary(sessionId, stateDir) {
110
+ const nodes = listRaptorNodes(sessionId, stateDir);
111
+ if (nodes.length === 0)
112
+ return undefined;
113
+ // Highest-level node = the root (covers all leaves).
114
+ const root = nodes.reduce((best, n) => (!best || n.level > best.level ? n : best), null);
115
+ return root?.summary || undefined;
94
116
  }
@@ -0,0 +1,69 @@
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
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { VectorStore } from "../../vectorStore.js";
15
+ import { runRaptor } from "./index.js";
16
+ import { compactSession } from "../../engine.js";
17
+ import { Logger } from "../../log.js";
18
+ import { loadDedupConfig } from "../../config/dedup.js";
19
+ import { listRaptorNodes } from "../../store/sqlite.js";
20
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-promote-"));
21
+ let counter = 0;
22
+ function raptorConfig() {
23
+ return { ...loadDedupConfig(), RAPTOR_ENABLED: true };
24
+ }
25
+ function msg(text, toolName) {
26
+ return toolName ? { role: "assistant", text, toolName, input: text, output: text } : { role: "user", text };
27
+ }
28
+ const SESS = "sess_promote";
29
+ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)", () => {
30
+ const stateDir = join(baseTmp, `run-${counter++}`);
31
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
32
+ // Persist several distinct checkpoints.
33
+ for (let i = 1; i <= 5; i++) {
34
+ compactSession({ sessionId: SESS, messages: [msg(`topic alpha wire ${i} and bootstrap sequence`), msg(`ok ${i}`, "Edit")], keepFrom: 2, timestamp: i }, s);
35
+ }
36
+ // No tree yet → flat search only, returns hits, no RAPTOR coverage.
37
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
38
+ const flat = s.search(SESS, "alpha wire bootstrap", 3);
39
+ assert.ok(flat.length > 0, "flat search returns hits");
40
+ // Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
41
+ const all = s.list(SESS);
42
+ const leaves = all.map((cp) => ({
43
+ id: cp.checkpointId,
44
+ messages: [],
45
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
46
+ embedding: cp.embedding,
47
+ }));
48
+ const tree = runRaptor(leaves, { stateDir, sessionId: SESS, logger: new Logger() });
49
+ assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
50
+ // With the tree live + RAPTOR_ENABLED, search still returns hits and now
51
+ // exercises the RAPTOR-served path without regression.
52
+ const withTree = s.search(SESS, "alpha wire bootstrap", 3);
53
+ assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
54
+ // Every returned hit is a real checkpoint in the session.
55
+ for (const h of withTree) {
56
+ assert.ok(all.some((cp) => cp.checkpointId === h.checkpoint.checkpointId), "hit is a real checkpoint");
57
+ }
58
+ });
59
+ test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
60
+ const stateDir = join(baseTmp, `run-${counter++}`);
61
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
62
+ compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
63
+ const r = s.search(SESS, "only one topic", 3);
64
+ assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
65
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
66
+ });
67
+ test("cleanup", () => {
68
+ rmSync(baseTmp, { recursive: true, force: true });
69
+ });
@@ -104,6 +104,7 @@ export function compactSession(input, store = getDefaultStore()) {
104
104
  originalTokenEstimate,
105
105
  timestamp: input.timestamp ?? 0,
106
106
  onTier: input.onTier,
107
+ compressionPressure: input.compressionPressure,
107
108
  });
108
109
  return {
109
110
  skipped: false,
@@ -14,6 +14,8 @@
14
14
  * extension decides where it lands.
15
15
  */
16
16
  import { recall as searchRecall } from "./engine.js";
17
+ import { estimateBlockTokens } from "./tokens.js";
18
+ import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
17
19
  /** Wrap a recall block so the model reads it as restored compacted context. */
18
20
  export function formatRecallBlock(hits) {
19
21
  if (hits.length === 0)
@@ -38,18 +40,42 @@ export function formatRecallBlock(hits) {
38
40
  export function recallAndInline(opts, store) {
39
41
  const limit = opts.limit ?? 3;
40
42
  const skip = opts.skipInjected ?? true;
43
+ const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)
44
+ const doWindowDedupe = opts.windowDedupe ?? false;
45
+ const dedupSim = opts.dedupSim ?? 0.9;
41
46
  const { hits } = searchRecall({ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: false }, store);
42
- // Shared dedup: drop checkpoints already injected this session, then mark the
43
- // survivors so repeated triggers are free. (Cosine near-dup collapse already
44
- // happened inside store.search.)
47
+ // Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
48
+ // embedder is local + cheap; never a network call (PREVENT-PI-004).
49
+ let liveEmbeddings = [];
50
+ if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
51
+ const embedder = defaultEmbedder();
52
+ liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
53
+ }
54
+ // Shared dedup + bounded/inline block assembly. We build the block
55
+ // incrementally so the token cap can stop mid-stream (Fix C).
45
56
  const toInject = [];
57
+ const parts = [];
58
+ let blockTokens = 0;
46
59
  for (const h of hits) {
47
60
  if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId))
48
61
  continue;
62
+ // Inline dedupe: skip a hit already resident in the live window (Fix C).
63
+ if (doWindowDedupe && liveEmbeddings.length > 0) {
64
+ const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
65
+ if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim))
66
+ continue;
67
+ }
68
+ const part = formatRecallBlock([h]);
69
+ const partTokens = estimateBlockTokens(part);
70
+ // Token cap: never push a chunk that would overrun the ceiling.
71
+ if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
72
+ break;
73
+ parts.push(part);
49
74
  toInject.push(h);
75
+ blockTokens += partTokens;
50
76
  store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
51
77
  }
52
- const block = formatRecallBlock(toInject);
78
+ const block = parts.join("\n");
53
79
  const report = toInject.map((h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`);
54
80
  return {
55
81
  toInject,
@@ -45,6 +45,34 @@ test("recallAndInline empty when store has nothing for query", () => {
45
45
  assert.equal(r.empty, true);
46
46
  assert.equal(r.block, "");
47
47
  });
48
+ test("Fix C: recallMaxTokens caps the injected block", () => {
49
+ const s = store();
50
+ // Three distinct checkpoints so we can observe the cap bite mid-stream.
51
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
52
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
53
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
54
+ // A ceiling of 100 tokens fits the first checkpoint (~82) but stops before the
55
+ // second (~163 cumulative) — proving the cap bites mid-stream.
56
+ const r = recallAndInline({ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 100, skipInjected: false }, s);
57
+ assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
58
+ assert.ok(r.toInject.length < 3, "cap prevented all three from injecting");
59
+ assert.ok(r.block.length > 0, "block non-empty");
60
+ });
61
+ test("Fix C: inline dedupe drops a hit already resident in the live window", () => {
62
+ const s = store();
63
+ const resident = "alpha module wiring and bootstrap sequence";
64
+ compactSession({ sessionId: SESS, messages: [msg("user", resident), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
65
+ compactSession({ sessionId: SESS, messages: [msg("user", "omega module telemetry and tracing spans"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
66
+ // Baseline: with dedupe OFF, both checkpoints are candidates.
67
+ const rNoDedup = recallAndInline({ sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false }, s);
68
+ // The live window contains the exact summary of the first checkpoint — as it
69
+ // would be if a prior recall already injected it. Inline dedupe must drop it
70
+ // (strictly fewer injected than the no-dedupe baseline).
71
+ const residentSummary = rNoDedup.toInject[0].checkpoint.summary;
72
+ const rDedup = recallAndInline({ sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false, windowDedupe: true, liveWindow: [residentSummary], dedupSim: 0.9 }, s);
73
+ assert.ok(rDedup.toInject.length <= rNoDedup.toInject.length, "dedupe never adds hits");
74
+ assert.ok(rDedup.toInject.length < rNoDedup.toInject.length, "inline dedupe dropped a resident hit");
75
+ });
48
76
  test("cleanup", () => {
49
77
  rmSync(baseTmp, { recursive: true, force: true });
50
78
  });
@@ -19,7 +19,7 @@ import { openStore } from "./sqlite.js";
19
19
  import { computeContentDigest } from "../dedup/digest.js";
20
20
  import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "../dedup/l1-minhash.js";
21
21
  import { lshBands } from "../dedup/l1-lsh.js";
22
- import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree } from "./sqlite.js";
22
+ import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree, withTx } from "./sqlite.js";
23
23
  import { buildRaptorTree } from "../dedup/raptor/tree.js";
24
24
  import { defaultEmbedder } from "../embedder.js";
25
25
  import { getStateDir } from "../store.js";
@@ -64,7 +64,7 @@ export function backfillContentHashes(stateDir = getStateDir()) {
64
64
  let processed = 0;
65
65
  let lastSid = start.lastSid;
66
66
  let lastId = start.lastId;
67
- const tx = db.transaction((rows) => {
67
+ function applyRows(rows) {
68
68
  const lookup = db.prepare("SELECT id FROM context_chunks WHERE session_id = ? AND content_hash = ? AND content_hash2 = ? AND id != ? LIMIT 1");
69
69
  const update = db.prepare(`UPDATE context_chunks
70
70
  SET content_hash=?, content_hash2=?, content_hash_version=?, normalized_text=?,
@@ -87,9 +87,9 @@ export function backfillContentHashes(stateDir = getStateDir()) {
87
87
  lastId = row.id;
88
88
  processed++;
89
89
  }
90
- });
90
+ }
91
91
  if (pending.length > 0) {
92
- tx(pending);
92
+ withTx(db, () => applyRows(pending));
93
93
  db.prepare("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").run(lastSid, lastId, updated, duplicatesResolved);
94
94
  }
95
95
  if (THROTTLE_MS > 0) {
@@ -135,7 +135,7 @@ export function backfillPhase(phase, sessionId, stateDir, opts = {}) {
135
135
  let cursor = lastId ?? undefined;
136
136
  for (let i = Math.max(0, startIndex); i < all.length; i += batchSize) {
137
137
  const batch = all.slice(i, i + batchSize);
138
- const tx = db.transaction(() => {
138
+ withTx(db, () => {
139
139
  for (const cp of batch) {
140
140
  const sig = minhashSignature(cp.normalizedText ?? cp.summary ?? "");
141
141
  if (sig.length === NUM_HASHES) {
@@ -148,7 +148,6 @@ export function backfillPhase(phase, sessionId, stateDir, opts = {}) {
148
148
  processed++;
149
149
  }
150
150
  });
151
- tx();
152
151
  savePhaseCursor(db, phase, cursor ?? null, processed);
153
152
  batches++;
154
153
  if (THROTTLE_MS > 0) {
@@ -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
+ });