echelongraph-mcp 2.7.2 → 3.0.2

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.
@@ -1,9 +1,13 @@
1
- // The text budget (#2783): what a success's first text block may cost, and how an answer larger
2
- // than that is cut. index.ts's `succeeded` builds every success's first text block with dataText;
3
- // a tool that returns a list of rows names it with a TextCut. structuredContent.data is never cut.
4
- // A module of its own, so a tool module can import its types and TEXT_BUDGET_DESCRIPTION without
5
- // importing index.ts, which starts the stdio server when loaded.
6
- //
1
+ // The text budget (#2783), and the structured result's (#3013): what a success may cost in each
2
+ // channel a client hands a model, and how an answer larger than that is cut. index.ts's
3
+ // `succeeded` builds every success's first text block with dataText (a tool that returns a list of
4
+ // rows names it with a TextCut), and its structured result with cutData (each tool names its lists
5
+ // in the order they are cut with a DataCut, and answering() hands the call's `full` and DataCut to
6
+ // `succeeded`). A module of its own, so a tool module can import its types,
7
+ // TEXT_BUDGET_DESCRIPTION and FULL_ARG without importing index.ts, which starts the stdio server
8
+ // when loaded.
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ import * as z from "zod";
7
11
  // What a success costs in the channel a model reads is mostly its first text block, and through
8
12
  // 2.6.2 that block was the API's JSON pretty-printed, whatever its size. Measured on production
9
13
  // through https://mcp.echelongraph.io/mcp (2.6.2, 2026-10-04): search_cves {search: "openssl"} at
@@ -18,8 +22,8 @@
18
22
  // serialized in a text block too, as a SHOULD, for backwards compatibility, and SEP-1624 proposes
19
23
  // content as the model-facing, token-efficient form and structuredContent as the machine-facing one.
20
24
  //
21
- // So structuredContent.data is always the API's JSON whole, and the first text block is that JSON
22
- // when it fits DATA_TEXT_BUDGET, half the ceiling, and cut when it does not:
25
+ // So the first text block is the API's JSON when it fits DATA_TEXT_BUDGET, half the ceiling, and
26
+ // cut when it does not (structuredContent.data, below, is cut on its own terms, never from this):
23
27
  // 1. pretty-printed, as since 1.x, when that fits;
24
28
  // 2. else the same JSON with each element of a list on a line of its own, which parses to the
25
29
  // same value, when that fits;
@@ -33,19 +37,73 @@
33
37
  // level keeps, and before any row leaves the text;
34
38
  // 4. else every list keeps its first n entries, n halving, and then strings are cut too.
35
39
  // Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
36
- // that says what the first text block leaves out, that structuredContent.data carries all of it,
40
+ // that says what the first text block leaves out, where structuredContent.data carries all of it,
37
41
  // and how to read the rest in the text: the tool that returns one row, and what of it, and the page
38
42
  // size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
