dsh-engram 0.7.0 → 0.7.1

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.
package/lib/index.js CHANGED
@@ -24,7 +24,7 @@ import { registerTools } from "./tools.js";
24
24
  import { makeCaptureHandler } from "./capture.js";
25
25
  import { installGoalCapture } from "./goal-capture.js";
26
26
  import { installPreferenceCapture } from "./preference.js";
27
- import { installBoundaryPrune } from "./boundary-prune.js";
27
+ import { boundaryPruneAvailability, installBoundaryPrune } from "./boundary-prune.js";
28
28
  import { resultPackDir, archivePrunedResults, resultRecallTool, sweepExpired } from "./result-pack.js";
29
29
  import { recordBoundaryPruneFire, recordRecallInjection, readPackTelemetry, engramHome } from "./pack-telemetry.js";
30
30
  import { createLedger, estimatePrefixChars } from "./ledger.js";
@@ -359,6 +359,27 @@ export function apply(ctx, config) {
359
359
  log,
360
360
  });
361
361
 
362
+ // Expose the ledger to the per-session web plane. Agent presets mount
363
+ // `dsh-engram/compaction` in their own isolate realm, so a closure from this
364
+ // apply() is unreachable there; a plain (non-isolated) service value is the
365
+ // bridge: the row credits compaction savings through it and `ledger` stays
366
+ // the single writer of the durable rollup.
367
+ if (ledger !== null) {
368
+ try {
369
+ // `ctx.provide` is fiber-scoped (cordis removes the service when this
370
+ // plugin unloads), so the returned disposer needs no bookkeeping here.
371
+ ctx.provide?.("engramLedger", {
372
+ noteCompaction: (sessionId, workspace, opts) => ledger.noteCompaction(sessionId, workspace, opts),
373
+ notePrune: (sessionId, workspace, opts) => ledger.notePrune(sessionId, workspace, opts),
374
+ noteRewrite: (sessionId, workspace, opts) => ledger.noteRewrite(sessionId, workspace, opts),
375
+ summary: () => ledger.summary(),
376
+ flush: () => ledger.flush(),
377
+ });
378
+ } catch (error) {
379
+ log?.warn?.(`engram ledger service unavailable: ${String(error?.message ?? error)}`);
380
+ }
381
+ }
382
+
362
383
  // Ledger-driven off-switch for BoundaryPrune (PROPOSAL-evolution v0.6-B):
363
384
  // per-workspace, 30s-cached, reads the same summary /stats exposes. Evidence
364
385
  // can only turn pruning OFF. No ledger → allow (repayment gate still runs).
@@ -422,7 +443,9 @@ export function apply(ctx, config) {
422
443
  /** Live Context GC status (API overview + durable status file). */
423
444
  compactionStatus: {
424
445
  host: "pending",
425
- web: { action: "pending", presets: [] },
446
+ // `disabled` (not `pending`) when the web opt-in is off: the status file
447
+ // must not look like a wiring run that never finished.
448
+ web: resolved.autoWebCompaction === false ? { action: "disabled", presets: [] } : { action: "pending", presets: [] },
426
449
  config: { autoWebCompaction: resolved.autoWebCompaction, gcReplacesCompaction: resolved.gcReplacesCompaction, gcNarrative: resolved.gcNarrative },
427
450
  },
428
451
  /** Recompute the exact [ENGRAM] block for a workspace (GUI overview cost). */
@@ -666,6 +689,16 @@ export function apply(ctx, config) {
666
689
  return null;
667
690
  }
668
691
  },
692
+ // Context Ledger credit: the Context GC engine measures the span it
693
+ // replaced with a checkpoint (`span − summary` chars) and reports it
694
+ // here; the ledger stops the "savings are always 0" asymmetry.
695
+ onCompacted: ({ session, workspace, charsSaved }) => {
696
+ try {
697
+ ledger?.noteCompaction(session?.id, workspace ?? wk(session?.header?.cwd), { charsSaved });
698
+ } catch (error) {
699
+ log?.warn?.(`engram ledger compaction credit failed: ${String(error)}`);
700
+ }
701
+ },
669
702
  });
