@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.
- package/README.md +3 -0
- package/build/client.js +90 -12
- package/build/client.test.js +101 -0
- package/build/helpers.js +105 -0
- package/build/index.js +16 -3
- package/build/knowledge-response.test.js +73 -0
- package/build/knowledge-sections.js +558 -0
- package/build/knowledge-sections.test.js +359 -0
- package/build/schemas.js +5 -2
- package/package.json +3 -1
|
@@ -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
|
+
}
|