43
+ //
44
+ // The structured result, per client (#3013). Through 2.7.x structuredContent.data was the API's
45
+ // JSON whole, on the reading above that structuredContent is the machine-facing form. It is not, in
46
+ // the client the README gives first-class install steps: Claude Code hands the model
47
+ // structuredContent, not the text blocks (reproduced 2026-10-11 over raw JSON-RPC: get_cve
48
+ // CVE-2021-44228 was 65,540 characters of structuredContent beside a first text block of 23,759,
49
+ // and Claude Code refused it, "exceeds maximum allowed tokens", against its default
50
+ // MAX_MCP_OUTPUT_TOKENS of 25,000, and saved it to a file, so the model got a path, not an answer).
51
+ // The spec (MCP 2025-06-18, Tools, Structured Content) does not say which of the two a client gives
52
+ // the model. So the text cut above reached every client but that one. Since 3.0.0 the structured
53
+ // result is held to the same budget, STRUCTURED_BUDGET: the whole of it, envelope and notes with
54
+ // data, as compact JSON, since that is what such a client sizes. Past it, data is cut (cutData):
55
+ // 1. the lists the tool's DataCut names, step by step in its order. Where a step names thinning
56
+ // levels (a list whose single entries are large: check_sbom's and scan_manifest's results,
57
+ // check_affected's matches, cve_remediation's vendor_remediations, search_cves' and the
58
+ // vendor-advisory tools' rows), every entry is first cut to the fields each level keeps, level
59
+ // by level, all entries kept, so that no row leaves while thinning its detail would do; then
60
+ // each step keeps the most entries that fit, every list in the step at most the same number,
61
+ // its first ones, or for a list named with leaveOutFirst (check_sbom's results) those entries
62
+ // the tool names leave first (the clean rows, counted in data.summary), then the last of the
63
+ // rest, or for a step of series named keepNewest (exposure_radar's daily and weekly counts,
64
+ // which the API sends oldest first) its last ones, the newest, the oldest leaving first; a
65
+ // step that cannot fit even empty is emptied, and the next one is cut. A level can cut inside
66
+ // each entry on its own terms (an EntryLevel, #3132: check_sbom's rows keep at most the same
67
+ // number of matches, the most that fit, so only the rows with more lose any), and a step named
68
+ // keepAll (#3139: cve_remediation's vendor_remediations) keeps every entry at its leanest level
69
+ // while the later steps are cut, its entries leaving only once they all are;
70
+ // 2. then every other list at the top level of data, the same way. A list inside an object of
71
+ // data (get_cve's remediation summary, with fix_status, #2841) is cut only where a DataCut
72
+ // names it, and none names get_cve's. Once a step fits, the room it leaves goes back to the
73
+ // steps emptied before it, the last first, each the same way (scan_manifest's not_checked,
74
+ // emptied while its rows were whole, keeps its entries once the thinned rows fit);
75
+ // 3. no string is shortened and no value changes: a thinned entry keeps some of its fields as
76
+ // they are (every field a thinning level drops is optional in the outputSchema, so data still
77
+ // validates against it), and no field outside the lists cut changes (the one field a cut
78
+ // adds is an EntryLevel's mark, check_sbom's matches_truncated, which says what the row's own
79
+ // cut left out, and which its outputSchema names); the envelope says
80
+ // data_truncated true, truncated gives each list cut by its path in data with its total, the
81
+ // number returned and, where its entries are thinned, thinned true, and the note ends with
82
+ // sentences starting DATA CUT that say what was cut and to which fields, how to read the rest
83
+ // (the offset or page size, where the tool pages: a page size the note says fits data whole
84
+ // is counted on the entries whole, never on the thinned ones, #3013's review) and that the
85
+ // same call with full: true returns data whole.
86
+ // The first text block is cut from the whole answer as before, not from the cut data: what it
87
+ // holds is unchanged for the clients that read it. full: true is for programs, which read data
88
+ // whole whatever its size; a client that sizes structured results, Claude Code among them, may
89
+ // still refuse such an answer past its own cap (MAX_MCP_OUTPUT_TOKENS raises Claude Code's).
90
+ // Rejected (#3013's decision of 2026-10-11): saying only that Claude Code gets files; cutting only
91
+ // for a client whose clientInfo.name is Claude Code (the same call would answer differently by
92
+ // client, and the next client to pass structuredContent on would get the 65,540 again); and dropping
93
+ // structuredContent (it loses the outputSchema check the spec recommends and the suite relies on).
39
94
  export const DATA_TEXT_BUDGET = 30_000;
40
95
  // What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
41
- // block is cut too past the budget (each list to its first entries, every other field kept), so
42
- // only its structuredContent.data is the record whole. A row tool's `whole` sentence starts with it.
43
- export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
96
+ // block is cut too past the budget (each list to its first entries, every other field kept), and
97
+ // since #3013 so is its structured result's data, unless the call says full: true. A row tool's
98
+ // `whole` sentence starts with it.
99
+ export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data with full: true, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
44
100
  // What every tool description says about it, one constant string (marketing-site
45
101
  // lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
46
102
  // DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
47
- // when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale.
48
- export const TEXT_BUDGET_DESCRIPTION = "Past 30,000 characters of JSON, the first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); data in the structured result always holds it whole.";
103
+ // when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale. #3013: the
104
+ // same length as 2.7.x's (203 characters), since several descriptions sit within ten characters of
105
+ // the floor the suite keeps under the client cut (#2924); FULL_ARG says the rest.
106
+ export const TEXT_BUDGET_DESCRIPTION = "Past 30,000 characters of JSON, the first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); so is structured data (DATA CUT), unless full: true.";
49
107
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
50
108
  const field = (o, k) => (isPlainObject(o) ? o[k] : undefined);
51
109
  const listed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
@@ -66,11 +124,12 @@ function cutValue(v, clip, cap, s) {
66
124
  if (typeof v === "string") {
67
125
  if (v.length <= clip)
68
126
  return v;
69
- s.clipped++;
127
+ if (s)
128
+ s.clipped++;
70
129
  return clipString(v, clip);
71
130
  }
72
131
  if (Array.isArray(v)) {
73
- if (v.length > cap)
132
+ if (s && v.length > cap)
74
133
  s.capped++;
75
134
  return v.slice(0, cap).map((x) => cutValue(x, clip, cap, s));
76
135
  }
@@ -78,18 +137,31 @@ function cutValue(v, clip, cap, s) {
78
137
  return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cutValue(x, clip, cap, s)]));
79
138
  return v;
80
139
  }
