@capacms/mcp 0.2.0

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/bound.mjs ADDED
@@ -0,0 +1,546 @@
1
+ /**
2
+ * bound.mjs — keep a tool's answer inside a character budget, say what was
3
+ * cut, and never hand back a cursor that skips what was cut.
4
+ *
5
+ * An agent's context is the resource these tools spend. A query that returns
6
+ * 200 entries with long bodies would otherwise fill it with one call, and a
7
+ * reply cut mid-string is JSON nobody can read. So the answer is shortened
8
+ * structurally, in this order, and each step only as far as it has to go:
9
+ *
10
+ * 1. The REST twin (`rest`) loses rows from its end while it takes more
11
+ * than half the budget and more than 2,000 characters. A twin URL is
12
+ * never clipped, and a `nodes(ids:)` read's lists every id, so without
13
+ * this it would crowd the data out.
14
+ * 2. A content string longer than half the budget is clipped, longest
15
+ * first, to the room the answer has, so one long value does not cut
16
+ * away the entries beside it: a list loses an entry only when the
17
+ * answer does not fit with every such string at 200 characters.
18
+ * 3. Content lists lose items from their end, the largest list first,
19
+ * measured item by item, down to one item each. A string clipped
20
+ * before then takes back the room the cut leaves, longest first.
21
+ * 4. Long content strings are clipped, longest first, each to the room
22
+ * actually left, and never below 200 characters.
23
+ * 5. Metadata lists (`rest`, `deprecations`, and `errors` beside `data`)
24
+ * lose items from their end: the REST twin of a root field whose data
25
+ * is shown is worth more than one more entry, so it goes last. A tool
26
+ * that names `rest` in `spare` (capa_graphql_query) has it left out
27
+ * whole before the last entry of the data goes.
28
+ *
29
+ * Never clipped: an id, a cursor, a url, or anything in `pageInfo`, `rest`,
30
+ * `deprecations` or a response's `errors`. A clipped cursor is a cursor the
31
+ * API refuses, and a clipped error loses the fix it carries.
32
+ *
33
+ * A CUT CONNECTION STAYS PAGEABLE. Its `nodes` and `edges` are cut together,
34
+ * and its `pageInfo` is made true of what is kept. A page read forward
35
+ * (`first`, `after`) loses entries from its end: `hasNextPage` is true (the
36
+ * cut entries come next), and `endCursor` is the cursor of the last entry
37
+ * kept. A page read backward (`last`, `before`) loses them from its start,
38
+ * farthest from `before`: `hasPreviousPage` is true, and `startCursor` is the
39
+ * cursor of the first entry kept. The cursor is read from the entry's edge, or
40
+ * from `cursors` when the caller holds them. With no cursor to give, it is
41
+ * null and the cut says how to page on: the uncut page's cursor would skip
42
+ * every entry that was cut.
43
+ *
44
+ * Which lists are a connection's entries, which object is its pageInfo, and
45
+ * which way it pages is read from the document when the caller passes
46
+ * `connections` (lib/graphql/document.mjs), so `items: nodes` and `info:
47
+ * pageInfo` are repaired under the keys they answer with. Without them, a
48
+ * list under `nodes` or `edges` beside a `pageInfo` is a connection read
49
+ * forward. A connection with its own `resume` says how to page on in its own
50
+ * words: a REST page's is `limit`, not `first` (lib/rest-tools.mjs).
51
+ *
52
+ * Every cut list is recorded as `{ path, kept, total }`, a connection's edges
53
+ * and nodes each, and every clipped string as `{ path, kept, total }` in
54
+ * characters, so the agent knows the answer is partial and where, and a
55
+ * clipped string's `kept` is where to read on from. `maxChars` bounds the
56
+ * whole answer, notes included.
57
+ */
58
+
59
+ export const DEFAULT_MAX_CHARS = 20_000;
60
+ export const TRUNCATION_HINT = "Ask for fewer fields or a smaller first.";
61
+ /** The shortest a string is clipped to, and the shortest that is ever clipped. */
62
+ const MIN_STRING = 200;
63
+ /** What a clipped string ends with. */
64
+ const ELLIPSIS = "...";
65
+ /** The REST twin keeps whole until it takes more than half the budget and more than this. */
66
+ const REST_FLOOR = 2_000;
67
+ const MAX_NOTES_LISTED = 10;
68
+ /** Strings under these keys are never clipped: `next` and `prev` are REST's cursors. */
69
+ const WHOLE_STRING_KEYS = new Set(["id", "cursor", "startCursor", "endCursor", "url", "next", "prev"]);
70
+ /**
71
+ * Outside `data`, the parts of an answer that describe the request rather
72
+ * than hold content: `rest`, `deprecations`, and `errors` beside `data` (a
73
+ * GraphQL response's errors; an answer that is about errors, such as
74
+ * capa_explain_error's, holds them as its content).
75
+ */
76
+ const isMetadata = (holder, name) => name === "rest" || name === "deprecations" || (name === "errors" && "data" in holder);
77
+ const CONTENT = 0;
78
+ const METADATA = 1;
79
+
80
+ function size(value) {
81
+ return JSON.stringify(value)?.length ?? 0;
82
+ }
83
+
84
+ function join(path, key) {
85
+ if (typeof key === "number") return `${path || "$"}[${key}]`;
86
+ return path ? `${path}.${key}` : key;
87
+ }
88
+
89
+ /**
90
+ * Every list that may be shortened and every string that may be clipped in
91
+ * `value`, with where it is, what holds it, and its tier. `keep` names
92
+ * top-level keys left untouched; `whole` names lists that keep every item.
93
+ */
94
+ function inventory(value, { keep, whole }) {
95
+ const lists = [];
96
+ const strings = [];
97
+ const visit = (node, path, holder, key, at) => {
98
+ if (Array.isArray(node)) {
99
+ if (!at.frozen && !whole.has(path || "$")) lists.push({ path: path || "$", array: node, holder, key, tier: at.tier });
100
+ node.forEach((item, i) => visit(item, join(path, i), node, i, at));
101
+ } else if (node && typeof node === "object") {
102
+ for (const [name, inner] of Object.entries(node)) {
103
+ const inData = at.inData || name === "data";
104
+ visit(inner, join(path, name), node, name, {
105
+ frozen: at.frozen || (path === "" && keep.has(name)),
106
+ inData,
107
+ tier: at.tier === METADATA || (!at.inData && isMetadata(node, name)) ? METADATA : CONTENT,
108
+ wholeStrings: at.wholeStrings || name === "pageInfo" || (!at.inData && isMetadata(node, name)),
109
+ });
110
+ }
111
+ } else if (typeof node === "string" && node.length > MIN_STRING && !at.frozen && !at.wholeStrings && !WHOLE_STRING_KEYS.has(key)) {
112
+ strings.push({ path, holder, key, tier: at.tier });
113
+ }
114
+ };
115
+ visit(value, "", null, null, { frozen: false, inData: false, tier: CONTENT, wholeStrings: false });
116
+ return { lists, strings };
117
+ }
118
+
119
+ /** A connection read by its literal field names, forward: what an answer with no document is read as. */
120
+ const LITERAL_CONNECTION = {
121
+ nodes: ["nodes"],
122
+ edges: [{ key: "edges", cursor: ["cursor"] }],
123
+ pageInfo: [{ key: "pageInfo", fields: { hasNextPage: ["hasNextPage"], hasPreviousPage: [], startCursor: [], endCursor: ["endCursor"] } }],
124
+ backward: false,
125
+ };
126
+
127
+ /** Where the object holding `list` sits: `data.articles` for `data.articles.nodes`. */
128
+ const holderPathOf = (list) => list.path.slice(0, list.path.length - String(list.key).length).replace(/\.$/, "");
129
+
130
+ /** `data.articles.nodes[3].coauthors` as the document names it: `data.articles.nodes.coauthors`. */
131
+ const shapeOf = (path) => path.replace(/\[\d+\]/g, "");
132
+
133
+ /**
134
+ * The connection whose entries `list` is, `{ ...shape, holderPath }`, or null.
135
+ * With `connections` from the document, only a list the document reads as
136
+ * `nodes` or `edges` (under whatever key) is one.
137
+ */
138
+ function connectionOf(list, connections) {
139
+ if (!list.holder || Array.isArray(list.holder)) return null;
140
+ const holderPath = holderPathOf(list);
141
+ const shape = connections ? connections[shapeOf(holderPath)] : LITERAL_CONNECTION;
142
+ if (!shape) return null;
143
+ const isEntries = shape.nodes.includes(list.key) || shape.edges.some((edge) => edge.key === list.key);
144
+ return isEntries ? { ...shape, holderPath } : null;
145
+ }
146
+
147
+ /** A connection's nodes and edges, which are cut together, each with its path; any other list alone. */
148
+ function parallelOf(list, connection) {
149
+ if (!connection) return [{ path: list.path, array: list.array }];
150
+ return [...connection.nodes, ...connection.edges.map((edge) => edge.key)]
151
+ .filter((key) => Array.isArray(list.holder[key]))
152
+ .map((key) => ({ path: join(connection.holderPath, key), array: list.holder[key] }));
153
+ }
154
+
155
+ /**
156
+ * Drop items from `list`, and from the lists cut with it, until `excess`
157
+ * characters are freed or one item is left: from the end, or from the start
158
+ * when `fromStart` (a page read backward keeps the entries nearest `before`).
159
+ */
160
+ function trim(list, parallel, excess, fromStart) {
161
+ let dropped = 0;
162
+ let freed = 0;
163
+ while (list.array.length - dropped > 1 && freed < excess) {
164
+ for (const items of parallel) {
165
+ const index = fromStart ? dropped : items.length - 1 - dropped;
166
+ if (index >= 0 && index < items.length) freed += size(items[index]) + 1;
167
+ }
168
+ dropped++;
169
+ }
170
+ for (const items of parallel) {
171
+ if (fromStart) items.splice(0, Math.min(dropped, items.length));
172
+ else items.length = Math.max(0, items.length - dropped);
173
+ }
174
+ }
175
+
176
+ /** The first string under any of `keys` in `holder`. */
177
+ const readAny = (holder, keys) => keys.map((key) => holder?.[key]).find((value) => typeof value === "string");
178
+
179
+ /**
180
+ * Make a cut connection's pageInfo true of what it kept: `total` entries were
181
+ * served and some were dropped. Returns how to page on when there is no cursor
182
+ * to give, else null.
183
+ */
184
+ function repairPage(holder, connection, cursors, total) {
185
+ const entries = [...connection.nodes, ...connection.edges.map((edge) => edge.key)].map((key) => holder[key]).find(Array.isArray);
186
+ const kept = entries?.length ?? 0;
187
+ const backward = connection.backward;
188
+ const [moreKey, cursorKey] = backward ? ["hasPreviousPage", "startCursor"] : ["hasNextPage", "endCursor"];
189
+ // The cursor of the entry at the cut: the first kept going back, the last kept going on.
190
+ const edgeCursor = connection.edges
191
+ .map((edge) => {
192
+ const items = holder[edge.key];
193
+ if (!Array.isArray(items) || !items.length) return undefined;
194
+ return readAny(items[backward ? 0 : items.length - 1], edge.cursor);
195
+ })
196
+ .find((value) => typeof value === "string");
197
+ const held = cursors?.[backward ? total - kept : kept - 1];
198
+ const cursor = edgeCursor ?? (typeof held === "string" ? held : null);
199
+ let asked = false;
200
+ for (const { key, fields } of connection.pageInfo) {
201
+ const info = holder[key];
202
+ if (!info || typeof info !== "object" || Array.isArray(info)) continue;
203
+ for (const alias of fields[moreKey]) if (alias in info) info[alias] = true;
204
+ for (const alias of fields[cursorKey]) {
205
+ if (!(alias in info)) continue;
206
+ info[alias] = cursor;
207
+ asked = true;
208
+ }
209
+ }
210
+ if (!asked || cursor !== null) return null;
211
+ if (connection.resume) return connection.resume(kept);
212
+ return backward
213
+ ? `startCursor is null because entries were cut; the uncut page's would skip them. Run the query again with last: ${kept} and the same before for a cursor before the first entry shown, or select edges { cursor }.`
214
+ : `endCursor is null because entries were cut; the uncut page's would skip them. Run the query again with first: ${kept} for a cursor after the last entry shown, or select edges { cursor }.`;
215
+ }
216
+
217
+ const fits = (value, maxChars) => size(value) <= maxChars;
218
+
219
+ /** The first `length` UTF-16 units of `text`, never ending inside a surrogate pair. */
220
+ function prefix(text, length) {
221
+ const end = length > 0 && /[\uD800-\uDBFF]/.test(text[length - 1] ?? "") ? length - 1 : length;
222
+ return text.slice(0, end);
223
+ }
224
+
225
+ /** Where every object and array in `value` sits: each clipped string is named by where its holder ended up. */
226
+ function pathsOf(value) {
227
+ const paths = new Map();
228
+ const visit = (node, path) => {
229
+ if (!node || typeof node !== "object") return;
230
+ paths.set(node, path);
231
+ if (Array.isArray(node)) node.forEach((item, i) => visit(item, join(path, i)));
232
+ else for (const [name, inner] of Object.entries(node)) visit(inner, join(path, name));
233
+ };
234
+ visit(value, "");
235
+ return paths;
236
+ }
237
+
238
+ /**
239
+ * `value` cut to fit `maxChars` when serialized (see the file comment for the
240
+ * order), the lists cut and the strings clipped. The input is not modified.
241
+ * Options: `keep` (top-level keys never touched), `whole` (lists that keep
242
+ * every item), `cursors` (a connection's cursors by its path, for a
243
+ * connection whose answer does not carry them), `connections` (the
244
+ * document's connections by path, from `connectionsOf`).
245
+ */
246
+ export function boundValue(value, maxChars = DEFAULT_MAX_CHARS, { keep = new Set(), whole = new Set(), cursors = {}, connections = null } = {}) {
247
+ if (fits(value, maxChars)) return { value, truncated: [], clipped: [] };
248
+ const copy = structuredClone(value);
249
+ const scope = { keep, whole };
250
+ const cuts = new Map();
251
+ const cutConnections = new Map();
252
+ /** Each clipped string by its holder and key: the text it had, and how much of it is kept. */
253
+ const clips = new Map();
254
+ const half = Math.floor(maxChars / 2);
255
+
256
+ /** Leave out the REST twin's last rows while it takes more than half the budget and more than REST_FLOOR. */
257
+ const capRest = () => {
258
+ const rest = copy.rest;
259
+ const share = Math.max(REST_FLOOR, half);
260
+ if (!Array.isArray(rest) || keep.has("rest") || whole.has("rest") || size(rest) <= share) return;
261
+ const total = rest.length;
262
+ while (rest.length && size(rest) > share) rest.pop();
263
+ cuts.set("rest", { path: "rest", kept: rest.length, total });
264
+ };
265
+
266
+ const cutLists = (tier) => {
267
+ while (!fits(copy, maxChars)) {
268
+ const candidates = inventory(copy, scope).lists.filter((l) => l.tier === tier && l.array.length > 1);
269
+ if (!candidates.length) return;
270
+ const sized = candidates.map((list) => ({ list, chars: size(list.array) }));
271
+ const biggest = sized.reduce((a, b) => (b.chars > a.chars ? b : a)).list;
272
+ // A connection's edges and nodes are both shortened, so both are noted.
273
+ const connection = connectionOf(biggest, connections);
274
+ const parallel = parallelOf(biggest, connection);
275
+ const lengths = parallel.map(({ array }) => array.length);
276
+ const fromStart = connection?.backward === true;
277
+ trim(biggest, parallel.map((p) => p.array), size(copy) - maxChars, fromStart);
278
+ parallel.forEach(({ path, array }, i) => {
279
+ if (array.length >= lengths[i]) return;
280
+ const note = { path, kept: array.length, total: cuts.get(path)?.total ?? lengths[i] };
281
+ cuts.set(path, fromStart ? { ...note, cutFrom: "start" } : note);
282
+ });
283
+ if (connection) {
284
+ const known = cutConnections.get(connection.holderPath);
285
+ cutConnections.set(connection.holderPath, {
286
+ holder: biggest.holder,
287
+ connection,
288
+ paths: parallel.map((p) => p.path),
289
+ total: known?.total ?? Math.max(...lengths),
290
+ });
291
+ }
292
+ }
293
+ };
294
+ /** The text a string held before any clip, and the clip record for it. */
295
+ const clipOf = ({ holder, key }) => clips.get(holder)?.get(key);
296
+ /**
297
+ * Clip one string to the most of the text it first held that lets the
298
+ * answer fit, and never below `floor` characters. Measured as JSON, since
299
+ * escapes make a string longer than its characters.
300
+ */
301
+ const clip = (string, floor) => {
302
+ const current = string.holder[string.key];
303
+ const record = clipOf(string);
304
+ const text = record?.text ?? current;
305
+ const room = maxChars - (size(copy) - size(current));
306
+ const clipped = (length) => `${prefix(text, length)}${ELLIPSIS}`;
307
+ // The longest prefix that fits the room, found by bisection between the floor and what is kept now.
308
+ let low = floor;
309
+ let high = (record?.kept ?? text.length) - 1;
310
+ if (high < low) return;
311
+ while (low < high) {
312
+ const middle = Math.ceil((low + high) / 2);
313
+ if (size(clipped(middle)) <= room) low = middle;
314
+ else high = middle - 1;
315
+ }
316
+ const kept = prefix(text, low);
317
+ string.holder[string.key] = `${kept}${ELLIPSIS}`;
318
+ if (!clips.has(string.holder)) clips.set(string.holder, new Map());
319
+ clips.get(string.holder).set(string.key, { text, kept: kept.length });
320
+ };
321
+ /**
322
+ * Give each clipped string still in the answer back as much of its text as
323
+ * the room left holds, longest first: a list cut item by item can free
324
+ * more than the clip before it needed. A string that fits whole is no
325
+ * longer clipped.
326
+ */
327
+ const regrow = () => {
328
+ const where = pathsOf(copy);
329
+ const held = [...clips]
330
+ .filter(([holder]) => where.has(holder))
331
+ .flatMap(([holder, keys]) => [...keys].map(([key, record]) => ({ holder, key, record })))
332
+ .sort((a, b) => b.record.text.length - a.record.text.length);
333
+ for (const { holder, key, record } of held) {
334
+ const current = holder[key];
335
+ const room = maxChars - (size(copy) - size(current));
336
+ if (size(record.text) <= room) {
337
+ holder[key] = record.text;
338
+ clips.get(holder).delete(key);
339
+ continue;
340
+ }
341
+ const clipped = (length) => `${prefix(record.text, length)}${ELLIPSIS}`;
342
+ let low = record.kept;
343
+ let high = record.text.length - 1;
344
+ while (low < high) {
345
+ const middle = Math.ceil((low + high) / 2);
346
+ if (size(clipped(middle)) <= room) low = middle;
347
+ else high = middle - 1;
348
+ }
349
+ if (low <= record.kept) continue;
350
+ const kept = prefix(record.text, low);
351
+ holder[key] = `${kept}${ELLIPSIS}`;
352
+ record.kept = kept.length;
353
+ }
354
+ };
355
+ /** Clip the strings of `tier` longer than `over`, longest first, none below `floor`, while the answer overruns. */
356
+ const clipStrings = (tier, over, floor) => {
357
+ if (fits(copy, maxChars)) return;
358
+ const long = inventory(copy, scope).strings.filter((s) => s.tier === tier && s.holder[s.key].length > over);
359
+ long.sort((a, b) => b.holder[b.key].length - a.holder[a.key].length);
360
+ for (const string of long) {
361
+ if (fits(copy, maxChars)) return;
362
+ clip(string, floor);
363
+ }
364
+ };
365
+
366
+ capRest();
367
+ clipStrings(CONTENT, Math.max(MIN_STRING, half), MIN_STRING);
368
+ cutLists(CONTENT);
369
+ regrow();
370
+ clipStrings(CONTENT, MIN_STRING, MIN_STRING);
371
+ cutLists(METADATA);
372
+ regrow();
373
+ clipStrings(METADATA, MIN_STRING, MIN_STRING);
374
+
375
+ // Cuts inside a list that was itself cut away are not worth reporting, and
376
+ // neither is a string clipped in an entry that was cut away after it.
377
+ const present = new Set(inventory(copy, { keep: new Set(), whole: new Set() }).lists.map((l) => l.path));
378
+ const truncated = [...cuts.values()].filter((c) => present.has(c.path));
379
+ const where = pathsOf(copy);
380
+ const clipped = [...clips]
381
+ .filter(([holder]) => where.has(holder))
382
+ .flatMap(([holder, keys]) => [...keys].map(([key, { text, kept }]) => ({ path: join(where.get(holder), key), kept, total: text.length })))
383
+ .sort((a, b) => b.total - a.total);
384
+ // One note per connection says how to page on: its nodes', else its edges'.
385
+ for (const [holderPath, { holder, connection, paths, total }] of cutConnections) {
386
+ const cut = truncated.find((c) => paths.includes(c.path));
387
+ const resume = cut && repairPage(holder, connection, cursors[holderPath], total);
388
+ if (resume) cut.resume = resume;
389
+ }
390
+ return { value: copy, truncated, clipped };
391
+ }
392
+
393
+ /** A count as the API writes one: 102,400. */
394
+ const grouped = (n) => n.toLocaleString("en-US");
395
+
396
+ /**
397
+ * The hint for a cut answer. For a tool that says how to read on (`readOn`)
398
+ * and a string clipped, it names the longest one, its size and what is
399
+ * shown, then how to read the rest: asking for fewer fields cannot help a
400
+ * value that is long. Otherwise `hint`.
401
+ */
402
+ function hintFor(clipped, hint, readOn) {
403
+ const longest = clipped[0];
404
+ if (!longest || !readOn) return hint;
405
+ return `${longest.path} is ${grouped(longest.total)} characters, and the first ${grouped(longest.kept)} are shown. ${readOn(longest)}`;
406
+ }
407
+
408
+ /** `value` with the notes that say what was cut, and how to ask for less. */
409
+ function withNotes(value, truncated, clipped, hint, readOn) {
410
+ if (!truncated.length && !clipped.length) return value;
411
+ const out = { ...value };
412
+ if (truncated.length) out.truncated = truncated.slice(0, MAX_NOTES_LISTED);
413
+ if (truncated.length > MAX_NOTES_LISTED) out.truncatedMore = truncated.length - MAX_NOTES_LISTED;
414
+ if (clipped.length) out.clipped = clipped.slice(0, MAX_NOTES_LISTED);
415
+ if (clipped.length > MAX_NOTES_LISTED) out.clippedMore = clipped.length - MAX_NOTES_LISTED;
416
+ out.hint = hintFor(clipped, hint, readOn);
417
+ return out;
418
+ }
419
+
420
+ /** Names in a note, at most `limit` of them, then how many more. */
421
+ export function someNames(names, limit = 40) {
422
+ return names.length > limit ? `${names.slice(0, limit).join(", ")} and ${names.length - limit} more` : names.join(", ");
423
+ }
424
+
425
+ /** "a", "a and b", "a, b and c". */
426
+ function listed(names) {
427
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names.join("");
428
+ }
429
+
430
+ function without(value, key) {
431
+ const { [key]: _dropped, ...rest } = value;
432
+ return rest;
433
+ }
434
+
435
+ /**
436
+ * `fallback` when its kept parts overrun `maxChars` beside `truncated`. The
437
+ * budget holds and a kept part is never cut, so the largest kept parts are
438
+ * left out whole, each named in `truncated`, until the answer fits. The hint
439
+ * names the least budget that returns them: `fallback` with neither note nor
440
+ * hint, which `boundAnswer` answers at its own size.
441
+ */
442
+ function withoutKeptParts(fallback, keptKeys, maxChars) {
443
+ const least = without(without(fallback, "note"), "hint");
444
+ const leftOut = [];
445
+ let out = least;
446
+ for (const key of [...keptKeys].sort((a, b) => size(fallback[b]) - size(fallback[a]))) {
447
+ if (fits(out, maxChars)) break;
448
+ out = { ...without(out, key), truncated: [...out.truncated, { path: key, kept: 0, total: 1 }] };
449
+ leftOut.push(key);
450
+ }
451
+ if (!fits(out, maxChars)) return { note: `The answer does not fit in ${maxChars} characters.`, truncated: [{ path: "$", kept: 0, total: 1 }] };
452
+ const one = leftOut.length === 1;
453
+ const hint =
454
+ `${listed(leftOut)} ${one ? "does" : "do"} not fit in ${maxChars} characters, so ${one ? "it is" : "they are"} left out rather than cut. ` +
455
+ `Call again with maxChars ${size(least)} or more for ${one ? "it" : "them"}.`;
456
+ return fits({ ...out, hint }, maxChars) ? { ...out, hint } : out;
457
+ }
458
+
459
+ /**
460
+ * A tool answer (an object) that serializes to at most `maxChars`, with
461
+ * `truncated`, `clipped` and `hint` saying what was cut and how to ask for
462
+ * less. Options, beside `boundValue`'s: `hint` (the advice for a cut answer,
463
+ * a query's by default), `readOn` (given the longest clipped string's note,
464
+ * how to read the rest of it, which the hint then says), `drop` (top-level keys left out whole, in order,
465
+ * before anything else is cut; each is noted as kept 0 of 1), `spare`
466
+ * (top-level keys left out whole, in order, only when one entry per list
467
+ * does not fit beside them, so they give way before the data does; noted the
468
+ * same way) and `fallbackHint` (the advice when even one entry per list does
469
+ * not fit).
470
+ *
471
+ * The budget the value is cut to is searched by bisection, so the notes are
472
+ * paid for exactly: data survives whenever one entry per list and the notes
473
+ * fit, the hint left out when only it does not. Only when they do not is the
474
+ * answer replaced by a note saying so. The note keeps the `keep` keys whole
475
+ * and names each part it left out, and a `keep` key that does not fit either
476
+ * is left out whole, never cut.
477
+ */
478
+ export function boundAnswer(answer, maxChars = DEFAULT_MAX_CHARS, options = {}) {
479
+ const { hint = TRUNCATION_HINT, readOn = null, keep = [], drop = [], spare = [], whole = [], cursors = {}, connections = null, fallbackHint = hint } = options;
480
+ if (fits(answer, maxChars)) return answer;
481
+ let base = answer;
482
+ const dropped = [];
483
+ for (const key of drop) {
484
+ if (!(key in base)) continue;
485
+ base = without(base, key);
486
+ dropped.push({ path: key, kept: 0, total: 1 });
487
+ const out = withNotes(base, dropped, [], hint);
488
+ if (fits(out, maxChars)) return out;
489
+ }
490
+ const scope = { keep: new Set(keep), whole: new Set(whole), cursors, connections };
491
+ /** The answer cut to `budget` with the spare parts `left` left out, the hint kept when `hinted`; null when it overruns. */
492
+ const cutWith = (left, hinted) => (budget) => {
493
+ const from = left.reduce(without, base);
494
+ const { value, truncated, clipped } = boundValue(from, budget, scope);
495
+ const out = withNotes(value, [...dropped, ...left.map((path) => ({ path, kept: 0, total: 1 })), ...truncated], clipped, hint, readOn);
496
+ const answer = hinted ? out : without(out, "hint");
497
+ return fits(answer, maxChars) ? answer : null;
498
+ };
499
+ // Before one entry per list gives way, the hint does, then each spare part in turn; what was cut is always named.
500
+ const spares = spare.filter((key) => key in base);
501
+ let cutTo = null;
502
+ let best = null;
503
+ for (let count = 0; count <= spares.length && !best; count++) {
504
+ for (const hinted of [true, false]) {
505
+ cutTo = cutWith(spares.slice(0, count), hinted);
506
+ best = cutTo(0);
507
+ if (best) break;
508
+ }
509
+ }
510
+ if (!best) {
511
+ base = spares.reduce(without, base);
512
+ dropped.push(...spares.map((path) => ({ path, kept: 0, total: 1 })));
513
+ const keptKeys = keep.filter((key) => key in base);
514
+ if (!keptKeys.length) {
515
+ return {
516
+ note: `The answer does not fit in ${maxChars} characters, even cut to one entry per list.`,
517
+ truncated: [{ path: "$", kept: 0, total: 1 }],
518
+ hint: fallbackHint,
519
+ };
520
+ }
521
+ const omitted = Object.keys(base).filter((key) => !keep.includes(key));
522
+ const fallback = {
523
+ ...Object.fromEntries(keptKeys.map((key) => [key, base[key]])),
524
+ note: `${listed(omitted)} does not fit in ${maxChars} characters beside ${listed(keptKeys)}, even cut to one entry per list.`,
525
+ truncated: [...dropped, ...omitted.map((path) => ({ path, kept: 0, total: 1 }))],
526
+ hint: fallbackHint,
527
+ };
528
+ // The note goes first, since truncated names every part the note names,
529
+ // then the hint. Kept parts that overrun the budget even so are left out.
530
+ const noNote = without(fallback, "note");
531
+ return [fallback, noNote, without(noNote, "hint")].find((out) => fits(out, maxChars)) ?? withoutKeptParts(fallback, keptKeys, maxChars);
532
+ }
533
+ let low = 0;
534
+ let high = maxChars;
535
+ while (high - low > 1) {
536
+ const middle = (low + high) >> 1;
537
+ const out = cutTo(middle);
538
+ if (out) {
539
+ low = middle;
540
+ best = out;
541
+ } else {
542
+ high = middle;
543
+ }
544
+ }
545
+ return best;
546
+ }