@lmzhen/dsh-evolution-core 0.26.26 → 0.27.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.
Files changed (107) hide show
  1. package/README.md +16 -1
  2. package/lib/composition/index.js +3 -0
  3. package/lib/composition-D8Y3-ROd.js +604 -0
  4. package/lib/ensure-MEYMj9jn.js +222 -0
  5. package/lib/errors-Br2-uUSK.js +57 -0
  6. package/lib/http-routes-BP-S3KV-.js +149 -0
  7. package/lib/http-routes.js +2 -0
  8. package/lib/index.js +66 -850
  9. package/lib/restrictor.js +37 -0
  10. package/lib/subagent/index.js +186 -0
  11. package/lib/types/citations.js +192 -0
  12. package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
  13. package/lib/types/composition/composer.js +681 -0
  14. package/lib/types/composition/ensure.d.ts +10 -0
  15. package/lib/types/composition/ensure.js +95 -0
  16. package/lib/types/composition/index.d.ts +5 -0
  17. package/lib/types/composition/index.js +7 -0
  18. package/lib/types/composition/preset-ids.d.ts +17 -0
  19. package/lib/types/composition/preset-ids.js +21 -0
  20. package/lib/types/composition/restricted-preset.d.ts +36 -0
  21. package/lib/types/composition/restricted-preset.js +42 -0
  22. package/lib/types/constants.js +223 -0
  23. package/lib/types/cost.js +67 -0
  24. package/lib/types/curator.js +346 -0
  25. package/lib/types/diagnostics.d.ts +26 -0
  26. package/lib/types/diagnostics.js +23 -0
  27. package/lib/types/document-body.js +91 -0
  28. package/lib/types/drift-signals.js +319 -0
  29. package/lib/types/env.js +25 -0
  30. package/lib/types/errors.d.ts +4 -1
  31. package/lib/types/errors.js +59 -0
  32. package/lib/types/events.js +16 -0
  33. package/lib/types/evidence.js +65 -0
  34. package/lib/types/evolution-events.js +479 -0
  35. package/lib/types/fold-untrusted.js +44 -0
  36. package/lib/types/frontmatter.js +623 -0
  37. package/lib/types/fuzzy-match.js +135 -0
  38. package/lib/types/gates.js +51 -0
  39. package/lib/types/guards.js +27 -0
  40. package/lib/types/health-baseline.js +63 -0
  41. package/lib/types/http-routes.d.ts +67 -35
  42. package/lib/types/http-routes.js +135 -0
  43. package/lib/types/index.d.ts +7 -2
  44. package/lib/types/index.js +101 -0
  45. package/lib/types/instance-scope.js +69 -0
  46. package/lib/types/io.js +675 -0
  47. package/lib/types/learn-prompt.js +42 -0
  48. package/lib/types/limits.js +63 -0
  49. package/lib/types/live.js +107 -0
  50. package/lib/types/long-runs.js +96 -0
  51. package/lib/types/memory-store.d.ts +0 -16
  52. package/lib/types/memory-store.js +606 -0
  53. package/lib/types/mutations.js +95 -0
  54. package/lib/types/namespace-migration.js +156 -0
  55. package/lib/types/numeric.js +47 -0
  56. package/lib/types/opt-in.js +101 -0
  57. package/lib/types/package-identity.js +47 -0
  58. package/lib/types/params.js +328 -0
  59. package/lib/types/payload.js +36 -0
  60. package/lib/types/platform-line.generated.d.ts +10 -0
  61. package/lib/types/platform-line.generated.js +12 -0
  62. package/lib/types/platform-services.d.ts +2 -2
  63. package/lib/types/platform-services.js +52 -0
  64. package/lib/types/probe.js +122 -0
  65. package/lib/types/prompts.js +409 -0
  66. package/lib/types/quality.js +308 -0
  67. package/lib/types/reachability.js +122 -0
  68. package/lib/types/redact.js +257 -0
  69. package/lib/types/reference-rewrite.js +144 -0
  70. package/lib/types/reports.js +110 -0
  71. package/lib/types/restrictor.d.ts +11 -0
  72. package/lib/types/restrictor.js +48 -0
  73. package/lib/types/review-channel.js +59 -0
  74. package/lib/types/run-registry.js +345 -0
  75. package/lib/types/scope.js +7 -0
  76. package/lib/types/serial.js +17 -0
  77. package/lib/types/session-fold.d.ts +15 -0
  78. package/lib/types/session-fold.js +28 -0
  79. package/lib/types/session-projection.js +295 -0
  80. package/lib/types/signals.js +209 -0
  81. package/lib/types/skill-discovery.js +66 -0
  82. package/lib/types/skill-health.js +134 -0
  83. package/lib/types/skill-history.js +745 -0
  84. package/lib/types/skill-lint.js +112 -0
  85. package/lib/types/skill-reads.js +67 -0
  86. package/lib/types/skill-row-cells.js +123 -0
  87. package/lib/types/skill-store.js +3528 -0
  88. package/lib/types/state-store.js +60 -0
  89. package/lib/types/store-ledger.js +117 -0
  90. package/lib/types/subagent/channel.d.ts +4 -0
  91. package/lib/types/subagent/channel.js +131 -0
  92. package/lib/types/subagent/index.d.ts +4 -0
  93. package/lib/types/subagent/index.js +5 -0
  94. package/lib/types/subagent/readiness.d.ts +6 -0
  95. package/lib/types/subagent/readiness.js +35 -0
  96. package/lib/types/subagent/types.d.ts +58 -0
  97. package/lib/types/subagent/types.js +2 -0
  98. package/lib/types/threats.js +338 -0
  99. package/lib/types/tool-dispatch.d.ts +5 -0
  100. package/lib/types/tool-dispatch.js +535 -0
  101. package/lib/types/tree-write.js +40 -0
  102. package/lib/types/unit-members.js +121 -0
  103. package/lib/types/usage.js +474 -0
  104. package/lib/types/write-authority.js +23 -0
  105. package/lib/types/write-gates.js +344 -0
  106. package/lib/types/write-inventory.js +158 -0
  107. package/package.json +39 -11