140
+ // A RowKeep as the fields it names, each with its subfields (null: kept whole), built once per
141
+ // RowKeep (3.0.2: through 3.0.1, once per row cut; a 2,000-row answer's structured cut built it
142
+ // some 12,000 times). A RowKeep is never changed once built.
143
+ const wantedOf = (() => {
144
+ const built = new WeakMap();
145
+ return (keep) => {
146
+ let wanted = built.get(keep);
147
+ if (!wanted)
148
+ built.set(keep, (wanted = new Map(keep.map((k) => (typeof k === "string" ? [k, null] : [k[0], k[1]])))));
149
+ return wanted;
150
+ };
151
+ })();
81
152
  function cutRow(row, keep, level, s, prefix = "") {
82
- const wanted = new Map(keep.map((k) => (typeof k === "string" ? [k, null] : [k[0], k[1]])));
153
+ const wanted = wantedOf(keep);
83
154
  const cap = level.cap ?? Number.POSITIVE_INFINITY;
84
155
  const out = {};
85
- for (const [k, v] of Object.entries(row)) {
156
+ for (const k of Object.keys(row)) {
157
+ const v = row[k];
86
158
  if (!wanted.has(k)) {
87
- s.left.add(`${prefix}${k}`);
159
+ s?.left.add(`${prefix}${k}`);
88
160
  continue;
89
161
  }
90
162
  const sub = wanted.get(k);
91
163
  if (sub && Array.isArray(v)) {
92
- if (v.length > cap)
164
+ if (s && v.length > cap)
93
165
  s.capped++;
94
166
  out[k] = v.slice(0, cap).map((e) => (isPlainObject(e) ? cutRow(e, sub, level, s, `${prefix}${k}[].`) : cutValue(e, level.clip, cap, s)));
95
167
  }
@@ -165,6 +237,12 @@ function cutSides(data, sides) {
165
237
  export const FIRST_TEXT_BLOCK = {
166
238
  opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
167
239
  };
240
+ // #3013: the first text block beside a structuredContent.data that is cut too (DATA CUT): the same
241
+ // text, cut the same way, and sentences that say data is whole only with full: true.
242
+ export const FIRST_TEXT_BLOCK_BESIDE_A_CUT = {
243
+ opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole with full: true (DATA CUT, below).`,
244
+ leftOut: (n, which) => `the other ${n} are left out of it${which}, and structuredContent.data holds them with full: true`,
245
+ };
168
246
  /** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
169
247
  export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
170
248
  const pretty = JSON.stringify(data, null, 2);
@@ -232,7 +310,8 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
232
310
  const which = outFirst > 0
233
311
  ? `: the ${outFirst} whose ${firstOut?.field} is ${(firstOut?.values ?? []).join(" or ")} (${firstOut?.why})${out.size > outFirst ? `, and the last ${out.size - outFirst} of the rest` : ""}`
234
312
  : `, the last ${out.size} in order`;
235
- const left = `Only ${kept.length} of the ${n} rows of ${cut.rows} are in it; the other ${out.size} are in structuredContent.data only${which}.`;
313
+ const elsewhere = place.leftOut ? place.leftOut(out.size, which) : `the other ${out.size} are in structuredContent.data only${which}`;
314
+ const left = `Only ${kept.length} of the ${n} rows of ${cut.rows} are in it; ${elsewhere}.`;
236
315
  return { text: shown, said: say(rowsSaid, ...sidesSaid, left, cut.whole, cut.page?.(kept.length, n, data)) };
237
316
  }
238
317
  base = { ...beside, [cut.rows]: cutRows };
@@ -250,5 +329,492 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
250
329
  const more = last.caps.size > 8 ? `, and ${last.caps.size - 8} more` : "";
251
330
  const lists = last.caps.size ? `Every list in it keeps at most its first ${last.n} ${last.n === 1 ? "entry" : "entries"}: ${listed(named)}${more}, each named with its full length.` : "";
252
331
  const strings = last.s.clipped ? `Every string in it longer than ${last.clip} characters keeps its first ${last.clip}, ending in "…".` : "";
253
- return { text: last.text, said: say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole) };
332
+ const said = say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole);
333
+ if (last.caps.size === 0)
334
+ return { text: last.text, said };
335
+ return { text: last.text, said, capped: Object.fromEntries([...last.caps].map(([p, len]) => [p || "the answer", { total: len, returned: Math.min(last.n, len) }])) };
336
+ }
337
+ // ── The structured result's budget (#3013) ──
338
+ //
339
+ // What a success's structured result may cost, all of it (the envelope, its notes and data), as
340
+ // compact JSON, which is how a client that hands structuredContent to a model reads it: the same
341
+ // number as the first text block's. See "The structured result, per client" above.
342
+ export const STRUCTURED_BUDGET = DATA_TEXT_BUDGET;
343
+ // The clause of a paging tool's DATA CUT sentence that names the page size whose rows fit data
344
+ // whole, given PageAt.wholeFit for its rows (search_cves, search_vendor_advisories).
345
+ export const wholePage = (n) => n > 0
346
+ ? `a page of ${n} ${n === 1 ? "row" : "rows"} or fewer like these fits data whole.`
347
+ : `even a page of one row like these is past ${STRUCTURED_BUDGET} characters with that row whole, so its data is cut too.`;
348
+ // A tool that names no list: only step 2 (every list at the top level of data) cuts its data.
349
+ export const NO_DATA_CUT = { steps: [] };
350
+ const calls = new AsyncLocalStorage();
351
+ export function answering(full, cut, fn) {
352
+ return calls.run({ full: full === true, cut }, fn);
353
+ }
354
+ export const answeringNow = () => calls.getStore();
355
+ // The argument every tool takes (#3013), and what it says, one constant string. It names no other
356
+ // tool and directs no model (test/description-policy.test.mjs reads inputSchema descriptions too).
357
+ export const FULL_ARG_DESCRIPTION = "true: data in the structured result is the answer whole, however long. Without it, a structured result past 30,000 characters of JSON has its data cut (DATA CUT): in an order the note states, the entries of its longest lists keep only some of their fields, and then the lists only some of their entries; data_truncated is true, and truncated gives each list cut with its total, the number returned and whether its entries are thinned. Whole data is for a program; a client that sizes structured results, as Claude Code does against MAX_MCP_OUTPUT_TOKENS, can refuse an answer past its cap.";
358
+ export const FULL_ARG = z.boolean().optional().describe(FULL_ARG_DESCRIPTION);
359
+ // A level as its fields, and what it does inside each entry (#3132).
360
+ const levelOf = (l) => (Array.isArray(l) ? { keep: l } : l);
361
+ const thinned = (e, level, cap = Number.POSITIVE_INFINITY) => {
362
+ if (!isPlainObject(e))
363
+ return e;
364
+ const { keep, capped, mark } = levelOf(level);
365
+ const t = cutRow(e, keep, { keep, clip: Number.POSITIVE_INFINITY });
366
+ const inner = capped ? t[capped] : undefined;
367
+ if (capped && Array.isArray(inner) && inner.length > cap)
368
+ t[capped] = inner.slice(0, cap);
369
+ return mark ? { ...t, ...mark(e, cap) } : t;
370
+ };
371
+ // The longest list named `capped` inside any entry of a list (#3132): the cap a level starts below.
372
+ const longestInside = (v, capped) => Array.isArray(v) ? v.reduce((m, e) => Math.max(m, Array.isArray(field(e, capped)) ? field(e, capped).length : 0), 0) : 0;
373
+ // An entry JSON cannot write (undefined) is written null in a list.
374
+ const entryChars = (e) => JSON.stringify(e)?.length ?? 4;
375
+ const listOf = (data, at) => at.reduce((o, k) => field(o, k), data);
376
+ // 3.0.2: a list's entries are measured, thinned, only when the cut first asks how many characters
377
+ // some of them take (chars), each thinned, measured and let go; and thinned to be kept only when
378
+ // the cut asks for them (keep), only those it keeps, each once. Through 3.0.1 every list the cut
379
+ // tried held every entry thinned for as long as the cut ran, and a level that caps a list inside
380
+ // each entry thinned every entry at no cap before it tried any: on a 2,000-row check_sbom answer
381
+ // (test/http-memory.test.mjs's) the structured cut allocated 108 MB, against 18 MB on 3.0.0 and 24
382
+ // MB now (V8's sampling heap profiler, collected objects included). What is measured and kept is
383
+ // the same: each entry thinned as before, its characters summed as before.
384
+ function listAt(data, path, firstOut, level, newest, cap = Number.POSITIVE_INFINITY, levelAt) {
385
+ const at = path.split(".");
386
+ const v = listOf(data, at);
387
+ if (!Array.isArray(v) || v.length === 0)
388
+ return undefined;
389
+ const n = v.length;
390
+ const made = [];
391
+ const entry = (i) => {
392
+ if (!level)
393
+ return v[i];
394
+ if (!(i in made))
395
+ made[i] = thinned(v[i], level, cap);
396
+ return made[i];
397
+ };
398
+ const thin = level ? levelOf(level).keep : undefined;
399
+ // A capped level's clause only where its cap cuts an entry's list (#3139's review: a package
400
+ // with one range is not "at most its first 1").
401
+ const lv = level ? levelOf(level) : undefined;
402
+ const bites = lv?.capped === undefined || v.some((e) => (Array.isArray(field(e, lv.capped)) ? field(e, lv.capped).length : 0) > cap);
403
+ const thinSaid = lv && bites ? lv.said?.(cap) : undefined;
404
+ // The indices in the order they leave: from the end, or, for a series kept newest, from the start.
405
+ const leavingOrder = (i) => (newest ? i : n - 1 - i);
406
+ const order = [];
407
+ for (const value of firstOut?.values ?? [])
408
+ for (let j = 0; j < n; j++)
409
+ if (field(v[leavingOrder(j)], firstOut?.field ?? "") === value)
410
+ order.push(leavingOrder(j));
411
+ const named = order.length;
412
+ const queued = new Set(order);
413
+ for (let j = 0; j < n; j++)
414
+ if (!queued.has(leavingOrder(j)))
415
+ order.push(leavingOrder(j));
416
+ // The characters of the entries in the order they leave, summed: leaving[j] is the first j's.
417
+ let leaving;
418
+ const sums = () => {
419
+ if (leaving)
420
+ return leaving;
421
+ const lens = [];
422
+ for (let i = 0; i < n; i++)
423
+ lens.push(entryChars(!level || i in made ? entry(i) : thinned(v[i], level, cap)));
424
+ leaving = [0];
425
+ for (const i of order)
426
+ leaving.push(leaving[leaving.length - 1] + lens[i]);
427
+ return leaving;
428
+ };
429
+ const kept = (k) => Math.max(0, Math.min(n, k));
430
+ const over = new Map();
431
+ const keptAt = (k) => {
432
+ const out = new Set(order.slice(0, n - kept(k)));
433
+ return Array.from({ length: n }, (_, i) => i).filter((i) => !out.has(i));
434
+ };
435
+ return {
436
+ path,
437
+ at,
438
+ n,
439
+ firstOut,
440
+ thin,
441
+ ...(thinSaid ? { thinSaid } : {}),
442
+ ...(newest ? { newest } : {}),
443
+ named,
444
+ ...(levelAt !== undefined ? { level: levelAt } : {}),
445
+ cap,
446
+ over,
447
+ chars: (k) => {
448
+ const l = sums();
449
+ return l[n] - l[n - kept(k)] + Math.max(0, kept(k) - 1);
450
+ },
451
+ kept: keptAt,
452
+ keep: (k) => keptAt(k).map((i) => (over.has(i) ? over.get(i)?.entry : entry(i))),
453
+ };
454
+ }
455
+ // `o` with the list at `at` replaced by `v`, each object on the way copied, its keys in their order.
456
+ function withList(o, at, v) {
457
+ const [k, ...rest] = at;
458
+ return { ...o, [k]: rest.length ? withList(o[k], rest, v) : v };
459
+ }
460
+ const orListed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} or ${xs[xs.length - 1]}`);
461
+ // The fields a thinning level keeps, as a sentence says them.
462
+ const keepSaid = (keep) => listed(keep.map((x) => (typeof x === "string" ? x : `${x[0]} (each with ${listed([...x[1]])})`)));
463
+ // The clause on the entries that took back detail (#3132's review): their positions among those
464
+ // kept, in their order in data, grouped where one level and cap holds a run of them (#3132's second
465
+ // review: not always the first kept, since give-back passes an entry that cannot take any back).
466
+ function givenBackSaid(list, k) {
467
+ if (list.over.size === 0)
468
+ return "";
469
+ const kept = list.kept(k);
470
+ const runs = [];
471
+ kept.forEach((i, pos) => {
472
+ const g = list.over.get(i);
473
+ if (!g)
474
+ return;
475
+ const last = runs[runs.length - 1];
476
+ if (last && last.to === pos && last.g.level === g.level && last.g.cap === g.cap)
477
+ last.to = pos + 1;
478
+ else
479
+ runs.push({ from: pos + 1, to: pos + 1, g });
480
+ });
481
+ if (runs.length === 0)
482
+ return "";
483
+ const n = runs.reduce((t, r) => t + r.to - r.from + 1, 0);
484
+ const one = (r) => `${r.from === r.to ? `entry ${r.from}` : `entries ${r.from} to ${r.to}`} cut to ${keepSaid(r.g.keep)}${r.g.said ? `, ${r.g.said}` : ""}`;
485
+ return `; with the room left, ${n} of the entries kept take back detail (by position among those kept, in their order in data): ${runs.map(one).join("; ")}`;
486
+ }
487
+ // What a cut keeps of each list it has cut so far, as truncated and as the DATA CUT sentences.
488
+ function cutSaid(cut, kept, wholeChars, data, over, wholeFit) {
489
+ const isThinned = ({ list, k }) => list.thin !== undefined && k > 0;
490
+ const cutOnes = kept.filter((x) => x.k < x.list.n || isThinned(x));
491
+ if (cutOnes.length === 0)
492
+ return { said: "" };
493
+ const truncated = Object.fromEntries(cutOnes.map((x) => [x.list.path, { total: x.list.n, returned: x.k, ...(isThinned(x) ? { thinned: true } : {}) }]));
494
+ const parts = [];
495
+ const byKept = new Map();
496
+ for (const x of cutOnes) {
497
+ const { list, k } = x;
498
+ const out = list.n - k;
499
+ const first = Math.min(out, list.named);
500
+ const end = list.newest ? "oldest" : "last";
501
+ const which = list.firstOut ? `whose ${list.firstOut.field} is ${orListed(list.firstOut.values)} (${list.firstOut.why})` : "";
502
+ const leftOut = out === 0 ? "" : first === 0 ? `, the ${end} ${out} left out` : first === out ? `, the ${out} left out being entries ${which}` : `, the ${out} left out being the ${first} ${which} and the ${end} ${out - first} of the rest`;
503
+ if (isThinned(x)) {
504
+ parts.push(`${list.path} keeps ${k === list.n ? `all ${k}` : `${k} of its ${list.n}`} entries, each cut to ${keepSaid(list.thin)}${list.thinSaid ? `, ${list.thinSaid}` : ""}${leftOut}${givenBackSaid(list, k)}`);
505
+ continue;
506
+ }
507
+ if (!list.firstOut || first === 0 || k === 0) {
508
+ const newest = list.newest === true && k > 0;
509
+ const key = `${k}:${newest}`;
510
+ const group = byKept.get(key);
511
+ if (group)
512
+ group.lists.push(list);
513
+ else {
514
+ const g = { k, newest, lists: [list] };
515
+ byKept.set(key, g);
516
+ parts.push(g);
517
+ }
518
+ continue;
519
+ }
520
+ parts.push(`${list.path} keeps ${k} of its ${list.n} entries${leftOut}`);
521
+ }
522
+ const cuts = parts.map((p) => {
523
+ if (typeof p === "string")
524
+ return p;
525
+ const paths = listed(p.lists.map((l) => l.path));
526
+ const which = p.newest ? "newest" : "first";
527
+ if (p.lists.length === 1)
528
+ return p.k === 0 ? `${paths} keeps none of its ${p.lists[0].n} entries` : `${paths} keeps its ${which} ${p.k} of ${p.lists[0].n} entries`;
529
+ return p.k === 0 ? `${paths} keep none of their entries` : `${paths} keep their ${which} ${p.k} entries each`;
530
+ });
531
+ const keeps = Object.fromEntries(cutOnes.filter(isThinned).map((x) => [x.list.path, x.list.thin]));
532
+ const page = cut.page?.(truncated, data, { keeps, wholeFit }) ?? "";
533
+ const said = [
534
+ `DATA CUT: with data whole, this structured result is ${wholeChars} characters of JSON, more than the ${STRUCTURED_BUDGET} it holds unless the call says full: true, so its data is cut: ${cuts.join("; ")}.`,
535
+ "truncated gives the total and the number returned of each list cut, and thinned where its entries are cut to some of their fields.",
536
+ page,
537
+ over ? `Cut so, the structured result is still past ${STRUCTURED_BUDGET} characters: data holds no other list to cut.` : "",
538
+ "The same call with full: true returns data whole.",
539
+ ];
540
+ return { truncated, said: said.filter(Boolean).join(" ") };
541
+ }
542
+ /**
543
+ * The structured result for `data` cut to STRUCTURED_BUDGET, as `cut` says (see "The structured
544
+ * result, per client" above): `structured` builds the result for a data, its truncated and the DATA
545
+ * CUT sentences, and is called with data null to size the rest of it. `wholeChars` is the result's
546
+ * size with data whole. undefined when data holds no list to cut.
547
+ */
548
+ export function cutData(data, cut, wholeChars, structured) {
549
+ if (!isPlainObject(data))
550
+ return undefined;
551
+ const named = new Set(cut.steps.flatMap((s) => s.lists));
552
+ const steps = [...cut.steps, { lists: Object.keys(data).filter((k) => !named.has(k)) }];
553
+ // Each list cut so far, in the order cut, at the level it was thinned to, how many entries it
554
+ // keeps, and the step that cut it.
555
+ const kept = new Map();
556
+ const apply = (also = []) => [...kept.values(), ...also].reduce((d, { list, k }) => withList(d, list.at, list.keep(k)), data);
557
+ // PageAt.wholeFit: of the list at `path`, the most first entries, each whole, that the result
558
+ // holds within the budget with the rest of data whole and no DATA CUT sentence (structured with
559
+ // truncated undefined): the shape of the same call at that page size. Counted once per path.
560
+ const wholeFits = new Map();
561
+ const wholeFit = (path) => {
562
+ const known = wholeFits.get(path);
563
+ if (known !== undefined)
564
+ return known;
565
+ const at = path.split(".");
566
+ const v = listOf(data, at);
567
+ let k = 0;
568
+ if (Array.isArray(v)) {
569
+ let chars = JSON.stringify(structured(withList(data, at, []), undefined, "")).length;
570
+ for (const e of v) {
571
+ chars += entryChars(e) + (k > 0 ? 1 : 0);
572
+ if (chars > STRUCTURED_BUDGET)
573
+ break;
574
+ k++;
575
+ }
576
+ }
577
+ wholeFits.set(path, k);
578
+ return k;
579
+ };
580
+ // The result's size with this step's lists at k entries each: the rest of the result (data
581
+ // null), data with this step's lists empty (`base`), and their entries.
582
+ const size = (lists, k, base) => {
583
+ const all = [...kept.values(), ...lists.map((list) => ({ list, k: Math.min(k, list.n) }))];
584
+ const { truncated, said } = cutSaid(cut, all, wholeChars, data, false, wholeFit);
585
+ return JSON.stringify(structured(null, truncated, said)).length - "null".length + base + lists.reduce((t, l) => t + l.chars(k), 0);
586
+ };
587
+ let fits = false;
588
+ for (const [s, step] of steps.entries()) {
589
+ // Every entry whole, then each thinning level in turn, every entry kept; at the leanest level
590
+ // the entries leave until the result fits (with keepAll, #3139, not in this step: see below).
591
+ const levels = [undefined, ...(step.thin ?? [])];
592
+ let lists = [];
593
+ let k = 0;
594
+ const at = (level, cap, levelAt) => step.lists.map((p) => listAt(data, p, step.leaveOutFirst, level, step.keepNewest, cap, levelAt)).filter((l) => l !== undefined && !kept.has(l.path));
595
+ for (const [i, thin] of levels.entries()) {
596
+ lists = at(thin, undefined, i - 1);
597
+ if (lists.length === 0)
598
+ break;
599
+ const base = JSON.stringify(apply(lists.map((list) => ({ list, k: 0 })))).length;
600
+ const top = Math.max(...lists.map((l) => l.n));
601
+ // #3132: a level that caps a list inside each entry, every entry kept: the most entries of
602
+ // that inner list, the same number in each, that fit; a level where even none fits is passed.
603
+ // #3132's review: a level with `least` is taken at that cap or more, or passed.
604
+ const capped = thin === undefined ? undefined : levelOf(thin).capped;
605
+ if (capped !== undefined) {
606
+ const longest = Math.max(0, ...step.lists.map((p) => longestInside(listOf(data, p.split(".")), capped)));
607
+ const least = (thin === undefined ? undefined : levelOf(thin).least) ?? 0;
608
+ if (longest > least && size(at(thin, least, i - 1), top, base) <= STRUCTURED_BUDGET) {
609
+ let m = least;
610
+ let hi = longest - 1;
611
+ while (m < hi) {
612
+ const mid = Math.ceil((m + hi) / 2);
613
+ if (size(at(thin, mid, i - 1), top, base) <= STRUCTURED_BUDGET)
614
+ m = mid;
615
+ else
616
+ hi = mid - 1;
617
+ }
618
+ lists = at(thin, m, i - 1);
619
+ k = top;
620
+ fits = true;
621
+ break;
622
+ }
623
+ if (i < levels.length - 1)
624
+ continue;
625
+ // The last level, and even its least cap does not fit: the entries go on at that cap.
626
+ lists = at(thin, least, i - 1);
627
+ }
628
+ if (i < levels.length - 1) {
629
+ if (size(lists, top, base) <= STRUCTURED_BUDGET) {
630
+ k = top;
631
+ fits = true;
632
+ break;
633
+ }
634
+ continue;
635
+ }
636
+ if (step.keepAll) {
637
+ // #3139: every entry stays, at this level; the later steps are cut first.
638
+ k = top;
639
+ fits = size(lists, top, base) <= STRUCTURED_BUDGET;
640
+ break;
641
+ }
642
+ // #3132's review: entries leave at the step's leaveAt level, where it names one.
643
+ if (step.leaveAt && step.thin) {
644
+ const { level, cap } = step.leaveAt;
645
+ lists = at(step.thin[level], cap, level);
646
+ }
647
+ if (step.whole) {
648
+ // Every entry or none (#3132's review: data.not_sent_purls).
649
+ if (size(lists, 0, base) <= STRUCTURED_BUDGET) {
650
+ k = size(lists, top, base) <= STRUCTURED_BUDGET ? top : 0;
651
+ fits = true;
652
+ }
653
+ break;
654
+ }
655
+ if (size(lists, 0, base) <= STRUCTURED_BUDGET) {
656
+ // The most entries each list of the step keeps with the result inside the budget.
657
+ let hi = top;
658
+ while (k < hi) {
659
+ const mid = Math.ceil((k + hi) / 2);
660
+ if (size(lists, mid, base) <= STRUCTURED_BUDGET)
661
+ k = mid;
662
+ else
663
+ hi = mid - 1;
664
+ }
665
+ fits = true;
666
+ }
667
+ }
668
+ for (const list of lists)
669
+ kept.set(list.path, { list, k: Math.min(k, list.n), step: s });
670
+ if (fits)
671
+ break;
672
+ }
673
+ const exact = () => {
674
+ const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, false, wholeFit);
675
+ return JSON.stringify(structured(apply(), truncated, said)).length;
676
+ };
677
+ // #3139: every step cut and the result still past the budget: a keepAll step's entries leave now,
678
+ // the step's first, as many as must, the rest of data as the steps after it left it. The steps
679
+ // after it then take back what room is left (below), as though it were the step that fits.
680
+ // #3139's review: with more than one keepAll step, the last one's entries leave first
681
+ // (cve_remediation's fixed_branches before any vendor advisory).
682
+ if (!fits) {
683
+ for (const [s, step] of [...steps.entries()].reverse()) {
684
+ if (!step.keepAll || fits)
685
+ continue;
686
+ const entries = [...kept.values()].filter((e) => e.step === s);
687
+ if (entries.length === 0)
688
+ continue;
689
+ const set = (k) => {
690
+ for (const e of entries)
691
+ e.k = Math.min(k, e.list.n);
692
+ };
693
+ set(0);
694
+ if (exact() > STRUCTURED_BUDGET)
695
+ continue;
696
+ let k = 0;
697
+ let hi = Math.max(...entries.map((e) => e.list.n));
698
+ while (k < hi) {
699
+ const mid = Math.ceil((k + hi) / 2);
700
+ set(mid);
701
+ if (exact() <= STRUCTURED_BUDGET)
702
+ k = mid;
703
+ else
704
+ hi = mid - 1;
705
+ }
706
+ set(k);
707
+ for (const e of entries)
708
+ e.step = steps.length;
709
+ fits = true;
710
+ }
711
+ }
712
+ // The room the step that fits leaves goes back to the steps emptied before it, the last first,
713
+ // each step's lists keeping at most the same number of their entries, as many as fit, at the
714
+ // level they were thinned to (scan_manifest's not_checked, emptied while its rows were whole,
715
+ // keeps its entries once the thinned rows fit).
716
+ if (fits) {
717
+ const last = Math.max(...[...kept.values()].map((e) => e.step));
718
+ for (let s = last - 1; s >= 0; s--) {
719
+ const entries = [...kept.values()].filter((e) => e.step === s);
720
+ if (entries.length === 0)
721
+ continue;
722
+ const at = (k) => {
723
+ for (const e of entries)
724
+ e.k = Math.min(k, e.list.n);
725
+ };
726
+ let k = Math.min(...entries.map((e) => e.k));
727
+ let hi = Math.max(...entries.map((e) => e.list.n));
728
+ if (steps[s].whole) {
729
+ // Every entry or none (#3132's review).
730
+ at(hi);
731
+ if (exact() > STRUCTURED_BUDGET)
732
+ at(k);
733
+ continue;
734
+ }
735
+ while (k < hi) {
736
+ const mid = Math.ceil((k + hi) / 2);
737
+ at(mid);
738
+ if (exact() <= STRUCTURED_BUDGET)
739
+ k = mid;
740
+ else
741
+ hi = mid - 1;
742
+ }
743
+ at(k);
744
+ }
745
+ }
746
+ // #3132's review: then a giveBack step's entries, in their order in data, take back detail (see
747
+ // DataCutStep): each the richest level above its own that fits. A RowKeep level is tried whole;
748
+ // of the capped levels, only the leanest at or above the entry's own (check_sbom's compact one,
749
+ // or the entry's own), uncapped and then at the most inner entries that fit, so a row takes back
750
+ // more of its matches compact before fewer of them thinned. #3132's second review: an entry that
751
+ // takes back nothing is passed, not an end (through round 2 it ended give-back, so a row at the
752
+ // compact level holding every match, too large to move up, kept every later capped row at its
753
+ // cap while room was left); the entries that took some are named by position (givenBackSaid).
754
+ if (fits) {
755
+ for (const [s, step] of steps.entries()) {
756
+ if (!step.giveBack || !step.thin)
757
+ continue;
758
+ const thin = step.thin;
759
+ for (const e of [...kept.values()].filter((x) => x.step === s)) {
760
+ const { list } = e;
761
+ const own = list.level;
762
+ if (own === undefined || list.thin === undefined)
763
+ continue;
764
+ const whole = listOf(data, list.at);
765
+ if (!Array.isArray(whole))
766
+ continue;
767
+ let leanestCapped = -1;
768
+ for (let j = 0; j <= own; j++)
769
+ if (levelOf(thin[j]).capped !== undefined)
770
+ leanestCapped = j;
771
+ const tryAt = (i, j, c) => {
772
+ const l = levelOf(thin[j]);
773
+ list.over.set(i, { entry: thinned(whole[i], thin[j], c), level: j, cap: c, keep: l.keep, ...(l.said && l.capped && c < Number.POSITIVE_INFINITY ? { said: l.said(c) } : {}) });
774
+ if (exact() <= STRUCTURED_BUDGET)
775
+ return true;
776
+ list.over.delete(i);
777
+ return false;
778
+ };
779
+ for (const i of list.kept(e.k)) {
780
+ // An entry its own level already keeps as the richest would is passed, not an end.
781
+ if (JSON.stringify(thinned(whole[i], thin[0])) === JSON.stringify(thinned(whole[i], thin[own], list.cap)))
782
+ continue;
783
+ let took = false;
784
+ for (let j = 0; j <= own && !took; j++) {
785
+ const l = levelOf(thin[j]);
786
+ const inner = l.capped !== undefined && Array.isArray(field(whole[i], l.capped)) ? field(whole[i], l.capped).length : 0;
787
+ if (l.capped === undefined) {
788
+ if (j < own)
789
+ took = tryAt(i, j, Number.POSITIVE_INFINITY);
790
+ continue;
791
+ }
792
+ if (j !== leanestCapped)
793
+ continue;
794
+ if (j < own || list.cap < inner)
795
+ took = tryAt(i, j, Number.POSITIVE_INFINITY);
796
+ if (took)
797
+ break;
798
+ let c = j === own ? list.cap + 1 : (l.least ?? 0);
799
+ let hi = inner - 1;
800
+ if (c > hi || !tryAt(i, j, c))
801
+ continue;
802
+ while (c < hi) {
803
+ const mid = Math.ceil((c + hi) / 2);
804
+ if (tryAt(i, j, mid))
805
+ c = mid;
806
+ else
807
+ hi = mid - 1;
808
+ }
809
+ took = tryAt(i, j, c);
810
+ }
811
+ }
812
+ }
813
+ }
814
+ }
815
+ const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, !fits, wholeFit);
816
+ if (truncated === undefined)
817
+ return undefined;
818
+ const d = apply();
819
+ return { data: d, truncated, said, structured: structured(d, truncated, said) };
254
820
  }