@withone/mcp 1.2.4 → 1.3.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.
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Header-based sectioning for action knowledge markdown.
3
+ *
4
+ * The backend returns one markdown document per action. Half of them are under
5
+ * ~8k chars, but the tail is long (p90 ~17k, max ~370k) and the bulk of every
6
+ * large doc sits in three sections — `Success Response`, `Response Fields`, and
7
+ * `Optional Request Body Fields`. Agents pay per token, so
8
+ * `get_one_action_knowledge` hands back a *digest*: the sections needed to
9
+ * build a correct request in full, plus a table of contents for everything
10
+ * else. The agent asks for the rest by name (`section: "Response Fields"`) or
11
+ * for the whole document (`full: true`) on a follow-up call.
12
+ *
13
+ * Ported from withoneai/cli (`src/lib/knowledge-sections.ts`, PR #197). The
14
+ * parsing/classification/digest engine is identical; only the user-facing
15
+ * "how to load more" strings are adapted for the MCP tool. Keep the engine in
16
+ * sync when the cli module changes.
17
+ *
18
+ * Everything in this module is pure: string in, structure out. No I/O.
19
+ */
20
+ export const DEFAULT_WHOLE_DOC_THRESHOLD = 8_000;
21
+ export const DEFAULT_DIGEST_BUDGET = 8_000;
22
+ export const DEFAULT_SECTION_CAP = 6_000;
23
+ // ---------------------------------------------------------------------------
24
+ // Parsing
25
+ // ---------------------------------------------------------------------------
26
+ const HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
27
+ const FENCE_RE = /^\s*(```|~~~)/;
28
+ export function slugify(heading) {
29
+ return heading
30
+ .toLowerCase()
31
+ .replace(/`/g, '')
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '') || 'section';
34
+ }
35
+ /**
36
+ * Split markdown into a section tree keyed by ATX headings. Headings inside
37
+ * fenced code blocks are ignored. Setext (underlined) headings are not
38
+ * recognised — the backend never emits them.
39
+ */
40
+ export function parseSections(markdown) {
41
+ // Normalise line endings up front: a CRLF document would otherwise leave a
42
+ // trailing `\r` on every line, which defeats the heading regex (`$` will not
43
+ // match before it) so nothing is recognised as a heading. Windows checkouts
44
+ // and some scraped docs carry CRLF, so this is a correctness fix, not just a
45
+ // test convenience.
46
+ const normalized = markdown.replace(/\r\n?/g, '\n');
47
+ const lines = normalized.split('\n');
48
+ const prefaceLines = [];
49
+ const blocks = [];
50
+ let inFence = false;
51
+ let fenceMarker = '';
52
+ for (const line of lines) {
53
+ const fence = FENCE_RE.exec(line);
54
+ if (fence) {
55
+ if (!inFence) {
56
+ inFence = true;
57
+ fenceMarker = fence[1];
58
+ }
59
+ else if (fence[1] === fenceMarker) {
60
+ inFence = false;
61
+ }
62
+ }
63
+ const m = !inFence ? HEADING_RE.exec(line) : null;
64
+ if (m) {
65
+ blocks.push({ level: m[1].length, heading: m[2].trim(), lines: [line] });
66
+ continue;
67
+ }
68
+ if (blocks.length === 0)
69
+ prefaceLines.push(line);
70
+ else
71
+ blocks[blocks.length - 1].lines.push(line);
72
+ }
73
+ const usedIds = new Set();
74
+ const nodes = blocks.map((b) => {
75
+ const base = slugify(b.heading);
76
+ let id = base;
77
+ for (let n = 2; usedIds.has(id); n++)
78
+ id = `${base}-${n}`;
79
+ usedIds.add(id);
80
+ return {
81
+ id,
82
+ heading: b.heading,
83
+ level: b.level,
84
+ text: b.lines.join('\n'),
85
+ chars: 0,
86
+ tier: classifyHeading(b.heading),
87
+ children: [],
88
+ };
89
+ });
90
+ // Build the tree by level. The first heading, when it is an H1, is the title;
91
+ // its children are promoted so the document's H2s are the top-level sections.
92
+ const root = [];
93
+ const stack = [];
94
+ let title = '';
95
+ let titleNode = null;
96
+ for (const node of nodes) {
97
+ while (stack.length && stack[stack.length - 1].level >= node.level)
98
+ stack.pop();
99
+ if (stack.length)
100
+ stack[stack.length - 1].children.push(node);
101
+ else
102
+ root.push(node);
103
+ stack.push(node);
104
+ }
105
+ if (root.length && nodes[0].level === 1 && root[0] === nodes[0]) {
106
+ titleNode = root.shift();
107
+ title = titleNode.heading;
108
+ root.unshift(...titleNode.children);
109
+ titleNode.children = [];
110
+ }
111
+ // Any text under the title heading itself (before the first H2) becomes preface.
112
+ let preface = prefaceLines.join('\n').trim();
113
+ if (titleNode) {
114
+ const own = titleNode.text.split('\n').slice(1).join('\n').trim();
115
+ if (own)
116
+ preface = preface ? `${preface}\n\n${own}` : own;
117
+ }
118
+ const measure = (n) => {
119
+ n.chars = n.text.length + n.children.reduce((sum, c) => sum + measure(c), 0);
120
+ return n.chars;
121
+ };
122
+ root.forEach(measure);
123
+ return { title, preface, sections: root, chars: normalized.length };
124
+ }
125
+ /** Full markdown for a section including its nested children. */
126
+ export function sectionText(section) {
127
+ const parts = [section.text, ...section.children.map(sectionText)];
128
+ return parts.join('\n').replace(/\n+$/, '');
129
+ }
130
+ export function flattenSections(sections) {
131
+ const out = [];
132
+ const walk = (list) => {
133
+ for (const s of list) {
134
+ out.push(s);
135
+ walk(s.children);
136
+ }
137
+ };
138
+ walk(sections);
139
+ return out;
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Classification
143
+ // ---------------------------------------------------------------------------
144
+ /** Lower-case, strip a trailing parenthetical like "(200 OK)" or "(application/json)". */
145
+ export function canonicalHeading(heading) {
146
+ return heading
147
+ .toLowerCase()
148
+ .replace(/\s*\([^)]*\)\s*$/, '')
149
+ .replace(/[`*_]/g, '')
150
+ .replace(/\s+/g, ' ')
151
+ .trim();
152
+ }
153
+ const ESSENTIAL_EXACT = new Set([
154
+ 'prerequisites',
155
+ 'method',
156
+ 'url',
157
+ 'endpoint',
158
+ 'headers',
159
+ 'description',
160
+ 'enforcement rules',
161
+ 'valid examples',
162
+ 'invalid examples',
163
+ 'request body',
164
+ 'sample request',
165
+ 'gotchas',
166
+ 'error handling',
167
+ 'graphql operation',
168
+ 'graphql arguments',
169
+ 'request variables',
170
+ 'variables',
171
+ 'path parameters',
172
+ 'query parameters',
173
+ 'parameters',
174
+ 'arguments',
175
+ ]);
176
+ const ESSENTIAL_PREFIX = ['required '];
177
+ /**
178
+ * Essential sections are what an agent needs to build a correct request.
179
+ * Everything else — response shapes, optional fields, worked examples, prose —
180
+ * is deferred and listed in the table of contents.
181
+ */
182
+ export function classifyHeading(heading) {
183
+ const c = canonicalHeading(heading);
184
+ if (ESSENTIAL_EXACT.has(c))
185
+ return 'essential';
186
+ if (ESSENTIAL_PREFIX.some((p) => c.startsWith(p)))
187
+ return 'essential';
188
+ return 'deferred';
189
+ }
190
+ /** Priority for back-filling deferred sections into spare budget (lower = sooner). */
191
+ function deferredPriority(heading) {
192
+ const c = canonicalHeading(heading);
193
+ if (c.startsWith('optional '))
194
+ return 0;
195
+ if (c === 'behavior' || c === 'behaviour')
196
+ return 1;
197
+ if (c === 'notes')
198
+ return 2;
199
+ if (c === 'example usage' || c.startsWith('example'))
200
+ return 3;
201
+ if (c === 'response fields')
202
+ return 5;
203
+ if (c === 'response' || c.startsWith('success response') || c.startsWith('error response'))
204
+ return 6;
205
+ return 4;
206
+ }
207
+ function truncateText(text, cap, id) {
208
+ const cut = text.slice(0, cap);
209
+ // Back up to a line boundary so we never split a table row or code fence mid-way.
210
+ const nl = cut.lastIndexOf('\n');
211
+ const head = nl > cap * 0.5 ? cut.slice(0, nl) : cut;
212
+ const remaining = text.length - head.length;
213
+ return `${head}\n\n_[truncated — ${remaining.toLocaleString('en-US')} more chars. Load the full section by calling get_one_action_knowledge again with section: "${id}"]_`;
214
+ }
215
+ /**
216
+ * Build the digest: essential sections in full (capped), then deferred
217
+ * sections back-filled in priority order while they fit the budget, then a
218
+ * table of contents for what was left out.
219
+ */
220
+ export function buildDigest(doc, opts = {}) {
221
+ const wholeThreshold = opts.wholeDocThreshold ?? DEFAULT_WHOLE_DOC_THRESHOLD;
222
+ const budget = opts.budget ?? DEFAULT_DIGEST_BUDGET;
223
+ const cap = opts.sectionCap ?? DEFAULT_SECTION_CAP;
224
+ const flat = flattenSections(doc.sections);
225
+ const whole = () => ({
226
+ markdown: renderWhole(doc),
227
+ truncated: false,
228
+ sections: flat.map((s) => summary(s, true)),
229
+ omitted: 0,
230
+ omittedChars: 0,
231
+ });
232
+ // Nothing to section on, or small enough that a second round trip costs more than it saves.
233
+ if (flat.length === 0 || doc.chars <= wholeThreshold)
234
+ return whole();
235
+ const decisions = new Map();
236
+ let used = doc.title ? doc.title.length + 4 : 0;
237
+ used += doc.preface.length;
238
+ // Pass 1: essentials. A deferred child of an essential parent (e.g. Optional
239
+ // fields under a custom action's `Request Body`) stays deferred — unless it is
240
+ // a sub-section (H3+) that itself contains essentials, such as Notion's
241
+ // `Request Body > Option B > Required Request Body Fields`. Then its own text
242
+ // is kept as scaffolding and its children are decided individually. H1/H2
243
+ // deferred nodes (appended schema chunks, companion endpoints) go wholesale.
244
+ const hasEssentialDescendant = (n) => n.children.some((c) => c.tier === 'essential' || hasEssentialDescendant(c));
245
+ const walk = (list) => {
246
+ for (const node of list) {
247
+ if (node.tier === 'essential' || (node.level >= 3 && hasEssentialDescendant(node))) {
248
+ const own = node.text;
249
+ if (own.length > cap) {
250
+ const render = truncateText(own, cap, node.id);
251
+ decisions.set(node.id, { node, included: 'partial', render });
252
+ used += render.length;
253
+ }
254
+ else {
255
+ decisions.set(node.id, { node, included: true, render: own });
256
+ used += own.length;
257
+ }
258
+ walk(node.children);
259
+ }
260
+ else {
261
+ decisions.set(node.id, { node, included: false, render: '' });
262
+ // Children of a deferred node are deferred wholesale.
263
+ for (const c of flattenSections(node.children))
264
+ decisions.set(c.id, { node: c, included: false, render: '' });
265
+ }
266
+ }
267
+ };
268
+ walk(doc.sections);
269
+ // Pass 2: back-fill deferred subtrees whose *parent* is included or top-level,
270
+ // smallest priority first, while they fit whole. Extra H1s (appended schema
271
+ // chunks, companion endpoints) are never back-filled: pulling three random
272
+ // schema types into the digest would mislead more than it helps.
273
+ const candidates = flat
274
+ .filter((s) => decisions.get(s.id)?.included === false)
275
+ .filter((s) => s.level >= 2)
276
+ .filter((s) => {
277
+ const parent = parentOf(doc.sections, s);
278
+ return !parent || decisions.get(parent.id)?.included !== false;
279
+ })
280
+ .sort((a, b) => deferredPriority(a.heading) - deferredPriority(b.heading) || a.chars - b.chars);
281
+ for (const s of candidates) {
282
+ if (used + s.chars > budget)
283
+ continue;
284
+ for (const n of [s, ...flattenSections(s.children)]) {
285
+ decisions.set(n.id, { node: n, included: true, render: n.text });
286
+ }
287
+ used += s.chars;
288
+ }
289
+ // If everything ended up included, the doc fits — return it whole.
290
+ const allIn = flat.every((s) => decisions.get(s.id)?.included === true);
291
+ if (allIn)
292
+ return whole();
293
+ // Render in document order.
294
+ const out = [];
295
+ if (doc.title)
296
+ out.push(`# ${doc.title}`);
297
+ if (doc.preface)
298
+ out.push(doc.preface);
299
+ for (const s of flat) {
300
+ const d = decisions.get(s.id);
301
+ if (d.included !== false)
302
+ out.push(d.render.replace(/\n+$/, ''));
303
+ }
304
+ const sections = flat.map((s) => summary(s, decisions.get(s.id).included));
305
+ // Chars the agent has not seen: whole omitted subtrees (counted once, at the
306
+ // top-most omitted node) plus the cut tail of every partial section.
307
+ const topOmitted = flat.filter((s) => {
308
+ if (decisions.get(s.id).included !== false)
309
+ return false;
310
+ const parent = parentOf(doc.sections, s);
311
+ return !parent || decisions.get(parent.id).included !== false;
312
+ });
313
+ const omittedChars = topOmitted.reduce((sum, s) => sum + s.chars, 0) +
314
+ flat
315
+ .filter((s) => decisions.get(s.id).included === 'partial')
316
+ .reduce((sum, s) => sum + (s.text.length - decisions.get(s.id).render.length), 0);
317
+ return {
318
+ markdown: out.join('\n\n'),
319
+ truncated: true,
320
+ sections,
321
+ omitted: topOmitted.length + flat.filter((s) => decisions.get(s.id).included === 'partial').length,
322
+ omittedChars: Math.max(0, omittedChars),
323
+ };
324
+ }
325
+ function summary(s, included) {
326
+ return { id: s.id, heading: s.heading, level: s.level, chars: s.chars, included };
327
+ }
328
+ function parentOf(roots, target) {
329
+ const find = (list, parent) => {
330
+ for (const n of list) {
331
+ if (n === target)
332
+ return parent;
333
+ const r = find(n.children, n);
334
+ if (r !== undefined)
335
+ return r;
336
+ }
337
+ return undefined;
338
+ };
339
+ return find(roots, null) ?? null;
340
+ }
341
+ export function renderWhole(doc) {
342
+ const out = [];
343
+ if (doc.title)
344
+ out.push(`# ${doc.title}`);
345
+ if (doc.preface)
346
+ out.push(doc.preface);
347
+ for (const s of doc.sections)
348
+ out.push(sectionText(s));
349
+ return out.join('\n\n');
350
+ }
351
+ export const DEFAULT_TOC_MAX_ENTRIES = 60;
352
+ /**
353
+ * Shrink a table of contents for the JSON envelope. Scraped mega-docs can
354
+ * carry 400+ headings (45 KB of metadata — more than the digest itself), so
355
+ * the list is cut to the shallowest heading depth that fits `max` entries.
356
+ * Hidden descendants are counted on their nearest surviving ancestor; the
357
+ * full list is one `--toc` call away.
358
+ */
359
+ export function collapseSections(sections, max = DEFAULT_TOC_MAX_ENTRIES) {
360
+ if (sections.length <= max)
361
+ return { sections, collapsed: false };
362
+ // The action doc proper is everything before the first extra H1; what
363
+ // follows is appended scrape chunks / companion endpoints. The canonical
364
+ // part keeps its H2s (and H3s when they fit); the appendix collapses to
365
+ // its H1s no matter what — those are the entries nobody needs to see.
366
+ const firstH1 = sections.findIndex((s) => s.level === 1);
367
+ const canonEnd = firstH1 === -1 ? sections.length : firstH1;
368
+ for (const depth of [3, 2]) {
369
+ const keep = (s, i) => (i < canonEnd ? s.level <= depth : s.level <= 1);
370
+ const count = sections.filter(keep).length;
371
+ if (count > max && depth !== 2)
372
+ continue;
373
+ const out = [];
374
+ let owner = null;
375
+ sections.forEach((s, i) => {
376
+ if (keep(s, i)) {
377
+ owner = { ...s };
378
+ out.push(owner);
379
+ }
380
+ else if (owner) {
381
+ owner.children = (owner.children ?? 0) + 1;
382
+ }
383
+ });
384
+ return { sections: out, collapsed: true };
385
+ }
386
+ return { sections, collapsed: false };
387
+ }
388
+ /**
389
+ * The table of contents for the JSON envelope: only what the digest left
390
+ * out. Included sections are already visible as headings in the markdown, so
391
+ * listing them again cost ~1.9k chars per response — enough to make the
392
+ * median (small) doc *larger* than before. Collapsed for mega-docs.
393
+ */
394
+ export function omittedSections(digest, max = DEFAULT_TOC_MAX_ENTRIES) {
395
+ return collapseSections(digest.sections.filter((s) => s.included !== true), max);
396
+ }
397
+ /**
398
+ * The trailer appended to a digest so an agent that only reads the markdown
399
+ * (and never looks at the JSON envelope) still knows the document continues.
400
+ */
401
+ export function renderDigestNotice(digest, platform, actionId) {
402
+ if (!digest.truncated)
403
+ return '';
404
+ // List only the top-most omitted sections: in document order the nearest
405
+ // preceding section with a lower level is the parent, and children of an
406
+ // omitted parent are implied by it.
407
+ const topMost = digest.sections.filter((s, idx) => {
408
+ if (s.included === true)
409
+ return false;
410
+ for (let i = idx - 1; i >= 0; i--) {
411
+ const prev = digest.sections[i];
412
+ if (prev.level < s.level)
413
+ return prev.included !== false;
414
+ }
415
+ return true;
416
+ });
417
+ // Extra H1s are appended scrape chunks or companion endpoints, not part of
418
+ // the action doc proper — say so, or "Append Block Children" showing up as
419
+ // omitted reads as if the core doc itself were missing.
420
+ const names = topMost.map((s) => {
421
+ if (s.included === 'partial')
422
+ return `${s.heading} (partial)`;
423
+ if (s.level === 1)
424
+ return `${s.heading} (appendix)`;
425
+ return s.heading;
426
+ });
427
+ // A scraped mega-doc can omit 30+ sections; naming a dozen is enough to
428
+ // orient the agent, the rest are reachable by name on a follow-up call.
429
+ const MAX_NAMES = 12;
430
+ const shown = names.slice(0, MAX_NAMES);
431
+ const rest = names.length - shown.length;
432
+ const nameList = rest > 0 ? `${shown.join(', ')}, and ${rest} more` : shown.join(', ');
433
+ return [
434
+ '---',
435
+ `**This is a digest, not the full document.** ${topMost.length} section${topMost.length === 1 ? '' : 's'} omitted (${digest.omittedChars.toLocaleString('en-US')} chars): ${nameList}.`,
436
+ `Load one or more by calling get_one_action_knowledge again for ${platform} / ${actionId} with section: "${topMost[0]?.heading ?? 'Response'}" (comma-separate several).`,
437
+ `Load the whole document with full: true.`,
438
+ ].join('\n');
439
+ }
440
+ /**
441
+ * One line for the *top* of a digest, so an agent that reads the beginning of
442
+ * the markdown and stops early still learns the document continues.
443
+ */
444
+ export function renderDigestBanner(digest) {
445
+ if (!digest.truncated)
446
+ return '';
447
+ return `> **Digest.** ${digest.omitted} section${digest.omitted === 1 ? '' : 's'} (${digest.omittedChars.toLocaleString('en-US')} chars) omitted: response shapes, reference schemas, or worked examples. The notice at the end names them and shows how to load them.`;
448
+ }
449
+ /** Short names agents are likely to type. Each maps to a predicate over the canonical heading. */
450
+ const ALIASES = {
451
+ response: (c) => c === 'response',
452
+ responses: (c) => c === 'response',
453
+ 'response fields': (c) => c === 'response fields',
454
+ fields: (c) => c === 'response fields',
455
+ optional: (c) => c.startsWith('optional '),
456
+ required: (c) => c.startsWith('required '),
457
+ examples: (c) => c === 'example usage' || c === 'examples',
458
+ example: (c) => c === 'example usage' || c === 'examples',
459
+ errors: (c) => c.startsWith('error response') || c === 'error handling',
460
+ success: (c) => c.startsWith('success response'),
461
+ body: (c) => c === 'request body' || c.endsWith('request body fields'),
462
+ query: (c) => c.endsWith('query parameters'),
463
+ path: (c) => c.endsWith('path parameters'),
464
+ notes: (c) => c === 'notes',
465
+ behavior: (c) => c === 'behavior' || c === 'behaviour',
466
+ gotchas: (c) => c === 'gotchas',
467
+ };
468
+ function normalizeQuery(q) {
469
+ return q.trim().replace(/^["'`]+|["'`]+$/g, '').toLowerCase().replace(/\s+/g, ' ');
470
+ }
471
+ /**
472
+ * Resolve one user-supplied name to sections. Match order: exact id, exact
473
+ * heading, alias, slug prefix / heading prefix, substring. Prefix and
474
+ * substring matches must be unique; aliases may return several.
475
+ */
476
+ export function findSections(doc, query) {
477
+ const flat = flattenSections(doc.sections);
478
+ const q = normalizeQuery(query);
479
+ const qSlug = slugify(q);
480
+ const miss = (reason, candidates) => ({
481
+ ok: false,
482
+ reason,
483
+ query,
484
+ candidates: candidates.map((s) => summary(s, false)),
485
+ });
486
+ if (!q)
487
+ return miss('not-found', flat);
488
+ // Exact id and exact heading together: a doc with two `## Response`
489
+ // headings (id `response` and `response-2`) should return both, not
490
+ // silently the first.
491
+ const exact = flat.filter((s) => s.id === qSlug || canonicalHeading(s.heading) === q || s.heading.toLowerCase() === q);
492
+ if (exact.length)
493
+ return { ok: true, sections: exact };
494
+ const alias = ALIASES[q];
495
+ if (alias) {
496
+ const hits = flat.filter((s) => alias(canonicalHeading(s.heading)));
497
+ if (hits.length)
498
+ return { ok: true, sections: dropNested(hits) };
499
+ }
500
+ const byPrefix = flat.filter((s) => s.id.startsWith(qSlug) || canonicalHeading(s.heading).startsWith(q));
501
+ if (byPrefix.length === 1)
502
+ return { ok: true, sections: byPrefix };
503
+ if (byPrefix.length > 1) {
504
+ // "error response" should match every error response block, not be ambiguous.
505
+ const sameCanon = new Set(byPrefix.map((s) => canonicalHeading(s.heading).replace(/\s*\d.*$/, '')));
506
+ if (sameCanon.size === 1)
507
+ return { ok: true, sections: dropNested(byPrefix) };
508
+ return miss('ambiguous', byPrefix);
509
+ }
510
+ const bySubstring = flat.filter((s) => s.id.includes(qSlug) || canonicalHeading(s.heading).includes(q));
511
+ if (bySubstring.length === 1)
512
+ return { ok: true, sections: bySubstring };
513
+ if (bySubstring.length > 1)
514
+ return miss('ambiguous', bySubstring);
515
+ return miss('not-found', flat);
516
+ }
517
+ /** If a hit's ancestor is also a hit, drop the descendant — its text is already inside the ancestor. */
518
+ function dropNested(hits) {
519
+ const inside = new Set();
520
+ for (const h of hits)
521
+ for (const c of flattenSections(h.children))
522
+ inside.add(c.id);
523
+ return hits.filter((h) => !inside.has(h.id));
524
+ }
525
+ /**
526
+ * Resolve a comma-separated `--section` value. Every name must resolve; the
527
+ * first failure is returned so the agent can correct it.
528
+ */
529
+ export function selectSections(doc, names) {
530
+ const picked = [];
531
+ const seen = new Set();
532
+ for (const name of names) {
533
+ const r = findSections(doc, name);
534
+ if (!r.ok)
535
+ return r;
536
+ for (const s of dropNested(r.sections)) {
537
+ if (!seen.has(s.id)) {
538
+ seen.add(s.id);
539
+ picked.push(s);
540
+ }
541
+ }
542
+ }
543
+ // Document order, and drop anything nested inside another pick.
544
+ const order = new Map(flattenSections(doc.sections).map((s, i) => [s.id, i]));
545
+ const final = dropNested(picked).sort((a, b) => order.get(a.id) - order.get(b.id));
546
+ const parts = [doc.title ? `# ${doc.title}` : '', ...final.map(sectionText)].filter(Boolean);
547
+ return { ok: true, sections: final, markdown: parts.join('\n\n') };
548
+ }
549
+ /** Split a `--section` flag value (repeatable, comma-separated) into names. */
550
+ export function parseSectionFlag(value) {
551
+ if (!value)
552
+ return [];
553
+ const raw = Array.isArray(value) ? value : [value];
554
+ return raw
555
+ .flatMap((v) => v.split(','))
556
+ .map((v) => v.trim())
557
+ .filter(Boolean);
558
+ }