@@ -0,0 +1,681 @@
1
+ /* WC (0.3.56): the N-5 escape reads through the single env module. */
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { allowRowCollisions } from "../env.js";
6
+ import { scopedPackageName } from "../package-identity.js";
7
+ /**
8
+ * Build the user-root Evolution preset composition from the RUNTIME platform's
9
+ * `standard` preset rows plus the evolution delta rows (P1-1 follow-up,
10
+ * 0.3.15): the agent-preset registry mounts ONE composition file verbatim, so
11
+ * a delta-only `agent.cordis.yml` would produce an agent carrying only the
12
+ * delta rows.
13
+ *
14
+ * Same contract as `install-layered.mjs` `generateAgentPreset` (the source
15
+ * install path) — installer.spec pins byte parity between the two, and both
16
+ * apply the identical row-collision contract: a delta id that overlaps a
17
+ * standard row id fails loud by default, and `DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1`
18
+ * downgrades it to a warning that keeps both (the row mounts twice).
19
+ *
20
+ * V10-14 / 0.3.53 (P1-2): BOTH composers now inject the Hermes 60-char catalog
21
+ * cap onto the standard-sourced `- id: tool-skill` row of the composed preset
22
+ * (see injectCatalogDescriptionCap) — the session-visible tool-skill instance
23
+ * mounts in the preset's own standing scope, where no profile-root patch can
24
+ * reach it. 0.3.53 moved the injection INTO the composer so
25
+ * `/evolution preset install` (the npm user's only preset path) gets it too;
26
+ * install-layered applies the byte-identical rule, pinned by installer.spec.
27
+ *
28
+ * Row ids are read from `- id:` lines; an id present in both fragments would
29
+ * mount twice and could shadow the platform row, so it fails loud.
30
+ * @param standardComposition - the runtime `standard` preset composition.
31
+ * @param deltaComposition - the evolution delta fragment.
32
+ * @returns the composed preset composition (standard rows first, then delta).
33
+ */
34
+ export function composePresetComposition(standardComposition, deltaComposition) {
35
+ const standardIds = compositionRowIds(standardComposition);
36
+ const deltaIds = compositionRowIds(deltaComposition);
37
+ const collisions = [...deltaIds].filter(id => standardIds.has(id)).sort();
38
+ if (collisions.length > 0 && !allowRowCollisions()) {
39
+ throw new Error(`evolution preset composition: delta rows collide with runtime standard rows: ${collisions.join(', ')}`);
40
+ }
41
+ if (collisions.length > 0) {
42
+ console.warn(`evolution preset composition: warning — delta rows collide with standard rows (${collisions.join(', ')}); keeping both (DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1)`);
43
+ }
44
+ return applyRowOverrides(`${standardComposition.replace(/\s+$/, '')}\n\n${deltaComposition.trim()}\n`);
45
+ }
46
+ /**
47
+ * One table, two generation paths: `row-overrides.json` at the PACKAGE ROOT
48
+ * states what a generated preset must carry beyond the platform composition,
49
+ * and the source installer (`scripts/install-layered.mjs`) reads the same file.
50
+ * The path resolves from both `src/` and the built `lib/`, which is why the
51
+ * file sits at the root and ships in `files`.
52
+ *
53
+ * Before 0.3.77 each side carried its own hand-kept copy of this table; they
54
+ * happened to stay byte-identical, which is exactly the kind of agreement no
55
+ * test can keep — the entries are now data, and a divergence is impossible.
56
+ */
57
+ /**
58
+ * Locate row-overrides.json beside the nearest package manifest above this module.
59
+ * A fixed ../ hop is WRONG once the module is bundled: tsdown may emit the composer body into
60
+ * lib/composition/index.js or flatten it into lib/<chunk>.js, so the caller-relative depth varies.
61
+ * The manifest anchor is stable in every shape (src/, lib/, lib/composition/, chunk files).
62
+ */
63
+ function rowOverridesPath() {
64
+ let dir = dirname(fileURLToPath(import.meta.url));
65
+ for (let hops = 0; hops < 8; hops += 1) {
66
+ const candidate = join(dir, 'row-overrides.json');
67
+ if (existsSync(candidate) && existsSync(join(dir, 'package.json')))
68
+ return candidate;
69
+ const parent = dirname(dir);
70
+ if (parent === dir)
71
+ break;
72
+ dir = parent;
73
+ }
74
+ throw new Error('evolution-core: row-overrides.json not found beside the package manifest (bundled path?)');
75
+ }
76
+ /** One cache per PATH: the installer reads the same file through its own root, and a single-slot
77
+ * cache would hand the second caller the first caller's bytes. */
78
+ const overridesCache = new Map();
79
+ /**
80
+ * Read and validate the shared override table.
81
+ * G6.1/M1: the path is a PARAMETER (default: the table beside this module). The source installer
82
+ * runs the generated port from `scripts/`, so its own root has to be passed in — a module-relative
83
+ * read would look for `scripts/../row-overrides.json` and fail.
84
+ * @param path - the table to read.
85
+ * @returns the entries, in file order. A malformed table fails LOUD: a composer
86
+ * that silently skipped an entry would ship a preset running the platform
87
+ * default while nothing downstream reported it.
88
+ */
89
+ export function loadRowOverrides(path = rowOverridesPath()) {
90
+ const cached = overridesCache.get(path);
91
+ if (cached !== undefined)
92
+ return cached;
93
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
94
+ if (!Array.isArray(parsed)) {
95
+ throw new Error('evolution-core: row-overrides.json must be an array of override entries');
96
+ }
97
+ const entries = [];
98
+ for (const [index, entry] of parsed.entries()) {
99
+ const candidate = entry;
100
+ if (candidate === null || typeof candidate !== 'object'
101
+ || typeof candidate.row !== 'string' || candidate.row === ''
102
+ || typeof candidate.key !== 'string' || candidate.key === ''
103
+ || !Array.isArray(candidate.lines) || candidate.lines.some(line => typeof line !== 'string')
104
+ || typeof candidate.missingReason !== 'string') {
105
+ throw new Error(`evolution-core: row-overrides.json entry ${index} is malformed (row/key/lines/missingReason are required)`);
106
+ }
107
+ entries.push({ row: candidate.row, key: candidate.key, lines: candidate.lines, missingReason: candidate.missingReason });
108
+ }
109
+ overridesCache.set(path, entries);
110
+ return entries;
111
+ }
112
+ /**
113
+ * Ensure every {@link RowOverride} inside its target row.
114
+ *
115
+ * Key-level by construction: we INSERT the override's own lines and leave a row
116
+ * that already carries the key byte-identical, so a re-run never doubles a key.
117
+ * This is the deliberate opposite of the platform's patch layers, where an
118
+ * override REPLACES `config` wholesale — the difference is why the composer can
119
+ * add a key without erasing the platform's own config defaults.
120
+ */
121
+ function applyRowOverrides(composition, overrides = loadRowOverrides()) {
122
+ let lines = composition.split('\n');
123
+ for (const override of overrides) {
124
+ lines = applyOneOverride(lines, override);
125
+ }
126
+ return lines.join('\n');
127
+ }
128
+ function applyOneOverride(lines, override) {
129
+ const rowRe = new RegExp('^- id:\\s*' + override.row.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$');
130
+ let found = false;
131
+ for (let i = 0; i < lines.length; i += 1) {
132
+ if (!rowRe.test(lines[i] ?? ''))
133
+ continue;
134
+ found = true;
135
+ // Walk the item's continuation lines (indented) up to the next item or
136
+ // top-level line; a blank line terminates the item block.
137
+ let end = i;
138
+ let hasConfig = false;
139
+ for (let j = i + 1; j < lines.length; j += 1) {
140
+ const next = lines[j] ?? '';
141
+ if (next.trim() === '')
142
+ break;
143
+ if (!/^\s/.test(next))
144
+ break;
145
+ // v22 (PRE-3): anchor `config:` to the ITEM's own child indent (exactly
146
+ // two spaces — the `- id:` row sits at column 0). The old `\s+` form
147
+ // matched a `config:` at ANY depth inside the item's sub-maps, so a
148
+ // platform preset that grew a nested config map silently skipped the
149
+ // cap injection with no diagnostic (the missed-cap warn fires only when
150
+ // the ROW itself is absent).
151
+ if (new RegExp('^ {2}' + override.key + ':(\\s|$)').test(next))
152
+ hasConfig = true;
153
+ end = j;
154
+ }
155
+ if (hasConfig)
156
+ continue;
157
+ lines.splice(end + 1, 0, ...override.lines);
158
+ i = end + override.lines.length;
159
+ }
160
+ if (!found)
161
+ console.warn('evolution preset composition: warning — ' + override.missingReason);
162
+ return lines;
163
+ }
164
+ /**
165
+ * Row ids of a composition fragment (line scan, no YAML library).
166
+ *
167
+ * PLAN S5.9 (2026-09-16, audit P2-27): the id extraction accepts INDENTED
168
+ * `- id:` rows too, so a collision hidden in a nested group is still caught —
169
+ * the old `^- id:` anchored at column 0 and was blind to exactly the rows an
170
+ * upstream group nesting would produce. Twin of `rowIds` in
171
+ * `scripts/install-layered.mjs` (installer.spec pins detection parity).
172
+ * Boundary (current, deliberate): DETECTION covers nested rows, while the
173
+ * override INJECTION anchors (`applyOneOverride` below) still match top-level
174
+ * rows only — the injection indent contract (`^ {2}key:`) is defined against a
175
+ * column-0 row.
176
+ */
177
+ export function compositionRowIds(composition) {
178
+ const ids = new Set();
179
+ for (const line of composition.split('\n')) {
180
+ const match = /^\s*- id:\s*(\S+)/.exec(line);
181
+ // C-25 (v10 audit): the old `match[1] ?? ''` was a dead expression (the
182
+ // regex guarantees group 1 exists), and an empty id would have poisoned
183
+ // the collision set anyway. Delta-INTERNAL duplicate ids remain
184
+ // undetected by design (deferred — see the v10 ledger).
185
+ const id = match?.[1];
186
+ if (id)
187
+ ids.add(id);
188
+ }
189
+ return ids;
190
+ }
191
+ /** The platform package a DECLARATIVE preset row names (0.2.x replaced preset directories with it). */
192
+ // G-04: DERIVED from this package own manifest scope, so the published scope follows without any
193
+ // release-time text rewrite (the .mjs mirror derives the same way).
194
+ export const AGENT_PRESET_PACKAGE = scopedPackageName('dsh-agent-preset', import.meta.url);
195
+ /**
196
+ * Compose the DECLARATIVE preset row from the platform base composition and the family delta.
197
+ *
198
+ * 0.2.x removed the directory mechanism: `dsh-agent-preset` is an ordinary composition row whose
199
+ * `config.plugins` carries the preset's complete child plugin list
200
+ * (`packages/preset/agent-preset/src/index.ts`, `agent-preset-registry/src/definition.ts` — the
201
+ * definition has NO base/extends field, which is why the platform base is merged in here).
202
+ *
203
+ * The composition itself is {@link composePresetComposition}: same row-collision contract and the
204
+ * same `row-overrides.json` injection, applied while the composed rows are still at column 0 — the
205
+ * override anchors are defined against a column-0 `- id:` row, so the indentation that puts them
206
+ * under `plugins:` must come last.
207
+ * @param baseComposition - the platform base preset patch/composition rows.
208
+ * @param deltaComposition - the family delta rows.
209
+ * @param identity - row id, preset id, display fields and order.
210
+ * @returns the row text, newline-terminated, ready to merge into a profile patch.
211
+ */
212
+ /**
213
+ * Drop the blank lines that merely separate block-sequence items, keeping the ones INSIDE a block
214
+ * literal (`key: |` / `>`): there a blank line is part of the value, and filtering it flattened a
215
+ * platform base preset's multi-paragraph prompt into one paragraph (G6 review, P1).
216
+ * @param text - the composition rows.
217
+ * @returns the rows with the separating blank lines removed.
218
+ */
219
+ export function denseBlockSequence(text) {
220
+ const out = [];
221
+ let scalarIndent = -1;
222
+ for (const line of text.split('\n')) {
223
+ const trimmed = line.trim();
224
+ const indent = line.length - line.trimStart().length;
225
+ if (scalarIndent >= 0 && trimmed !== '' && indent <= scalarIndent)
226
+ scalarIndent = -1;
227
+ if (scalarIndent < 0) {
228
+ const match = /^(\s*)[\w".-]+:\s*[|>]/.exec(line);
229
+ if (match !== null)
230
+ scalarIndent = (match[1] ?? '').length;
231
+ }
232
+ if (trimmed === '' && scalarIndent < 0)
233
+ continue;
234
+ out.push(line);
235
+ }
236
+ return out.join('\n');
237
+ }
238
+ export function composePresetRow(baseComposition, deltaComposition, identity) {
239
+ const composed = composePresetComposition(baseComposition, deltaComposition);
240
+ const plugins = denseBlockSequence(composed)
241
+ .split('\n')
242
+ // A kept blank line stays BLANK: indenting it would add trailing spaces to a block scalar's
243
+ // value (the shape the platform's own base patches use is a truly empty line).
244
+ .map(line => (line === '' ? '' : ' ' + line))
245
+ .join('\n');
246
+ return [
247
+ '- id: ' + identity.rowId,
248
+ " name: '" + AGENT_PRESET_PACKAGE + "'",
249
+ ' config:',
250
+ ' id: ' + identity.id,
251
+ // No trailing commas: the row is YAML, and the platform's own patch parser rejects a
252
+ // JSON-style comma after a block mapping entry (pinned by the parse case in
253
+ // evolution-host/tests/installer-preset-base.spec.ts).
254
+ ' name: ' + JSON.stringify(identity.name),
255
+ ' description: ' + JSON.stringify(identity.description),
256
+ ' order: ' + String(identity.order),
257
+ ' plugins:',
258
+ plugins,
259
+ '',
260
+ ].join('\n');
261
+ }
262
+ /** The patch-entry key that ADDS a row: a plain entry overrides the row with the same id, so a
263
+ * preset the platform does not ship has to arrive inside an `insert` entry
264
+ * (the platform's own `user-patches.spec.ts` proves the override-vs-insert split). */
265
+ const PATCH_INSERT_KEY = 'insert';
266
+ /**
267
+ * Wrap a composed row as the patch entry a profile patch needs.
268
+ *
269
+ * The row {@link composePresetRow} returns starts at column 0; a patch entry's list nests it four
270
+ * spaces deeper. Wrapping is a separate step because the same row text is also what
271
+ * {@link mergePresetRow} matches by id, and because the composer's override anchors are defined
272
+ * against column-0 rows.
273
+ * @param row - the row text, as {@link composePresetRow} returns it.
274
+ * @returns the patch entry text, newline-terminated.
275
+ */
276
+ export function composePresetInsert(row) {
277
+ const body = row.replace(/\s+$/, '').split('\n').map(line => ' ' + line).join('\n');
278
+ return `- ${PATCH_INSERT_KEY}:\n${body}\n`;
279
+ }
280
+ /**
281
+ * The row list a platform base preset patch carries under `config.plugins`, dedented to column 0.
282
+ *
283
+ * A base preset arrives as a whole patch entry — `- insert:` / `- id: preset-standard` /
284
+ * `config:` / `plugins:` — because that is how the platform ships it
285
+ * (`packages/bundle/web-app/presets/standard.patch.yml`). The preset's own list is the
286
+ * SHALLOWEST `plugins:` line: a child row may carry a key by that name far deeper.
287
+ * @param patchText - the base preset patch file's text.
288
+ * @returns the plugin rows, dedented and newline-terminated.
289
+ * @throws {Error} when the patch carries no `plugins:` list.
290
+ */
291
+ export function basePresetPlugins(patchText) {
292
+ const lines = patchText.split('\n');
293
+ let at = -1;
294
+ let indent = Number.POSITIVE_INFINITY;
295
+ for (const [index, line] of lines.entries()) {
296
+ const match = /^(\s*)plugins:\s*$/.exec(line);
297
+ const width = match?.[1]?.length;
298
+ if (width === undefined)
299
+ continue;
300
+ if (width < indent) {
301
+ indent = width;
302
+ at = index;
303
+ }
304
+ }
305
+ if (at < 0)
306
+ throw new Error('evolution preset composition: the base preset patch carries no `plugins:` list');
307
+ const block = [];
308
+ for (const line of lines.slice(at + 1)) {
309
+ if (line.trim() === '') {
310
+ block.push(line);
311
+ continue;
312
+ }
313
+ if (line.length - line.trimStart().length <= indent)
314
+ break;
315
+ block.push(line);
316
+ }
317
+ while (block.length > 0 && block[block.length - 1]?.trim() === '')
318
+ block.pop();
319
+ const widths = block.filter(line => line.trim() !== '').map(line => line.length - line.trimStart().length);
320
+ const dedent = widths.length === 0 ? 0 : Math.min(...widths);
321
+ return block.map(line => line.slice(0, dedent).trim() === '' ? line.slice(dedent) : line).join('\n') + '\n';
322
+ }
323
+ /** The row id a family preset occupies: the platform ships \`preset-standard\` for id `standard`
324
+ * (`packages/bundle/web-app/presets/standard.patch.yml`), so a family preset id mirrors it. */
325
+ export function presetRowId(id) {
326
+ return `preset-${id}`;
327
+ }
328
+ /**
329
+ * The patch entry a profile patch receives for one preset: base rows plus the family delta, wrapped
330
+ * as \`- insert:\`.
331
+ *
332
+ * The two halves are also exposed on their own — {@link composePresetRow} for the row, so
333
+ * {@link mergePresetRow} can match it by id, and {@link basePresetPlugins} to read the platform
334
+ * base out of a shipped patch.
335
+ * @param basePatchText - the platform base preset patch's text.
336
+ * @param deltaComposition - the family delta rows.
337
+ * @param identity - row id, preset id, display fields and order.
338
+ * @returns the patch entry text, newline-terminated.
339
+ */
340
+ export function composePresetEntry(basePatchText, deltaComposition, identity) {
341
+ return composePresetInsert(composePresetRow(basePresetPlugins(basePatchText), deltaComposition, identity));
342
+ }
343
+ /**
344
+ * The text of one row's own patch item, or null when the patch carries no such row.
345
+ *
346
+ * The comparison unit for a freshness check: this is byte-for-byte what {@link composePresetEntry}
347
+ * writes for the same row id, whether the row sits inside an \`- insert:\` entry (installer-written)
348
+ * or at column 0 (saved by the Web editor).
349
+ * @param patchText - the patch text.
350
+ * @param rowId - the row id to read.
351
+ * @returns the item's text, newline-terminated, or null.
352
+ */
353
+ export function presetRowBody(block) {
354
+ const lines = block.split('\n');
355
+ if (!/^- insert:\s*$/.test(lines[0] ?? ''))
356
+ return block;
357
+ return lines.slice(1).map(line => (line.startsWith(' ') ? line.slice(4) : line)).join('\n');
358
+ }
359
+ /**
360
+ * The row BODY of a patch block, whichever form it is in.
361
+ *
362
+ * {@link presetRowBlock} returns the enclosing item verbatim: an installer-written row sits inside
363
+ * `- insert:` while a row the platform's Web editor saved IS that column-0 item. A comparison must
364
+ * compare like with like — comparing the raw block against `composePresetEntry()` (which always
365
+ * starts with `- insert:`) reported DIFFERS for every editor-saved row, forever (G6 review, P2), and
366
+ * the remedy it printed (`re-run the installer`) was refused by the `exists; use --force` guard.
367
+ * @param block - a block as {@link presetRowBlock} returns it.
368
+ * @returns the row text, `- insert:` unwrapped and its four-space indent stripped.
369
+ */
370
+ export function presetRowBlock(patchText, rowId) {
371
+ const lines = patchText.split('\n');
372
+ const span = rowBlockSpan(lines, rowId);
373
+ if (span === null)
374
+ return null;
375
+ const block = [...lines.slice(span.start, span.end)];
376
+ while (block.length > 0 && (block[block.length - 1] ?? '').trim() === '')
377
+ block.pop();
378
+ return block.join('\n') + '\n';
379
+ }
380
+ /** The package every shipped base preset patch declares its row with
381
+ * (`packages/bundle/web-app/presets/standard.patch.yml`). The platform's only bundle carrying
382
+ * preset patches today; a bundle a caller mounts is probed beside it. */
383
+ const PLATFORM_BUNDLE_PACKAGE = '@deepseek-ai/dsh-web-app';
384
+ /**
385
+ * What `DSH_AGENT_PRESET_ROOT` must name: a directory holding the platform base preset patches
386
+ * under their shipped file names (`<base>.patch.yml`) — e.g. a copy of a same-version platform
387
+ * tree's `packages/bundle/web-app/presets/`. The desktop application is the shape that needs it:
388
+ * its platform packages live inside `resources/app.asar`, so there is no plain
389
+ * `node_modules/@deepseek-ai/dsh-web-app/presets/` on disk for a source installer to read.
390
+ */
391
+ export const AGENT_PRESET_ROOT_HINT = 'point DSH_AGENT_PRESET_ROOT at a directory holding <base>.patch.yml (copy it out of a same-version dsh tree: packages/bundle/web-app/presets/)';
392
+ /**
393
+ * Resolve the platform patch file one `--base` names.
394
+ *
395
+ * 0.2.x ships each base preset as a bundle patch layer
396
+ * (`packages/bundle/web-app/presets/<base>.patch.yml`, declared in that package's
397
+ * `dsh.bundle.patch`), so the FILE — not a preset directory — is the base a family row is composed
398
+ * from. Candidates, in order: an explicit `DSH_AGENT_PRESET_ROOT`; a module-graph probe when the
399
+ * caller supplies one (a desktop/Electron host resolves the bundle inside `app.asar`, where no file
400
+ * walk can reach); the target profile's own `node_modules`; then, walking up from the caller,
401
+ * every `presets/` directory of a checkout's bundle packages, then every
402
+ * `@deepseek-ai/dsh-<name>/presets/` directory of an installed tree.
403
+ *
404
+ * A base with no patch file is REFUSED with every probed candidate listed: composing a family preset
405
+ * from another base's rows would mount rows the user did not ask for, with nothing saying so.
406
+ * @param base - the base name, already validated against `evolution-agent/bases.json`.
407
+ * @param sources - the roots to probe.
408
+ * @returns the patch file's absolute path.
409
+ * @throws {Error} when no candidate exists.
410
+ */
411
+ export function resolveBasePresetPatch(base, sources) {
412
+ const file = `${base}.patch.yml`;
413
+ const explicit = sources.root?.trim();
414
+ if (explicit !== undefined && explicit !== '') {
415
+ const path = join(explicit, file);
416
+ if (!existsSync(path)) {
417
+ throw new Error(`evolution preset composition: DSH_AGENT_PRESET_ROOT is set but ${path} does not exist — ${AGENT_PRESET_ROOT_HINT}`);
418
+ }
419
+ return path;
420
+ }
421
+ const probes = [];
422
+ const probe = (path) => {
423
+ probes.push(path);
424
+ return existsSync(path) ? path : undefined;
425
+ };
426
+ if (sources.resolve !== undefined) {
427
+ const probeSpecifier = `${PLATFORM_BUNDLE_PACKAGE}/presets/${file}`;
428
+ // The failure is a CANDIDATE miss, not an error: the caller's module graph may simply not carry
429
+ // the bundle (a headless profile), and the file candidates below still apply.
430
+ try {
431
+ const found = probe(sources.resolve(probeSpecifier));
432
+ if (found !== undefined)
433
+ return found;
434
+ }
435
+ catch {
436
+ probes.push(`node module graph: ${probeSpecifier}`);
437
+ }
438
+ }
439
+ if (sources.profileDir !== undefined && sources.profileDir !== '') {
440
+ for (const name of new Set([PLATFORM_BUNDLE_PACKAGE, ...(sources.bundles ?? [])])) {
441
+ const found = probe(join(sources.profileDir, 'node_modules', ...name.split('/'), 'presets', file));
442
+ if (found !== undefined)
443
+ return found;
444
+ }
445
+ }
446
+ for (let level = sources.fromDir; level !== dirname(level); level = dirname(level)) {
447
+ const bundleRoot = join(level, 'packages', 'bundle');
448
+ for (const name of listDirectories(bundleRoot)) {
449
+ const found = probe(join(bundleRoot, name, 'presets', file));
450
+ if (found !== undefined)
451
+ return found;
452
+ }
453
+ const scopeRoot = join(level, 'node_modules', '@deepseek-ai');
454
+ for (const name of listDirectories(scopeRoot)) {
455
+ if (!name.startsWith('dsh-'))
456
+ continue;
457
+ const found = probe(join(scopeRoot, name, 'presets', file));
458
+ if (found !== undefined)
459
+ return found;
460
+ }
461
+ }
462
+ // Desktop roots are probed LAST (G6 review, P2): they hold SOME platform version's copy, while
463
+ // the tree this code runs in is the one the profile is built against.
464
+ for (const root of installRoots(sources.fromDir)) {
465
+ for (const shape of DESKTOP_RESOURCE_SHAPES) {
466
+ const found = probe(join(root, ...shape, 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', file));
467
+ if (found !== undefined)
468
+ return found;
469
+ const scanned = probeDesktopScope(join(root, ...shape), file, probes);
470
+ if (scanned !== undefined)
471
+ return scanned;
472
+ }
473
+ }
474
+ throw new Error(`evolution preset composition: cannot find the runtime platform '${base}' preset patch — `
475
+ + `no candidate carries presets/${file}. A base with no runtime patch is refused rather than `
476
+ + 'composed from another base: the generated preset must follow the platform it runs on. '
477
+ + `Probed (in order): ${probes.join(', ')}. On a desktop install the platform packages live `
478
+ + `inside resources/app.asar and no plain file exists — ${AGENT_PRESET_ROOT_HINT}`);
479
+ }
480
+ /** The Electron resource layouts a desktop installation exposes its unpacked packages under. */
481
+ const DESKTOP_RESOURCE_SHAPES = [
482
+ ['resources', 'app.asar.unpacked'],
483
+ ['resources', 'app', 'resources', 'app.asar.unpacked'],
484
+ ];
485
+ /**
486
+ * Installation directories a desktop/Electron tree may sit at, probed before the source walk.
487
+ *
488
+ * The default Windows desktop location plus every ancestor that carries an
489
+ * `resources/app.asar.unpacked` directory (a script run from inside an installation).
490
+ * @param fromDir - the caller's own directory.
491
+ * @returns the candidate installation roots.
492
+ */
493
+ function installRoots(fromDir) {
494
+ const roots = [];
495
+ const localAppData = process.env.LOCALAPPDATA;
496
+ if (process.platform === 'win32' && localAppData !== undefined && localAppData !== '') {
497
+ roots.push(join(localAppData, 'Programs', 'DeepSeek Harness'));
498
+ }
499
+ for (let level = fromDir; level !== dirname(level); level = dirname(level)) {
500
+ if (existsSync(join(level, 'resources', 'app.asar.unpacked')))
501
+ roots.push(level);
502
+ }
503
+ return roots;
504
+ }
505
+ /**
506
+ * Probe every `@deepseek-ai/dsh-*` package under one unpacked resource root.
507
+ * @param resourceRoot - e.g. `<install>/resources/app.asar.unpacked`.
508
+ * @param file - the patch file name to look for.
509
+ * @param probes - the caller's probe log, appended to for the failure report.
510
+ * @returns the patch path when one exists.
511
+ */
512
+ export function probeDesktopScope(resourceRoot, file, probes) {
513
+ const scopeRoot = join(resourceRoot, 'dsh', 'node_modules', '@deepseek-ai');
514
+ for (const name of listDirectories(scopeRoot)) {
515
+ if (!name.startsWith('dsh-'))
516
+ continue;
517
+ const path = join(scopeRoot, name, 'presets', file);
518
+ probes.push(path);
519
+ if (existsSync(path))
520
+ return path;
521
+ }
522
+ return undefined;
523
+ }
524
+ /**
525
+ * Subdirectories of one directory, or none when it does not exist: a missing level is an ordinary
526
+ * miss on a candidate walk, not a failure.
527
+ * @param dir - the directory to list.
528
+ * @returns the directory names.
529
+ */
530
+ // G6.1/M1: exported for the installer port (the same functions were hand-copied there).
531
+ export function listDirectories(dir) {
532
+ try {
533
+ return readdirSync(dir, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name);
534
+ }
535
+ catch {
536
+ return [];
537
+ }
538
+ }
539
+ /**
540
+ * The block of one row id: the enclosing column-0 list item and the row's own first line.
541
+ *
542
+ * An installer-written row sits inside `- insert:` (four spaces deeper); a row the Web editor
543
+ * saved is that column-0 item itself. Both must be replaced whole, so the span reaches back to the
544
+ * enclosing item and forward to the next one.
545
+ * @param lines - the patch text's lines.
546
+ * @param rowId - the row id to locate.
547
+ * @returns the span, or null when the patch carries no such row.
548
+ */
549
+ export function rowBlockSpan(lines, rowId) {
550
+ const rowRe = new RegExp('^\\s*- id:\\s*' + rowId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$');
551
+ const rowStart = lines.findIndex(line => rowRe.test(line));
552
+ if (rowStart < 0)
553
+ return null;
554
+ let start = rowStart;
555
+ while (start > 0 && !/^- /.test(lines[start] ?? ''))
556
+ start -= 1;
557
+ let end = lines.length;
558
+ for (let index = start + 1; index < lines.length; index += 1) {
559
+ if (/^- /.test(lines[index] ?? '')) {
560
+ end = index;
561
+ break;
562
+ }
563
+ }
564
+ return { start, end, rowStart };
565
+ }
566
+ /**
567
+ * Remove one row from a patch, taking its `- insert:` item with it when that item holds nothing
568
+ * else (an empty insert entry is left-over structure the platform would keep parsing).
569
+ * @param patchText - the patch text to edit.
570
+ * @param rowId - the row id to remove.
571
+ * @returns the patch text, newline-terminated; byte-identical when the id is absent.
572
+ */
573
+ export function removePresetRow(patchText, rowId) {
574
+ const lines = patchText.split('\n');
575
+ const span = rowBlockSpan(lines, rowId);
576
+ if (span === null)
577
+ return patchText;
578
+ const rowLine = lines[span.rowStart] ?? '';
579
+ const rowIndent = rowLine.length - rowLine.trimStart().length;
580
+ const nested = rowIndent > 0;
581
+ const siblings = lines.slice(span.start + 1, span.end).filter((line) => {
582
+ const width = line.length - line.trimStart().length;
583
+ return width === rowIndent && line.trimStart().startsWith('- ');
584
+ }).length;
585
+ // A nested row inside an entry that holds others is removed alone — before it or after it
586
+ // alike; the entry itself goes when it held nothing else (an empty insert entry is structure the
587
+ // platform would keep parsing).
588
+ const cut = nested && siblings > 1
589
+ ? { start: span.rowStart, end: rowSiblingEnd(lines, span.rowStart, span.end) }
590
+ : span;
591
+ const kept = [...lines.slice(0, cut.start), ...lines.slice(cut.end)];
592
+ // Byte discipline: only whole trailing EMPTY lines the cut left behind are dropped. A kept
593
+ // line's own terminator is never rewritten — a CRLF file's last line ends with `\r`, and
594
+ // trimming it (or collapsing interior blank lines) made install→uninstall non-reversible.
595
+ while (kept.length > 1 && (kept[kept.length - 1] ?? '') === '')
596
+ kept.pop();
597
+ const text = kept.join('\n');
598
+ if (text === '')
599
+ return '';
600
+ return text.endsWith('\n') ? text : text + '\n';
601
+ }
602
+ /**
603
+ * The end of one nested row's own block inside a patch entry.
604
+ * @param lines - the patch text's lines.
605
+ * @param rowStart - the row's `- id:` line.
606
+ * @param itemEnd - the enclosing item's end.
607
+ * @returns the index of the next sibling row, or the item's end.
608
+ */
609
+ export function rowSiblingEnd(lines, rowStart, itemEnd) {
610
+ const indent = (lines[rowStart] ?? '').length - (lines[rowStart] ?? '').trimStart().length;
611
+ for (let index = rowStart + 1; index < itemEnd; index += 1) {
612
+ const line = lines[index] ?? '';
613
+ if (line.trim() === '')
614
+ continue;
615
+ const width = line.length - line.trimStart().length;
616
+ if (width === indent && line.trimStart().startsWith('- '))
617
+ return index;
618
+ if (width < indent)
619
+ return index;
620
+ }
621
+ return itemEnd;
622
+ }
623
+ /**
624
+ * Merge one generated row into a composition (a profile patch) by row id.
625
+ *
626
+ * Idempotent by construction: an existing row with the same `- id:` is REPLACED whole — its block
627
+ * is the enclosing column-0 list item, so an installer-written row inside `- insert:` and a
628
+ * Web-editor-saved row at column 0 are both matched by {@link rowBlockSpan} — and anything else in
629
+ * the patch stays byte-identical, because the patch usually carries rows this installer knows
630
+ * nothing about. A missing row is appended.
631
+ *
632
+ * The platform seeds a fresh profile patch with the empty list `[]`
633
+ * (`packages/boot/app-boot/src/profile.ts`, `PROFILE_PATCH_FILENAME`), which is a COMPLETE YAML
634
+ * document: a block sequence appended after it is not parsable. That seed is therefore replaced, not
635
+ * extended.
636
+ * @param patchText - the current patch text (`''` for a fresh one).
637
+ * @param row - the row text, as {@link composePresetRow} returns it.
638
+ * @param rowId - the row id to replace or append.
639
+ * @returns the merged patch text, newline-terminated.
640
+ */
641
+ export function mergePresetRow(patchText, row, rowId) {
642
+ const lines = patchText.split('\n');
643
+ const block = row.replace(/\s+$/, '').split('\n');
644
+ const span = rowBlockSpan(lines, rowId);
645
+ if (span === null) {
646
+ // Only the SEAM is normalized. The platform seed (`[]`, `[ ]`, `[] # empty`) is a COMPLETE flow
647
+ // sequence a block sequence may not follow, so that one line goes; every other byte the author
648
+ // wrote stays — a blank line inside a block scalar (`section: |`) is part of the VALUE, and
649
+ // filtering the whole file rewrote user content on install (G6 review, P1).
650
+ const seedIndex = lines.findIndex(line => EMPTY_PATCH_SEED_RE.test(line));
651
+ const base = seedIndex >= 0 ? [...lines.slice(0, seedIndex), ...lines.slice(seedIndex + 1)] : [...lines];
652
+ const kept = trimTrailingBlankLines(base);
653
+ return (kept.length === 0 ? block : [...kept, ...block]).join('\n') + '\n';
654
+ }
655
+ const merged = [...lines.slice(0, span.start), ...block, ...lines.slice(span.end)];
656
+ // Same byte discipline on the replace path: no global blank-line collapsing, and no `\s+$` trim
657
+ // (it ate the trailing \r of a CRLF file's last line).
658
+ return trimTrailingBlankLines(merged).join('\n') + '\n';
659
+ }
660
+ /** The empty patch list the platform seeds a profile patch with; see {@link mergePresetRow}. */
661
+ export const EMPTY_PATCH_SEED = '[]';
662
+ /** The seed in any spelling the platform accepts (`[]`, `[ ]`, `[] # empty`). */
663
+ const EMPTY_PATCH_SEED_RE = /^\s*\[\s*\](\s*#.*)?\s*$/;
664
+ /** Drop trailing blank lines only: a kept line's own terminator (a CRLF file's `\r`) is never touched. */
665
+ export function trimTrailingBlankLines(lines) {
666
+ const out = [...lines];
667
+ while (out.length > 0 && (out[out.length - 1] ?? '').trim() === '')
668
+ out.pop();
669
+ return out;
670
+ }
671
+ /**
672
+ * The text to write when a patch is left with no entries.
673
+ *
674
+ * A zero-byte patch file is not what the platform writes or parses; its own seed is the empty list.
675
+ * @param patchText - the text {@link removePresetRow} returned.
676
+ * @returns the platform's empty-list seed when nothing is left, the text otherwise.
677
+ */
678
+ export function presetPatchText(patchText) {
679
+ return patchText.trim() === '' ? `${EMPTY_PATCH_SEED}\n` : patchText;
680
+ }
681
+ //# sourceMappingURL=composer.js.map