670
703
  disposers.push(() => {
671
704
  // The engine's service registration is fiber-scoped and auto-removed on
@@ -680,6 +713,10 @@ export function apply(ctx, config) {
680
713
  let hostSettled = false;
681
714
  let webSettled = resolved.autoWebCompaction === false;
682
715
  let statusTimer = void 0;
716
+ // Authoritative L1 availability, captured where BoundaryPrune is actually
717
+ // installed (the pruner may land after the first status flush on a slow
718
+ // boot; the live check is only a fallback).
719
+ let boundaryPruneState = void 0;
683
720
  const flushStatus = () => {
684
721
  if (!hostSettled || !webSettled) return;
685
722
  if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
@@ -687,6 +724,9 @@ export function apply(ctx, config) {
687
724
  planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
688
725
  host: service.compactionStatus.host,
689
726
  web: service.compactionStatus.web,
727
+ // L1 availability is the missing credit path behind a "节省 always 0"
728
+ // ledger: record whether BoundaryPrune can actually fire here.
729
+ boundaryPrune: boundaryPruneState ?? boundaryPruneAvailability(ctx, resolved),
690
730
  config: service.compactionStatus.config,
691
731
  configSource: {
692
732
  autoWebCompaction: resolved.autoWebCompaction,
@@ -713,12 +753,15 @@ export function apply(ctx, config) {
713
753
  // inside the agent preset's own realm, which a profile patch cannot reach.
714
754
  // When the `agentPresets` service is present, swap the stock
715
755
  // `compaction-basic` row for `dsh-engram/compaction` in every stock-layout
716
- // preset — the default one plus the whole roster (shipped + user roots) —
717
- // so a session composed from any preset gets Context GC. Surgical,
718
- // idempotent, backed up, validated; a preset with a custom compaction
719
- // layout (or none) is never touched. The row's engine config is derived
720
- // from the settings knobs (`gcReplacesCompaction` / `gcNarrative`), so
721
- // changing them re-provisions the web rows on the next boot. Flip
756
+ // preset. The row is located structurally (indentation-agnostic) inside the
757
+ // `compaction` group, so both preset generations are covered: legacy
758
+ // compositions with `preset.path` (DSH ≤ 0.1.x) and the declarative
759
+ // `@deepseek-ai/dsh-web-app/presets/<id>.patch.yml` files (DSH ≥ 0.2.0,
760
+ // where the row sits at 14 spaces and the id maps to the shipped file).
761
+ // Surgical, idempotent, backed up, validated; a preset with a custom
762
+ // compaction layout (or none) is never touched. The row's engine config is
763
+ // derived from the settings knobs (`gcReplacesCompaction` / `gcNarrative`),
764
+ // so changing them re-provisions the web rows on the next boot. Flip
722
765
  // `autoWebCompaction:false` to stop auto-wiring on later boots (it does
723
766
  // not un-wire already-wired presets — use `npm run web-compaction:revert`
724
767
  // for that). Best-effort: a failure only warns.
@@ -1010,6 +1053,9 @@ export function apply(ctx, config) {
1010
1053
  },
1011
1054
  }, { allowFire: boundaryGate });
1012
1055
  disposers.push(disposeBoundaryPrune);
1056
+ // BoundaryPrune is installed here, so this is the moment L1 provably can
1057
+ // (or cannot) fire — record it for the durable status snapshot.
1058
+ boundaryPruneState = boundaryPruneAvailability(ctx, resolved);
1013
1059
 
1014
1060
  // ResultPack L2 tool (default ON with L1 since v0.6): `result_recall(callId,
1015
1061
  // offset?)` pages back an archived middle. Independent of the memory
@@ -26,19 +26,40 @@
26
26
  * "stock" status fail (status "custom"/"layout-mismatch") and the provisioner
27
27
  * leaves the file alone instead of guessing.
28
28
  *
29
+ * Two preset generations are supported, and the row matching is
30
+ * INDENTATION-AGNOSTIC (the row is located structurally inside the
31
+ * `compaction` group), because the same row lives at different depths:
32
+ *
33
+ * - DSH ≤ 0.1.x — one composition per preset:
34
+ * `<root>/<id>/agent.cordis.yml` (row at 4 spaces), `preset.path` is known;
35
+ * - DSH ≥ 0.2.0 — declarative presets, one patch file per preset:
36
+ * `@deepseek-ai/dsh-web-app/presets/<id>.patch.yml` (row at 14 spaces
37
+ * inside the `- insert:` operation), and `preset.path` does not exist — the
38
+ * id is mapped to the shipped file by {@link discoverPresetsDir}.
39
+ *
29
40
  * Flip `autoWebCompaction:false` to opt out; `revertWebCompaction` restores
30
41
  * the stock row on uninstall.
31
42
  * @module dsh-engram/web-provision
32
43
  */
33
44
 
34
- import { readFile, writeFile } from "node:fs/promises";
45
+ import { readFile, writeFile, readdir } from "node:fs/promises";
46
+ import { existsSync, realpathSync } from "node:fs";
47
+ import { createRequire } from "node:module";
48
+ import { dirname, join } from "node:path";
35
49
 
36
50
  /** The engine package subpath a preset row mounts dsh-engram's engine under. */
37
51
  export const ENGRAM_ROW_NAME = "dsh-engram/compaction";
38
52
 
39
- /** The stock row the shipped `standard` preset mounts inside `compaction`. */
40
- export const STOCK_COMPACTION_ROW =
41
- " - id: compaction-basic\n name: '@deepseek-ai/dsh-compaction-basic'";
53
+ /** The stock row name the shipped presets mount inside the `compaction` group. */
54
+ export const STOCK_ROW_NAME = "'@deepseek-ai/dsh-compaction-basic'";
55
+
56
+ /** Package name part of {@link STOCK_ROW_NAME} (quote-free, for matching). */
57
+ const STOCK_NAME = "@deepseek-ai/dsh-compaction-basic";
58
+
59
+ /** Row ids the provisioner looks for (list-item `- id:` values). */
60
+ const STOCK_ROW_ID = "compaction-basic";
61
+ const ENGRAM_ROW_ID = "engram-compaction";
62
+ const COMPACTION_GROUP_ID = "compaction";
42
63
 
43
64
  /** Engine config the plugin writes into each wired preset row (settings-driven). */
44
65
  export const DEFAULT_ROW_CONFIG = Object.freeze({
@@ -47,19 +68,37 @@ export const DEFAULT_ROW_CONFIG = Object.freeze({
47
68
  });
48
69
 
49
70
  /**
50
- * The engram row text for a given engine config (indentation must match the
51
- * stock preset: `- id:` at 4 spaces, keys at 6, sub-keys at 8).
71
+ * The stock `compaction-basic` row at a given indentation (the row sits at
72
+ * different depths across preset generations: 4 in DSH ≤ 0.1.x composition
73
+ * files, 14 in the DSH ≥ 0.2.0 declarative patch files).
74
+ * @param {number} [indent] - leading spaces before `- id:`.
75
+ * @returns {string}
76
+ */
77
+ export function stockCompactionRowText(indent = 4) {
78
+ const pad = " ".repeat(indent);
79
+ return `${pad}- id: ${STOCK_ROW_ID}\n${pad} name: ${STOCK_ROW_NAME}`;
80
+ }
81
+
82
+ /** The legacy 4-space stock row (back-compat export). */
83
+ export const STOCK_COMPACTION_ROW = stockCompactionRowText(4);
84
+
85
+ /**
86
+ * The engram row text for a given engine config. Indentation must match the
87
+ * stock row it replaces (`- id:` at `indent`, keys at `indent + 2`, sub-keys
88
+ * at `indent + 4`).
52
89
  * @param {{ gcReplacesCompaction?: boolean; gcNarrative?: boolean }} [config]
90
+ * @param {number} [indent] - leading spaces before `- id:`.
53
91
  * @returns {string}
54
92
  */
55
- export function engramCompactionRowText(config = {}) {
93
+ export function engramCompactionRowText(config = {}, indent = 4) {
56
94
  const { gcReplacesCompaction = true, gcNarrative = true } = config;
95
+ const pad = " ".repeat(indent);
57
96
  return [
58
- " - id: engram-compaction",
59
- " name: dsh-engram/compaction",
60
- " config:",
61
- ` gcReplacesCompaction: ${gcReplacesCompaction}`,
62
- ` gcNarrative: ${gcNarrative}`,
97
+ `${pad}- id: ${ENGRAM_ROW_ID}`,
98
+ `${pad} name: dsh-engram/compaction`,
99
+ `${pad} config:`,
100
+ `${pad} gcReplacesCompaction: ${gcReplacesCompaction}`,
101
+ `${pad} gcNarrative: ${gcNarrative}`,
63
102
  ].join("\n");
64
103
  }
65
104
 
@@ -81,6 +120,109 @@ function codeOf(error) {
81
120
  : undefined;
82
121
  }
83
122
 
123
+ /** Leading-space width of one line. */
124
+ function indentOf(line) {
125
+ return line.length - line.trimStart().length;
126
+ }
127
+
128
+ /** `{ indent, id }` for a YAML list item line `- id: <id>`, else undefined. */
129
+ function itemOf(line) {
130
+ const match = /^(\s*)- id:\s*['"]?([^'"\s#]+)['"]?\s*$/.exec(line);
131
+ if (match === null) return undefined;
132
+ return { indent: match[1].length, id: match[2] };
133
+ }
134
+
135
+ /**
136
+ * End (exclusive line index) of the YAML block starting at `start`: the first
137
+ * blank line, or the first line indented no deeper than the item itself.
138
+ * @param {string[]} lines
139
+ * @param {number} start
140
+ */
141
+ function blockEnd(lines, start) {
142
+ const indent = indentOf(lines[start]);
143
+ let end = start + 1;
144
+ while (end < lines.length) {
145
+ const line = lines[end];
146
+ if (line.trim() === "") break;
147
+ if (indentOf(line) <= indent) break;
148
+ end += 1;
149
+ }
150
+ return end;
151
+ }
152
+
153
+ /** The `name:` value of the item block starting at `index` (quotes stripped). */
154
+ function nameOf(lines, index) {
155
+ const end = blockEnd(lines, index);
156
+ for (let i = index + 1; i < end; i += 1) {
157
+ const match = /^\s*name:\s*['"]?([^'"\s#]+)['"]?\s*$/.exec(lines[i]);
158
+ if (match !== null) return match[1];
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ /**
164
+ * Spans covering the items of every `compaction` group in the composition (a
165
+ * list item whose block carries `group: true`). The group exists in both
166
+ * preset generations; only its indentation differs, which is why nothing here
167
+ * depends on column positions.
168
+ * @param {string[]} lines
169
+ * @returns {Array<{ start: number; end: number }>}
170
+ */
171
+ function compactionGroupSpans(lines) {
172
+ const spans = [];
173
+ for (let i = 0; i < lines.length; i += 1) {
174
+ const head = itemOf(lines[i]);
175
+ if (head === undefined || head.id !== COMPACTION_GROUP_ID) continue;
176
+ const end = blockEnd(lines, i);
177
+ const isGroup = lines
178
+ .slice(i + 1, end)
179
+ .some((line) => /^\s*group:\s*true\s*$/.test(line));
180
+ if (isGroup) spans.push({ start: i + 1, end });
181
+ }
182
+ return spans;
183
+ }
184
+
185
+ /**
186
+ * Locate the stock and engram rows of a composition. Rows are validated by
187
+ * BOTH the `- id:` value and the `name:` value, so a hand-edited backend under
188
+ * the same id is never mistaken for a row we may rewrite.
189
+ * @param {string} text
190
+ * @returns {{ lines: string[]; stock: number[]; engram: number[]; ambiguous: boolean }}
191
+ */
192
+ function locateRows(text) {
193
+ const lines = text.split("\n");
194
+ const groups = compactionGroupSpans(lines);
195
+ // Two compaction groups in one composition: refuse to guess which one runs.
196
+ if (groups.length > 1) return { lines, stock: [], engram: [], ambiguous: true };
197
+ const span = groups[0] ?? { start: 0, end: lines.length, fallback: true };
198
+ const valid = (rowId, name) => {
199
+ const hits = [];
200
+ for (let i = span.start; i < span.end; i += 1) {
201
+ const head = itemOf(lines[i]);
202
+ if (head === undefined || head.id !== rowId) continue;
203
+ if (span.fallback === true && head.indent === 0) continue;
204
+ if (nameOf(lines, i) !== name) continue;
205
+ hits.push(i);
206
+ }
207
+ return hits;
208
+ };
209
+ return {
210
+ lines,
211
+ stock: valid(STOCK_ROW_ID, STOCK_NAME),
212
+ engram: valid(ENGRAM_ROW_ID, ENGRAM_ROW_NAME),
213
+ ambiguous: false,
214
+ };
215
+ }
216
+
217
+ /** Replace lines `[start, end)` with `replacement` (joined by newlines). */
218
+ function replaceBlock(lines, start, end, replacement) {
219
+ return [
220
+ ...lines.slice(0, start),
221
+ ...replacement.split("\n"),
222
+ ...lines.slice(end),
223
+ ].join("\n");
224
+ }
225
+
84
226
  /**
85
227
  * Classify a preset composition's compaction wiring.
86
228
  * - `wired` — already carries `dsh-engram/compaction`;
@@ -90,52 +232,23 @@ function codeOf(error) {
90
232
  * @returns {"wired" | "stock" | "custom"}
91
233
  */
92
234
  export function compactionWireStatus(text) {
93
- if (text.includes(ENGRAM_ROW_NAME)) return "wired";
94
- if (text.includes(STOCK_COMPACTION_ROW)) return "stock";
95
- return "custom";
235
+ const { stock, engram } = locateRows(text);
236
+ if (engram.length > 0) return stock.length > 0 ? "custom" : "wired";
237
+ return stock.length > 0 ? "stock" : "custom";
96
238
  }
97
239
 
98
240
  /** Post-swap sanity invariants the result must satisfy. */
99
241
  function invariantsOk(text) {
100
242
  return (
101
243
  text.includes(ENGRAM_ROW_NAME) &&
102
- !text.includes("@deepseek-ai/dsh-compaction-basic") &&
244
+ !text.includes(STOCK_NAME) &&
103
245
  text.includes("command-compact") &&
104
246
  text.includes("tool-result-pruner") &&
105
- text.includes("name: cordis:group") &&
247
+ text.includes("cordis:group") &&
106
248
  text.includes("isolate:")
107
249
  );
108
250
  }
109
251
 
110
- /**
111
- * Locate the engram row block (the two fixed lines plus any deeper-indented
112
- * `config:` lines) inside a wired composition.
113
- * @param {string} text
114
- * @returns {{ start: number; end: number } | null} line indices `[start, end)`
115
- */
116
- function engramRowBlockBounds(text) {
117
- const lines = text.split("\n");
118
- const start = lines.findIndex((line) => line === " - id: engram-compaction");
119
- if (start === -1) return null;
120
- let end = start + 1;
121
- while (end < lines.length) {
122
- const line = lines[end];
123
- if (line.trim() === "") break;
124
- if (line.startsWith(" - id:")) break;
125
- if (line.startsWith(" ")) { end += 1; continue; }
126
- break;
127
- }
128
- return { start, end };
129
- }
130
-
131
- /** Does the file already carry exactly the expected row text? */
132
- function rowMatches(text, rowText) {
133
- const bounds = engramRowBlockBounds(text);
134
- if (bounds === null) return false;
135
- const current = text.split("\n").slice(bounds.start, bounds.end).join("\n");
136
- return current === rowText;
137
- }
138
-
139
252
  /**
140
253
  * Produce the wired composition for `rowConfig`. Input may be `stock` (first
141
254
  * wiring: the stock row is replaced) or `wired` (refresh: the existing engram
@@ -147,26 +260,27 @@ function rowMatches(text, rowText) {
147
260
  * @returns {string | null}
148
261
  */
149
262
  export function swapCompactionRow(text, rowConfig = {}) {
150
- const rowText = engramCompactionRowText(rowConfig);
151
- const status = compactionWireStatus(text);
152
- if (status === "stock") {
153
- if (text.split(STOCK_COMPACTION_ROW).length - 1 !== 1) return null;
154
- const next = text.replace(STOCK_COMPACTION_ROW, rowText);
263
+ const { lines, stock, engram, ambiguous } = locateRows(text);
264
+ if (ambiguous) return null;
265
+ if (engram.length > 0) {
266
+ if (engram.length > 1 || stock.length > 0) return null;
267
+ const index = engram[0];
268
+ const end = blockEnd(lines, index);
269
+ const rowText = engramCompactionRowText(rowConfig, indentOf(lines[index]));
270
+ if (lines.slice(index, end).join("\n") === rowText) return text; // already this config
271
+ const next = replaceBlock(lines, index, end, rowText);
155
272
  return invariantsOk(next) ? next : null;
156
273
  }
157
- if (status === "wired") {
158
- if (rowMatches(text, rowText)) return text; // already exactly this config
159
- const bounds = engramRowBlockBounds(text);
160
- if (bounds === null) return null;
161
- const lines = text.split("\n");
162
- const next = [
163
- ...lines.slice(0, bounds.start),
164
- rowText,
165
- ...lines.slice(bounds.end),
166
- ].join("\n");
167
- return invariantsOk(next) ? next : null;
168
- }
169
- return null;
274
+ if (stock.length !== 1) return null;
275
+ const index = stock[0];
276
+ const end = blockEnd(lines, index);
277
+ const next = replaceBlock(
278
+ lines,
279
+ index,
280
+ end,
281
+ engramCompactionRowText(rowConfig, indentOf(lines[index])),
282
+ );
283
+ return invariantsOk(next) ? next : null;
170
284
  }
171
285
 
172
286
  /**
@@ -177,41 +291,121 @@ export function swapCompactionRow(text, rowConfig = {}) {
177
291
  * @returns {string | null}
178
292
  */
179
293
  export function restoreCompactionRow(text) {
180
- if (compactionWireStatus(text) !== "wired") return null;
181
- const bounds = engramRowBlockBounds(text);
182
- if (bounds === null) return null;
183
- const lines = text.split("\n");
184
- const next = [
185
- ...lines.slice(0, bounds.start),
186
- STOCK_COMPACTION_ROW,
187
- ...lines.slice(bounds.end),
188
- ].join("\n");
294
+ const { lines, engram, ambiguous } = locateRows(text);
295
+ if (ambiguous || engram.length !== 1) return null;
296
+ const index = engram[0];
297
+ const end = blockEnd(lines, index);
298
+ const next = replaceBlock(lines, index, end, stockCompactionRowText(indentOf(lines[index])));
189
299
  return compactionWireStatus(next) === "stock" ? next : null;
190
300
  }
191
301
 
302
+ /** Environment override for the shipped declarative preset directory. */
303
+ export const PRESETS_DIR_ENV = "DSH_WEB_PRESETS_DIR";
304
+
305
+ /**
306
+ * Resolve the shipped declarative preset directory
307
+ * (`@deepseek-ai/dsh-web-app/presets`, DSH ≥ 0.2.0). Both the plugin and the
308
+ * standalone CLI run OUTSIDE that package, so the directory is resolved
309
+ * through the `dsh` installation that launched the process (`process.argv[1]`,
310
+ * plus `opts.anchors` — the CLI passes the `dsh` bin it finds on `PATH`).
311
+ *
312
+ * Resolution order: `opts.presetsDir` (explicit; an empty string disables
313
+ * discovery) → `DSH_WEB_PRESETS_DIR` → package resolution from each anchor.
314
+ * @param {{ presetsDir?: string; anchors?: string[] }} [opts]
315
+ * @returns {string | undefined}
316
+ */
317
+ export function discoverPresetsDir(opts = {}) {
318
+ if (typeof opts.presetsDir === "string") {
319
+ return opts.presetsDir.length > 0 ? opts.presetsDir : undefined;
320
+ }
321
+ const fromEnv = process.env[PRESETS_DIR_ENV];
322
+ if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
323
+ for (const anchor of [process.argv[1], ...(opts.anchors ?? [])]) {
324
+ const dir = presetsDirFromAnchor(anchor);
325
+ if (dir !== undefined) return dir;
326
+ }
327
+ return undefined;
328
+ }
329
+
330
+ /** `presets/` beside the `@deepseek-ai/dsh-web-app` package, via one anchor. */
331
+ function presetsDirFromAnchor(anchor) {
332
+ if (typeof anchor !== "string" || anchor.length === 0) return undefined;
333
+ let file;
334
+ try {
335
+ file = realpathSync(anchor);
336
+ } catch {
337
+ return undefined;
338
+ }
339
+ let require;
340
+ try {
341
+ require = createRequire(file);
342
+ } catch {
343
+ return undefined;
344
+ }
345
+ try {
346
+ const pkg = require.resolve("@deepseek-ai/dsh-web-app/package.json");
347
+ const dir = join(dirname(pkg), "presets");
348
+ if (existsSync(dir)) return dir;
349
+ } catch {}
350
+ // Fallback: dsh's own nested node_modules (covers layouts where only `dsh`
351
+ // resolves from the anchor, e.g. a global bin without a hoisted web-app).
352
+ try {
353
+ const dshPkg = require.resolve("@deepseek-ai/dsh/package.json");
354
+ const dir = join(dirname(dshPkg), "node_modules", "@deepseek-ai", "dsh-web-app", "presets");
355
+ if (existsSync(dir)) return dir;
356
+ } catch {}
357
+ return undefined;
358
+ }
359
+
360
+ /** `<presetsDir>/<id>.patch.yml`, when that shipped file exists. */
361
+ function declarativePresetFile(presetsDir, id) {
362
+ if (presetsDir === undefined || typeof id !== "string" || id.length === 0) return undefined;
363
+ const file = join(presetsDir, `${id}.patch.yml`);
364
+ return existsSync(file) ? file : undefined;
365
+ }
366
+
367
+ /** Every `*.patch.yml` in the presets directory, sorted, as `{ id, path }`. */
368
+ async function declarativePresetFiles(presetsDir) {
369
+ let entries = [];
370
+ try {
371
+ entries = await readdir(presetsDir, { withFileTypes: true });
372
+ } catch {
373
+ return [];
374
+ }
375
+ return entries
376
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".patch.yml"))
377
+ .map((entry) => ({
378
+ id: entry.name.slice(0, -".patch.yml".length),
379
+ path: join(presetsDir, entry.name),
380
+ }))
381
+ .sort((a, b) => a.id.localeCompare(b.id));
382
+ }
383
+
192
384
  /**
193
385
  * Collect the presets to consider — the resolved DEFAULT plus every preset on
194
- * the roster (`service.list()` when present). Deduped by path; broken presets
195
- * are skipped with a log line. Never throws.
386
+ * the roster (`service.list()` when present). Each preset is mapped to a
387
+ * composition file: legacy presets carry `path`; declarative presets
388
+ * (DSH ≥ 0.2.0) are mapped by id to `<presetsDir>/<id>.patch.yml`. A preset
389
+ * without a file (user-defined declarative presets live in the profile patch)
390
+ * is skipped with a log line. Deduped by path; broken presets are skipped.
391
+ * Never throws.
196
392
  * @returns {Promise<Array<{ id: string; path: string }>>}
197
393
  */
198
- async function collectPresetTargets(service, log) {
394
+ async function collectPresetTargets(service, log, opts) {
395
+ const presetsDir = discoverPresetsDir(opts);
199
396
  const targets = [];
200
397
  const seen = new Set();
201
- const push = (preset) => {
202
- if (preset === undefined || preset === null) return;
203
- if (preset.broken !== undefined && preset.broken !== null) {
204
- log(
205
- `engram web-provision: preset "${preset.id}" is broken (${preset.broken}) — leaving it alone`,
206
- );
207
- return;
208
- }
209
- if (seen.has(preset.path)) return;
210
- seen.add(preset.path);
211
- targets.push(preset);
398
+ const push = (id, path) => {
399
+ if (typeof path !== "string" || path.length === 0 || seen.has(path)) return;
400
+ seen.add(path);
401
+ targets.push({ id, path });
212
402
  };
403
+
404
+ const roster = [];
405
+ let rosterOk = false;
213
406
  try {
214
- push(await service.resolve());
407
+ const resolvedPreset = await service.resolve();
408
+ if (resolvedPreset !== undefined && resolvedPreset !== null) roster.push(resolvedPreset);
215
409
  } catch (error) {
216
410
  log(
217
411
  `engram web-provision: default agent preset unavailable (${describe(error)}) — continuing with the roster`,
@@ -219,13 +413,52 @@ async function collectPresetTargets(service, log) {
219
413
  }
220
414
  if (typeof service.list === "function") {
221
415
  try {
222
- for (const preset of await service.list()) push(preset);
416
+ const listed = await service.list();
417
+ if (Array.isArray(listed)) {
418
+ roster.push(...listed);
419
+ rosterOk = true;
420
+ }
223
421
  } catch (error) {
224
422
  log(
225
423
  `engram web-provision: preset roster unavailable (${describe(error)}) — using the default only`,
226
424
  );
227
425
  }
228
426
  }
427
+
428
+ for (const preset of roster) {
429
+ if (preset === undefined || preset === null) continue;
430
+ if (preset.broken !== undefined && preset.broken !== null) {
431
+ log(
432
+ `engram web-provision: preset "${preset.id}" is broken (${preset.broken}) — leaving it alone`,
433
+ );
434
+ continue;
435
+ }
436
+ // DSH ≤ 0.1.x: the preset knows its composition file.
437
+ if (typeof preset.path === "string") {
438
+ push(preset.id, preset.path);
439
+ continue;
440
+ }
441
+ // DSH ≥ 0.2.0: a declarative preset — map its id to the shipped patch file.
442
+ const file = declarativePresetFile(presetsDir, preset.id);
443
+ if (file !== undefined) {
444
+ push(preset.id, file);
445
+ } else if (presetsDir === undefined) {
446
+ log(
447
+ `engram web-provision: preset "${preset.id}" has no composition path and no shipped preset `
448
+ + `directory was found (set ${PRESETS_DIR_ENV} or run from a normal dsh install) — skipping`,
449
+ );
450
+ } else {
451
+ log(
452
+ `engram web-provision: preset "${preset.id}" has no shipped file in ${presetsDir} — skipping `
453
+ + "(user-defined declarative presets live in the profile patch and are not wireable here)",
454
+ );
455
+ }
456
+ }
457
+
458
+ // Roster entirely unavailable: fall back to the shipped declarative files.
459
+ if (!rosterOk && targets.length === 0 && presetsDir !== undefined) {
460
+ for (const entry of await declarativePresetFiles(presetsDir)) push(entry.id, entry.path);
461
+ }
229
462
  return targets;
230
463
  }
231
464
 
@@ -257,7 +490,7 @@ export async function provisionWebCompaction(service, opts = {}) {
257
490
  opts.writeBackup ?? ((path, text) => writeFile(path, text, { flag: "wx" }));
258
491
  const backupSuffix = opts.backupSuffix ?? BACKUP_SUFFIX;
259
492
 
260
- const presets = await collectPresetTargets(service, log);
493
+ const presets = await collectPresetTargets(service, log, opts);
261
494
  if (presets.length === 0) {
262
495
  log("engram web-provision: no usable agent preset found — Context GC auto-wiring skipped");
263
496
  return { action: "no-default", presets: [] };
@@ -371,7 +604,7 @@ export async function revertWebCompaction(service, opts = {}) {
371
604
  const readText = opts.readText ?? ((preset) => readFile(preset.path, "utf8"));
372
605
  const writeText = opts.writeText ?? ((preset, text) => writeFile(preset.path, text, "utf8"));
373
606
 
374
- const presets = await collectPresetTargets(service, log);
607
+ const presets = await collectPresetTargets(service, log, opts);
375
608
  if (presets.length === 0) return { action: "no-default", presets: [] };
376
609
 
377
610
  const results = [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-engram",
3
3
  "description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
4
- "version": "0.7.0",
4
+ "version": "0.7.1",
5
5
  "packageManager": "pnpm@11.22.0",
6
6
  "license": "MIT",
7
7
  "repository": {