dsh-engram 0.3.0 → 0.3.3

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.
@@ -0,0 +1,626 @@
1
+ /**
2
+ * dsh-engram: Context GC — the auto-GC that REPLACES DSH's built-in lossy
3
+ * LLM-summary compaction (NOT the memory-panel GC).
4
+ *
5
+ * Thesis (mirrors pi-esr `extensions/context-gc.ts`): the bulk of evicted
6
+ * context is RE-FETCHABLE — engram memories (`engram_store`/`engram_recall`/
7
+ * `engram_detail`), ESR tasks/entities (`esr_*`), filePath anchors. So instead
8
+ * of asking an LLM to compress everything into prose (lossy, un-queryable, and
9
+ * itself a context consumer), we scan the messages being evicted for
10
+ * provenance anchors and emit an explicit pointer summary: "this detail lives
11
+ * at `<tool call>`". Only genuinely un-provenanced turns (pure chat / reasoning
12
+ * with no re-fetchable backing) get a scoped LLM narrative — the safety net,
13
+ * configurable off for a pure-mechanical zero-LLM path.
14
+ *
15
+ * The 6 pi-esr GC constraints:
16
+ * - indexed-output-evictable → pointer to `engram_detail(id)` / `engram_recall`
17
+ * - stable-task-evictable → pointer to the [ESR] block / `esr_ready`
18
+ * - working-set-protected → active tasks restated in the summary
19
+ * - pointer-salience → every evicted category has an explicit re-fetch call
20
+ * - no-provenance-no-evict → un-provenanced turns via scoped narrative or verbatim
21
+ *
22
+ * Layout: the pure (zero-LLM, dependency-free) layer lives here; the DSH
23
+ * `CompactionEngine` binding is `loadContextGcEngine`, behind a guarded dynamic
24
+ * import so the whole module never crashes when `dsh-compaction-basic` is
25
+ * absent from the host.
26
+ */
27
+
28
+ import { basename } from "node:path";
29
+ import { slugId } from "./util.js";
30
+
31
+ /** Memory ids are uuids; the [ENGRAM] index shows `#` + first 8 alnum chars. */
32
+ const MEMORY_ID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
33
+ const MEMORY_MARKER_RE = /#([a-z0-9]{6,})/gi;
34
+ // A bare 8-hex word is a memory short id (the first 8 chars of a uuid, which
35
+ // is always hex) as echoed by engram_recall / engram_detail memory lines.
36
+ const MEMORY_SHORT_RE = /\b[a-f0-9]{8}\b/g;
37
+ const TASK_ID_RE = /\btsk_[a-z0-9]{4,}\b/gi;
38
+ const ENTITY_ID_RE = /\bent_[a-z0-9-]{2,48}\b/gi;
39
+
40
+ /** engram memory-surface tools — their output is persisted and re-fetchable. */
41
+ const ENGRAM_ANCHOR_TOOLS = new Set(["engram_store", "engram_recall", "engram_detail"]);
42
+
43
+ /** ESR task/entity tools — structured state in the task store. */
44
+ const ESR_ANCHOR_TOOLS = new Set([
45
+ "esr_task",
46
+ "esr_close",
47
+ "esr_ready",
48
+ "esr_link",
49
+ "esr_dep",
50
+ "esr_claim",
51
+ "esr_unclaim",
52
+ "esr_node",
53
+ "esr_model",
54
+ ]);
55
+
56
+ /** Management tools are NOT provenance anchors (like ctx_purge — never re-run). */
57
+ const ESR_MANAGEMENT_TOOLS = new Set(["esr_gc"]);
58
+
59
+ /** Concatenate the visible text of a DSH content-block array. */
60
+ export function blockText(content) {
61
+ if (typeof content === "string") return content;
62
+ if (!Array.isArray(content)) return "";
63
+ let out = "";
64
+ for (const part of content) {
65
+ if (part && typeof part === "object" && typeof part.text === "string") out += `${part.text}\n`;
66
+ }
67
+ return out.trim();
68
+ }
69
+
70
+ function addSet(set, value) {
71
+ const v = String(value ?? "").trim();
72
+ if (v.length > 0) set.add(v);
73
+ }
74
+
75
+ /** Register a memory id/marker (deduped) plus its 8-char `#` marker. */
76
+ function addMemoryId(report, id) {
77
+ const v = String(id).trim();
78
+ if (v.length === 0) return;
79
+ report.memoryIds.add(v);
80
+ const bare = v.replace(/^#/, "").replace(/[^a-z0-9]/gi, "").slice(0, 8);
81
+ if (bare.length >= 4) report.memoryMarkers.add(`#${bare}`);
82
+ }
83
+
84
+ /** Discover memory ids + `#`/bare short markers echoed in a result text. */
85
+ function addMemoryIdsFromText(report, text) {
86
+ if (typeof text !== "string") return;
87
+ for (const m of String(text).matchAll(MEMORY_ID_RE)) addMemoryId(report, m[0]);
88
+ for (const m of String(text).matchAll(MEMORY_MARKER_RE)) addMemoryId(report, m[1]);
89
+ for (const m of String(text).matchAll(MEMORY_SHORT_RE)) addMemoryId(report, m[0]);
90
+ }
91
+
92
+ /** Discover task/entity ids echoed in a result text. */
93
+ function addEntityIdsFromText(report, text) {
94
+ if (typeof text !== "string") return;
95
+ for (const m of String(text).matchAll(TASK_ID_RE)) addSet(report.entityIds, m[0]);
96
+ for (const m of String(text).matchAll(ENTITY_ID_RE)) addSet(report.entityIds, m[0]);
97
+ }
98
+
99
+ /** Low-level provenance extraction for one tool-call block. */
100
+ function scanToolCall(report, name, args, resultText) {
101
+ if (ESR_MANAGEMENT_TOOLS.has(name)) return false;
102
+ let anchored = false;
103
+
104
+ if (name === "engram_store") {
105
+ if (typeof args.file_path === "string" && args.file_path) addSet(report.filePaths, args.file_path);
106
+ report.engramUsed = true;
107
+ anchored = true;
108
+ } else if (name === "engram_recall") {
109
+ if (typeof args.query === "string" && args.query.trim()) report.recallQueries.push(args.query.trim());
110
+ if (typeof args.entity === "string" && args.entity.trim()) report.entityQueries.push(args.entity.trim());
111
+ report.engramUsed = true;
112
+ anchored = true;
113
+ } else if (name === "engram_detail") {
114
+ if (typeof args.id === "string" && args.id.trim()) addMemoryId(report, args.id);
115
+ report.engramUsed = true;
116
+ anchored = true;
117
+ } else if (name === "esr_task") {
118
+ if (typeof args.id === "string" && args.id.trim()) addSet(report.entityIds, args.id);
119
+ if (typeof args.entity === "string" && args.entity.trim()) addSet(report.entityIds, args.entity);
120
+ report.esrUsed = true;
121
+ anchored = true;
122
+ } else if (name === "esr_close") {
123
+ if (typeof args.task_id === "string" && args.task_id.trim()) addSet(report.entityIds, args.task_id);
124
+ for (const ref of Array.isArray(args.memory_refs) ? args.memory_refs : []) addMemoryId(report, ref);
125
+ report.esrUsed = true;
126
+ anchored = true;
127
+ } else if (name === "esr_link" || name === "esr_dep" || name === "esr_claim" || name === "esr_unclaim") {
128
+ for (const key of ["source", "target", "task_id", "dep_id", "entity_id"]) {
129
+ if (typeof args[key] === "string" && args[key].trim()) addSet(report.entityIds, args[key]);
130
+ }
131
+ report.esrUsed = true;
132
+ anchored = true;
133
+ } else if (name === "esr_node") {
134
+ // The created node id is deterministically `ent_<slug(name)>`.
135
+ if (typeof args.name === "string" && args.name.trim()) addSet(report.entityIds, `ent_${slugId(args.name)}`);
136
+ report.esrUsed = true;
137
+ anchored = true;
138
+ } else if (name === "esr_ready" || name === "esr_model") {
139
+ report.esrUsed = true;
140
+ anchored = true;
141
+ }
142
+
143
+ // Echo enrichment: ids always appear in the tool's result text too.
144
+ addMemoryIdsFromText(report, resultText);
145
+ addEntityIdsFromText(report, resultText);
146
+ return anchored;
147
+ }
148
+
149
+ /**
150
+ * Scan a DSH message array (the evicted shadowed region, surface order) for
151
+ * provenance anchors. Pure + deterministic; message shapes are the DSH
152
+ * `Message`/`ContentBlock` vocabulary (`tool-call` on assistant messages,
153
+ * `tool-result` on user messages, correlated by `toolCallId`).
154
+ *
155
+ * @returns {{ memoryIds, memoryMarkers, recallQueries, entityQueries,
156
+ * entityIds, filePaths, anchoredMessageIndices, esrUsed, engramUsed }}
157
+ */
158
+ export function scanMessages(messages) {
159
+ const report = {
160
+ memoryIds: new Set(),
161
+ memoryMarkers: new Set(),
162
+ recallQueries: [],
163
+ entityQueries: [],
164
+ entityIds: new Set(),
165
+ filePaths: new Set(),
166
+ anchoredMessageIndices: new Set(),
167
+ esrUsed: false,
168
+ engramUsed: false,
169
+ };
170
+
171
+ const list = Array.isArray(messages) ? messages : [];
172
+
173
+ // Pre-correlate toolCallId -> result text for conditional confirmation.
174
+ const resultByCallId = new Map();
175
+ for (const msg of list) {
176
+ for (const block of Array.isArray(msg?.content) ? msg.content : []) {
177
+ if (block?.type === "tool-result") resultByCallId.set(block.toolCallId, blockText(block.content));
178
+ }
179
+ }
180
+
181
+ for (let i = 0; i < list.length; i += 1) {
182
+ const msg = list[i];
183
+ if (msg === null || typeof msg !== "object") continue;
184
+ let anchored = false;
185
+
186
+ // Assistant messages carry the tool-call blocks.
187
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
188
+ for (const block of msg.content) {
189
+ if (!block || block.type !== "tool-call") continue;
190
+ let args = {};
191
+ if (typeof block.arguments === "string" && block.arguments.length > 0) {
192
+ try {
193
+ args = JSON.parse(block.arguments);
194
+ } catch {
195
+ args = {};
196
+ }
197
+ }
198
+ const resultText = resultByCallId.get(block.id) ?? "";
199
+ if (scanToolCall(report, block.name ?? "", args, resultText)) anchored = true;
200
+ }
201
+ }
202
+
203
+ // Tool-result messages can carry the same anchors standalone (e.g. a
204
+ // split turn where the call left the evicted span) — but only when they
205
+ // actually echo re-fetchable ids. A generic `bash`/`read` output with no
206
+ // engram/ESR anchor must NOT mark the turn provenanced (no-provenance-
207
+ // no-evict), so the narrative safety net survives tool-heavy sessions.
208
+ if (Array.isArray(msg.content)) {
209
+ for (const block of msg.content) {
210
+ if (block?.type !== "tool-result") continue;
211
+ const beforeMem = report.memoryIds.size + report.memoryMarkers.size;
212
+ const beforeEnt = report.entityIds.size;
213
+ const resultText = blockText(block.content);
214
+ addMemoryIdsFromText(report, resultText);
215
+ addEntityIdsFromText(report, resultText);
216
+ const foundIds = report.memoryIds.size + report.memoryMarkers.size > beforeMem
217
+ || report.entityIds.size > beforeEnt;
218
+ if (foundIds) anchored = true;
219
+ }
220
+ }
221
+
222
+ if (anchored) report.anchoredMessageIndices.add(i);
223
+ }
224
+
225
+ return report;
226
+ }
227
+
228
+ /** Is this message a tool-result splice (DSH: single `tool-result` block)? */
229
+ export function isToolResultMessage(msg) {
230
+ if (msg === null || typeof msg !== "object" || msg.role !== "user") return false;
231
+ if (!Array.isArray(msg.content) || msg.content.length !== 1) return false;
232
+ return msg.content[0]?.type === "tool-result";
233
+ }
234
+
235
+ /**
236
+ * Group messages into turns. A turn opens at a genuine user message (not a
237
+ * tool-result splice) or at index 0, and absorbs the tool-call/result
238
+ * sequence that follows. A turn is `anchored` when any of its messages carries
239
+ * a provenance anchor.
240
+ */
241
+ export function groupTurns(messages, anchoredIndices) {
242
+ const anchored = anchoredIndices instanceof Set ? anchoredIndices : new Set();
243
+ const list = Array.isArray(messages) ? messages : [];
244
+ const turns = [];
245
+ let cur = null;
246
+ for (let i = 0; i < list.length; i += 1) {
247
+ const opensTurn = cur === null || (list[i]?.role === "user" && !isToolResultMessage(list[i]));
248
+ if (opensTurn && cur !== null) cur.end = i;
249
+ if (opensTurn) {
250
+ cur = { start: i, end: list.length, anchored: false };
251
+ turns.push(cur);
252
+ }
253
+ if (anchored.has(i) && cur !== null) cur.anchored = true;
254
+ }
255
+ return turns;
256
+ }
257
+
258
+ /**
259
+ * Read the workspace's task/memory surface to classify the referenced ids
260
+ * (best-effort, zero-LLM). `known` memories are re-fetchable right now and are
261
+ * the only ones worth an explicit `engram_detail` pointer (pointer-salience);
262
+ * unknown ids are dropped rather than offered as dead re-fetch calls.
263
+ */
264
+ export function collectPointerContext(domain, workspace, report) {
265
+ const context = {
266
+ label: workspace ? basename(String(workspace).replace(/[\\/]+$/, "")) : "this workspace",
267
+ activeTasks: [],
268
+ stableTaskCount: 0,
269
+ memoryIndex: new Map(), // marker/full-id -> { kind, text }
270
+ };
271
+ if (!domain || !workspace) return context;
272
+ try {
273
+ const tasks = domain.listTasks?.(workspace, { includeStable: true }) ?? [];
274
+ context.stableTaskCount = tasks.filter((t) => t.state === "stable").length;
275
+ context.activeTasks = tasks
276
+ .filter((t) => t.state === "active")
277
+ .map((t) => ({ id: String(t.id), name: String(t.name ?? "").slice(0, 72) }))
278
+ .slice(0, 8);
279
+ const pool = domain.listMemories?.(workspace, 200) ?? [];
280
+ const resolvedIds = new Set();
281
+ for (const id of [...report.memoryIds, ...report.memoryMarkers]) {
282
+ if (context.memoryIndex.has(id)) continue;
283
+ const bare = String(id).replace(/^#/, "");
284
+ const hit = pool.find((m) => m.id === id || m.id.slice(0, 8) === bare || m.id.startsWith(bare));
285
+ // Skip already-resolved ids (a full id and its #marker are the same memory).
286
+ if (!hit || resolvedIds.has(hit.id)) continue;
287
+ resolvedIds.add(hit.id);
288
+ context.memoryIndex.set(id, { kind: hit.kind, text: String(hit.text).slice(0, 60) });
289
+ }
290
+ } catch {
291
+ // best-effort: pointer summary still runs from the report alone
292
+ }
293
+ return context;
294
+ }
295
+
296
+ /**
297
+ * Build the pointer summary that REPLACES the default LLM-compressed summary
298
+ * body. Terse, deterministic, every category carries an explicit re-fetch
299
+ * call. The narrative block is appended by the caller.
300
+ */
301
+ export function buildPointerSummary(report, context, { turns = [], label = "this workspace" } = {}) {
302
+ const lines = [
303
+ "## Context GC — evicted detail is RE-FETCHABLE (do not re-derive)",
304
+ "",
305
+ "Older conversation was evicted to free context. The detail is NOT lost: it lives in the engram memory",
306
+ "store and the ESR task store. Re-fetch ONLY when you actually need it, using the tools below.",
307
+ "",
308
+ ];
309
+
310
+ const provenanced = turns.filter((t) => t.anchored).length;
311
+ const unprovenanced = turns.length - provenanced;
312
+ const what = turns.length === 1 ? "turn" : "turns";
313
+ let evictedLine = `Evicted ${turns.length} ${what}.`;
314
+ if (turns.length > 0) {
315
+ evictedLine += ` ${provenanced} with re-fetchable provenance, ${unprovenanced} narrative-only.`;
316
+ }
317
+ lines.push(evictedLine);
318
+ lines.push("");
319
+
320
+ const ctx = context ?? {};
321
+
322
+ // ── ESR task/entity pointers ─────────────────────────────────────────
323
+ const activeTasks = Array.isArray(ctx.activeTasks) ? ctx.activeTasks : [];
324
+ const stableCount = typeof ctx.stableTaskCount === "number" ? ctx.stableTaskCount : 0;
325
+ const esrEntities = report?.entityIds?.size ? [...report.entityIds].slice(0, 8) : [];
326
+ if (report?.esrUsed === true || activeTasks.length > 0 || esrEntities.length > 0) {
327
+ lines.push("### ESR task state");
328
+ lines.push("Reload the full graph with the [ESR] block / `esr_ready` — only re-fetch what you need.");
329
+ if (activeTasks.length > 0) {
330
+ lines.push("- Active working set (still tracked in ESR):");
331
+ for (const t of activeTasks) lines.push(` - \`${t.id}\` — ${t.name}`);
332
+ }
333
+ if (stableCount > 0) {
334
+ lines.push(`- ${stableCount} closed task(s) retained — details stay re-fetchable via the [ESR] block.`);
335
+ }
336
+ if (esrEntities.length > 0) {
337
+ lines.push(`- Referenced entities: ${esrEntities.map((e) => `\`${e}\``).join(", ")}`);
338
+ }
339
+ lines.push("");
340
+ }
341
+
342
+ // ── engram memory pointers ───────────────────────────────────────────
343
+ const memoryIndex = ctx.memoryIndex instanceof Map ? ctx.memoryIndex : new Map();
344
+ const knownIds = [...memoryIndex.keys()];
345
+ const recallQueries = (report?.recallQueries ?? []).slice(0, 5);
346
+ const entityQueries = (report?.entityQueries ?? []).slice(0, 5);
347
+ const filePaths = report?.filePaths?.size ? [...report.filePaths].slice(0, 3) : [];
348
+ if (report?.engramUsed === true || knownIds.length > 0 || recallQueries.length > 0 || entityQueries.length > 0 || filePaths.length > 0) {
349
+ lines.push("### engram memories (re-fetchable)");
350
+ if (knownIds.length > 0) {
351
+ lines.push("Fetch the exact records that are still in the store:");
352
+ for (const id of knownIds.slice(0, 10)) {
353
+ const meta = memoryIndex.get(id);
354
+ const kind = meta?.kind ? ` [${meta.kind}]` : "";
355
+ lines.push(`- \`engram_detail(id: "${id}")\`${kind}`);
356
+ }
357
+ }
358
+ for (const q of recallQueries) lines.push(`- Re-run recall: \`engram_recall(query: ${JSON.stringify(q)})\``);
359
+ for (const e of entityQueries) lines.push(`- Re-run timeline: \`engram_recall(entity: ${JSON.stringify(e)})\``);
360
+ if (filePaths.length > 0) {
361
+ lines.push(`- File-anchored detail lives near: ${filePaths.map((p) => `\`${p}\``).join(", ")}`);
362
+ }
363
+ if (knownIds.length === 0 && recallQueries.length === 0 && entityQueries.length === 0) {
364
+ lines.push("- No separately re-fetchable memory ids in the evicted span; the narrative below carries it.");
365
+ }
366
+ lines.push("");
367
+ }
368
+
369
+ return lines.join("\n");
370
+ }
371
+
372
+ /**
373
+ * Compact serialization of un-provenanced messages for the narrative fallback.
374
+ * Tool arguments are included verbatim (they are the re-derivable surface).
375
+ */
376
+ export function serializeMessages(messages, { maxChars = 4000 } = {}) {
377
+ const lines = [];
378
+ const list = Array.isArray(messages) ? messages : [];
379
+ for (const msg of list) {
380
+ const role = msg?.role ?? "?";
381
+ const blocks = Array.isArray(msg?.content) ? msg.content : [];
382
+ if (role === "assistant") {
383
+ for (const b of blocks) {
384
+ if (b?.type === "text") lines.push(`assistant: ${b.text}`);
385
+ else if (b?.type === "reasoning") lines.push(`assistant (thinking): ${b.text}`);
386
+ else if (b?.type === "tool-call") lines.push(`assistant → ${b.name}(${b.arguments})`);
387
+ }
388
+ } else if (role === "user") {
389
+ const toolResult = blocks.find((b) => b?.type === "tool-result");
390
+ if (toolResult) lines.push(`tool result: ${blockText(toolResult.content)}`);
391
+ else for (const b of blocks) if (b?.type === "text") lines.push(`user: ${b.text}`);
392
+ }
393
+ }
394
+ const text = lines.join("\n");
395
+ if (text.length <= maxChars) return text;
396
+ return `${text.slice(0, maxChars)}\n…[truncated, ${text.length} chars total]`;
397
+ }
398
+
399
+ /**
400
+ * Narrative for un-provenanced turns (the `no-provenance-no-evict` safety
401
+ * net). With `enabled` and a working `callLlm`, a scoped summary is produced;
402
+ * otherwise — or on any failure — the turns are kept as truncated verbatim so
403
+ * detail is never silently dropped. Pure aside from the injected `callLlm`.
404
+ *
405
+ * @param callLlm(messageSlice, signal) → { text, model?, llm? } | null
406
+ */
407
+ export async function summarizeUnprovenanced(messages, { enabled = true, maxChars = 4000, signal, callLlm } = {}) {
408
+ const list = Array.isArray(messages) ? messages : [];
409
+ if (list.length === 0) {
410
+ return { text: "_None — all evicted turns had re-fetchable provenance._", generated: false };
411
+ }
412
+ const fallback = () => ({ text: serializeMessages(list, { maxChars }), generated: false });
413
+ if (enabled !== true || typeof callLlm !== "function") return fallback();
414
+ try {
415
+ const result = await callLlm(list, signal);
416
+ if (!result || typeof result.text !== "string" || result.text.trim().length === 0) return fallback();
417
+ return { text: result.text.trim(), generated: true, model: result.model, llm: result.llm };
418
+ } catch {
419
+ return fallback();
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Build the Context GC replacement summary (pointer block + narrative). This
425
+ * is the text that lands inside DSH's `<compacted-summary>` checkpoint node.
426
+ */
427
+ export function buildContextGcSummary(report, context, { turns, narrative }) {
428
+ const label = context?.label ?? "this workspace";
429
+ const pointer = buildPointerSummary(report, context, { turns, label });
430
+ const parts = [
431
+ pointer,
432
+ "### Narrative not captured elsewhere",
433
+ narrative.text,
434
+ "",
435
+ "---",
436
+ "_Context GC: detail above is re-fetchable via the listed tools. Prefer re-fetching over re-deriving._",
437
+ ];
438
+ return parts.join("\n");
439
+ }
440
+
441
+ /**
442
+ * Load a mountable compaction engine CLASS for the host/preset plane.
443
+ *
444
+ * `mode: "context-gc"` (default) returns the ContextGcEngine subclass;
445
+ * `mode: "default"` returns a thin BasicCompactionEngine subclass (identical
446
+ * default LLM summarizer) — used when `gcReplacesCompaction` is off AFTER the
447
+ * base `compaction-basic` row was disabled by the profile patch, so the
448
+ * `compaction` service never disappears while dsh-engram is installed.
449
+ *
450
+ * Returns `null` when `@deepseek-ai/dsh-compaction-basic` is not importable
451
+ * (graceful — the host keeps whatever compaction it still has).
452
+ */
453
+ export async function loadCompactionEngine(ctx, config = {}, deps = {}, { mode = "context-gc" } = {}) {
454
+ const mod = await import("@deepseek-ai/dsh-compaction-basic").catch(() => null);
455
+ if (!mod?.BasicCompactionEngine) return null;
456
+ if (mode === "default") {
457
+ // The plain-default mode: construct BasicCompactionEngine verbatim (it IS
458
+ // the shipped default LLM summarizer), so when the base row was disabled
459
+ // by the profile patch the `compaction` service still exists.
460
+ return class DefaultCompactionEngine extends mod.BasicCompactionEngine {
461
+ constructor() {
462
+ super(ctx, { auto: true, ...config });
463
+ }
464
+ };
465
+ }
466
+ return loadContextGcEngine(ctx, config, deps);
467
+ }
468
+
469
+ /**
470
+ * Load the DSH binding: a `ContextGcEngine extends BasicCompactionEngine`
471
+ * whose sole override is `summarize()`. Returns `null` when
472
+ * `@deepseek-ai/dsh-compaction-basic` is not importable (graceful — the host
473
+ * keeps its default compaction).
474
+ *
475
+ * @param ctx - cordis context the engine mounts `compaction` service on.
476
+ * @param config - BasicCompactionConfig (policy; `auto` defaults true).
477
+ * @param deps - { readWorkspace(ws, report), narrativeEnabled, narrativeMaxChars,
478
+ * narrativeMaxTokens, narrativeLlm?, defaultSummarize?, log? }
479
+ * @returns the engine CLASS (construct with `new Engine()`), or null.
480
+ */
481
+ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
482
+ let mod;
483
+ try {
484
+ mod = await import("@deepseek-ai/dsh-compaction-basic");
485
+ } catch {
486
+ return null;
487
+ }
488
+ const Basic = mod?.BasicCompactionEngine;
489
+ if (typeof Basic !== "function") return null;
490
+
491
+ const narrativeEnabled = deps.narrativeEnabled !== false;
492
+ const narrativeMaxChars = deps.narrativeMaxChars ?? 4000;
493
+ const narrativeMaxTokens = deps.narrativeMaxTokens ?? 1024;
494
+ const engineConfig = { auto: true, ...config };
495
+
496
+ return class ContextGcEngine extends Basic {
497
+ constructor() {
498
+ super(ctx, engineConfig);
499
+ this.gcDeps = deps;
500
+ }
501
+
502
+ /** The one seam we take over: produce the checkpoint summary. */
503
+ async summarize(input, agent, signal) {
504
+ try {
505
+ return await this._contextGc(input, agent, signal);
506
+ } catch (error) {
507
+ const logger = this.ctx?.logger;
508
+ const message = error instanceof Error ? error.message : String(error);
509
+ if (typeof logger?.warn === "function") logger.warn(`engram context-gc failed (${message}); falling back to default compaction`);
510
+ if (typeof this.gcDeps.defaultSummarize === "function") {
511
+ return this.gcDeps.defaultSummarize(input, agent, signal);
512
+ }
513
+ return super.summarize(input, agent, signal);
514
+ }
515
+ }
516
+
517
+ async _contextGc(input, agent, signal) {
518
+ const messages = Array.isArray(input?.messages) ? input.messages : [];
519
+ const report = scanMessages(messages);
520
+ const turns = groupTurns(messages, report.anchoredMessageIndices);
521
+
522
+ const ws = agent?.session?.header?.cwd;
523
+ const context = typeof this.gcDeps.readWorkspace === "function"
524
+ ? await this.gcDeps.readWorkspace(ws, report)
525
+ : undefined;
526
+ const safeContext = context ?? { label: ws ? basename(String(ws).replace(/[\\/]+$/, "")) : "this workspace" };
527
+
528
+ const unprovenanced = turns
529
+ .filter((t) => !t.anchored)
530
+ .flatMap((t) => messages.slice(t.start, t.end));
531
+
532
+ const callLlm = narrativeEnabled
533
+ ? (slice, sig) => (typeof this.gcDeps.narrativeLlm === "function"
534
+ ? this.gcDeps.narrativeLlm(slice, agent, sig)
535
+ : this._defaultNarrativeLlm(slice, agent, sig))
536
+ : undefined;
537
+
538
+ const narrative = await summarizeUnprovenanced(unprovenanced, {
539
+ enabled: narrativeEnabled,
540
+ maxChars: narrativeMaxChars,
541
+ signal,
542
+ callLlm,
543
+ });
544
+
545
+ const summaryText = buildContextGcSummary(report, safeContext, { turns, narrative });
546
+ const result = { summary: [{ type: "text", text: summaryText }] };
547
+ if (narrative.llm && typeof narrative.llm === "object") Object.assign(result, narrative.llm);
548
+ return result;
549
+ }
550
+
551
+ /**
552
+ * Default scoped narrative through the host's own LLM seam
553
+ * (`ctx.llm.stream`, `purpose: 'compaction'`, prefix-cache friendly).
554
+ */
555
+ async _defaultNarrativeLlm(slice, agent, signal) {
556
+ try {
557
+ const llm = this.ctx?.llm;
558
+ if (!llm || typeof llm.stream !== "function") return { text: "" };
559
+ const dshLlm = await import("@deepseek-ai/dsh-llm");
560
+ const target = this._narrativeTarget(agent);
561
+ if (!target) return { text: "" };
562
+
563
+ const instruction = [
564
+ "You are a context-transfer assistant. The turns below were evicted from context and have NO",
565
+ "re-fetchable provenance (no engram memory, no ESR task, no file anchor) — that is why they need a",
566
+ "narrative. Everything re-fetchable has already been replaced by pointers elsewhere.",
567
+ "",
568
+ "Produce a CONCISE narrative summary (max ~250 words): what the user asked, what was explored or",
569
+ "decided, and any open thread. No preamble.",
570
+ ].join("\n");
571
+
572
+ const messages = [
573
+ ...slice,
574
+ dshLlm.createUserMessage({
575
+ content: [{ type: "text", text: instruction }],
576
+ source: { kind: "plugin", plugin: "dsh-engram" },
577
+ }),
578
+ ];
579
+ const assembler = new dshLlm.BlockAssembler();
580
+ const options = {
581
+ provider: target.provider,
582
+ model: target.model,
583
+ messages,
584
+ maxTokens: narrativeMaxTokens,
585
+ sessionId: agent?.session?.id,
586
+ purpose: "compaction",
587
+ ...(signal === undefined ? {} : { signal }),
588
+ };
589
+ for await (const chunk of llm.stream(options)) assembler.push(chunk);
590
+ const rawOutput = assembler.blocks();
591
+ const text = rawOutput
592
+ .filter((b) => b?.type === "text")
593
+ .map((b) => b.text)
594
+ .join("\n")
595
+ .trim();
596
+ if (text.length === 0) return { text: "" };
597
+ return {
598
+ text,
599
+ model: target.model,
600
+ llm: {
601
+ rawOutput,
602
+ llmStreamCall: true,
603
+ provider: target.provider,
604
+ model: target.model,
605
+ maxTokens: narrativeMaxTokens,
606
+ },
607
+ };
608
+ } catch {
609
+ return { text: "" };
610
+ }
611
+ }
612
+
613
+ /** Resolve the summarization route: configured → latest routed → agent options. */
614
+ _narrativeTarget(agent) {
615
+ const latest = agent?.session?.requestHeader?.()?.config;
616
+ if (latest && latest.provider?.length && latest.model?.length) {
617
+ return { provider: latest.provider, model: latest.model };
618
+ }
619
+ const agentTarget = agent?.options;
620
+ if (agentTarget && agentTarget.provider?.length && agentTarget.model?.length) {
621
+ return { provider: agentTarget.provider, model: agentTarget.model };
622
+ }
623
+ return undefined;
624
+ }
625
+ };
626
+ }