@cyanheads/pubmed-mcp-server 2.10.6 → 2.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/AGENTS.md +9 -5
  2. package/CLAUDE.md +9 -5
  3. package/README.md +5 -2
  4. package/dist/mcp-server/tools/definitions/_budget.d.ts +42 -0
  5. package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -0
  6. package/dist/mcp-server/tools/definitions/_budget.js +49 -0
  7. package/dist/mcp-server/tools/definitions/_budget.js.map +1 -0
  8. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +9 -0
  9. package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +89 -9
  11. package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +17 -0
  13. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +336 -53
  15. package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/format-citations.tool.js +11 -1
  18. package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +7 -11
  21. package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
  22. package/dist/services/ncbi/ncbi-service.d.ts +5 -0
  23. package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
  24. package/dist/services/ncbi/ncbi-service.js +33 -10
  25. package/dist/services/ncbi/ncbi-service.js.map +1 -1
  26. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +3 -3
  27. package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
  28. package/dist/services/ncbi/parsing/pmc-article-parser.js +73 -8
  29. package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
  30. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +8 -0
  31. package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
  32. package/dist/services/ncbi/parsing/pmc-xml-helpers.js +10 -0
  33. package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
  34. package/dist/services/ncbi/types.d.ts +6 -2
  35. package/dist/services/ncbi/types.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/server.json +3 -3
@@ -28,6 +28,7 @@ import { parsePmcArticle } from '../../../services/ncbi/parsing/pmc-article-pars
28
28
  import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers.js';
29
29
  import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
30
30
  import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
31
+ import { fitWholeItems } from './_budget.js';
31
32
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
32
33
  import { pmidStringSchema } from './_schemas.js';
33
34
  import { escapeMarkdownInline, sliceCodeUnits } from './_text.js';
@@ -41,6 +42,43 @@ function filterSections(sections, sectionFilter) {
41
42
  const lowerFilter = sectionFilter.map((s) => s.toLowerCase());
42
43
  return sections.filter((s) => s.title && lowerFilter.some((f) => s.title?.toLowerCase().includes(f)));
43
44
  }
45
+ /**
46
+ * Render a section subtree as text blocks, in document order: each section's
47
+ * heading on its own line above its text. Used for the levels past
48
+ * {@link MAX_SECTION_DEPTH}, which have no node of their own to live in. (#112)
49
+ */
50
+ function flattenSectionText(section) {
51
+ const heading = section.title ? formatHeading(section.label, section.title) : undefined;
52
+ const block = [heading, section.text].filter(Boolean).join('\n');
53
+ return [...(block ? [block] : []), ...(section.subsections ?? []).flatMap(flattenSectionText)];
54
+ }
55
+ /**
56
+ * Clamp a section tree to the depth the output schema declares. A section at the
57
+ * deepest level absorbs its descendants into its own text instead of carrying
58
+ * them as subsections the schema would strip on validation — silently, from both
59
+ * `structuredContent` and `content[]`. Shallower trees pass through untouched.
60
+ * (#112)
61
+ */
62
+ function clampSectionDepth(sections, depth = 1) {
63
+ return sections.map((section) => {
64
+ const subsections = section.subsections;
65
+ if (!subsections?.length)
66
+ return section;
67
+ if (depth < MAX_SECTION_DEPTH) {
68
+ return { ...section, subsections: clampSectionDepth(subsections, depth + 1) };
69
+ }
70
+ const { subsections: _dropped, ...rest } = section;
71
+ const tail = subsections.flatMap(flattenSectionText);
72
+ return { ...rest, text: [section.text, ...tail].filter(Boolean).join('\n\n') };
73
+ });
74
+ }
75
+ /**
76
+ * Apply the requested section/reference filters, then clamp the section tree to
77
+ * the depth the output schema carries. Both run here so every path producing a
78
+ * `pmc` article — PMC EFetch and the Europe PMC stage — shares one shape, and
79
+ * the budget helpers downstream count the text that will actually survive
80
+ * validation. (#112)
81
+ */
44
82
  function applyPmcFilters(article, filters) {
45
83
  let out = article;
46
84
  if (filters.sections?.length) {
@@ -53,7 +91,7 @@ function applyPmcFilters(article, filters) {
53
91
  const { references: _, ...rest } = out;
54
92
  out = rest;
55
93
  }
56
- return out;
94
+ return { ...out, sections: clampSectionDepth(out.sections) };
57
95
  }
58
96
  /**
59
97
  * True when a `sections` filter removed every body section from an article that
@@ -109,11 +147,30 @@ function buildBodylessNotice(affectedIds) {
109
147
  return `No body text could be retrieved for ${subject} — the full-text source returned front matter and abstract only, and no later tier recovered a copy. See \`triedTiers\` on the \`unavailable\` entry for what each tier reported, and use \`pubmed_fetch_articles\` for the abstract and metadata.`;
110
148
  }
111
149
  // ─── Schemas ─────────────────────────────────────────────────────────────────
150
+ /**
151
+ * How many `<sec>` levels the output schema carries as structured nodes. JATS
152
+ * nesting is unbounded and the parser recurses without a cap, so the schema is
153
+ * what decides how deep a section survives output validation — anything past it
154
+ * used to be dropped silently from both output surfaces (#112). Sections deeper
155
+ * than this are now flattened into the deepest surviving node's text instead, so
156
+ * no body content is lost at any depth.
157
+ *
158
+ * Two is the ceiling the tool's own contract can verify, not a guess at how deep
159
+ * real records nest: `format-parity`'s sentinel walker stops after 8 schema hops,
160
+ * and `articles[]` → the article union → `sections[]` → `subsections[]` already
161
+ * spends them all. A third `subsections` level puts its own elements out of the
162
+ * walker's reach, so `format()` parity for that subtree would ship unverified.
163
+ * Levels are inlined rather than expressed with `z.lazy()` regardless — a
164
+ * self-referential schema emits `$defs`/`$ref`, which Gemini rejects.
165
+ */
166
+ const MAX_SECTION_DEPTH = 2;
112
167
  const SubsectionSchema = z
113
168
  .object({
114
169
  title: z.string().optional().describe('Subsection heading'),
115
170
  label: z.string().optional().describe('Subsection label'),
116
- text: z.string().describe('Subsection body text'),
171
+ text: z
172
+ .string()
173
+ .describe('Subsection body text. Sections nested deeper than this level are folded in here in document order, each heading rendered on its own line above its text.'),
117
174
  })
118
175
  .describe('Article subsection');
119
176
  const SectionSchema = z
@@ -251,7 +308,10 @@ const UnavailableReasonSchema = z
251
308
  'parse-failed',
252
309
  'service-error',
253
310
  ])
254
- .describe('Why no full text was returned. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data).');
311
+ .describe('Why no full text was returned — the most specific signal any tier that answered reported. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data). A reason never means the chain ran to completion — read `unqueriedTiers` for that.');
312
+ const UnqueriedTierSchema = z
313
+ .enum(['europepmc', 'unpaywall'])
314
+ .describe('A fallback tier this deployment has not configured');
255
315
  const TierOutcomeSchema = z
256
316
  .enum([
257
317
  'not-attempted',
@@ -282,6 +342,10 @@ const UnavailableSchema = z
282
342
  triedTiers: z
283
343
  .array(TriedTierSchema)
284
344
  .describe('Per-tier outcomes the chain produced for this id, in execution order. Covers `pmc`, `europepmc`, and `unpaywall` — the same tiers the tool description references. Tiers that the chain skipped appear as `outcome: not-attempted` with a `detail` explaining why.'),
345
+ unqueriedTiers: z
346
+ .array(UnqueriedTierSchema)
347
+ .optional()
348
+ .describe('Tiers the chain skipped because this deployment has not configured them, and that could have served this id — the search was incomplete, and a deployment with these tiers configured may still resolve the id. `triedTiers` carries which environment variable each one is waiting on. Absent when every tier that could have served the id was actually queried; a tier skipped because it was inapplicable to this id (no DOI for Unpaywall) is never listed.'),
285
349
  })
286
350
  .describe('One identifier that could not be returned, with the full chain it traversed');
287
351
  // ─── Character-budget schemas ────────────────────────────────────────────────
@@ -341,14 +405,55 @@ const TruncationSchema = z
341
405
  .describe('Per-article accounting, covering only the articles the budget shortened'),
342
406
  })
343
407
  .describe('Character accounting for full text the budget shortened. Present only when a budget actually removed characters — its absence means every returned article carries its full post-filter body.');
408
+ const DeferredSchema = z
409
+ .object({
410
+ maxResponseCharacters: z
411
+ .number()
412
+ .describe('The `maxResponseCharacters` ceiling this response was budgeted against'),
413
+ returnedCharacters: z
414
+ .number()
415
+ .describe('Serialized characters the returned article records account for'),
416
+ deferredCount: z
417
+ .number()
418
+ .describe('Articles the chain resolved but withheld to stay under the ceiling'),
419
+ idType: z
420
+ .enum(['pmid', 'pmcid', 'doi'])
421
+ .describe('Which input branch the deferred ids belong to — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Matches the `idType` on `unavailable` entries.'),
422
+ ids: z
423
+ .array(z.string())
424
+ .describe('Identifiers of the deferred articles, in response order, keyed as they were requested (PMC IDs in `PMC<digits>` form). Re-call `pubmed_fetch_fulltext` with these under the `idType` branch and the same other inputs. Never contains an id from `unavailable`.'),
425
+ nextDeferredCharacters: z
426
+ .number()
427
+ .describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped. Raise `maxResponseCharacters` to at least this to make progress; a smaller article further down `ids` cannot be reached until this one fits.'),
428
+ })
429
+ .describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
344
430
  /** True when the request asked for any budget at all. Without one, every budget
345
431
  * helper returns its input untouched so the response is byte-identical. */
346
432
  function budgetRequested(budget) {
347
433
  return budget.maxCharacters !== undefined || budget.maxCharactersPerSection !== undefined;
348
434
  }
349
- /** Body characters a top-level section carries its own text plus its subsections'. */
435
+ /** Every text field in a section subtree, in document order, own text first. */
436
+ function sectionTextFields(section) {
437
+ return [section.text, ...(section.subsections ?? []).flatMap(sectionTextFields)];
438
+ }
439
+ /**
440
+ * Body characters a section carries — its own text plus every nested
441
+ * subsection's. Measured off {@link sectionTextFields} rather than its own walk,
442
+ * so the count the budget reports as `originalCharacters` is always taken over
443
+ * exactly the fields {@link fitFields} shortens.
444
+ */
350
445
  function sectionCharacters(section) {
351
- return (section.text.length + (section.subsections?.reduce((n, sub) => n + sub.text.length, 0) ?? 0));
446
+ return sectionTextFields(section).reduce((n, text) => n + text.length, 0);
447
+ }
448
+ /**
449
+ * Rebuild a section subtree from `fitted`, consuming one entry per node in the
450
+ * same document order {@link sectionTextFields} produced them. `cursor` walks
451
+ * the flat list across the whole subtree.
452
+ */
453
+ function withFittedTexts(section, fitted, cursor) {
454
+ const text = fitted[cursor.i++] ?? '';
455
+ const subsections = section.subsections?.map((sub) => withFittedTexts(sub, fitted, cursor));
456
+ return { ...section, text, ...(subsections && { subsections }) };
352
457
  }
353
458
  /**
354
459
  * Shorten an ordered list of text fields so their combined length fits
@@ -448,7 +553,7 @@ function applyPmcBudget(article, budget) {
448
553
  let returnedCharacters = 0;
449
554
  article.sections.forEach((section, i) => {
450
555
  const original = sizes[i] ?? 0;
451
- const fitted = fitFields([section.text, ...(section.subsections?.map((sub) => sub.text) ?? [])], allowances[i] ?? 0);
556
+ const fitted = fitFields(sectionTextFields(section), allowances[i] ?? 0);
452
557
  const returned = fitted.reduce((sum, text) => sum + text.length, 0);
453
558
  returnedCharacters += returned;
454
559
  sectionReports.push({
@@ -461,13 +566,7 @@ function applyPmcBudget(article, budget) {
461
566
  omittedSections += 1;
462
567
  return;
463
568
  }
464
- kept.push({
465
- ...section,
466
- text: fitted[0] ?? '',
467
- ...(section.subsections && {
468
- subsections: section.subsections.map((sub, j) => ({ ...sub, text: fitted[j + 1] ?? '' })),
469
- }),
470
- });
569
+ kept.push(withFittedTexts(section, fitted, { i: 0 }));
471
570
  });
472
571
  if (returnedCharacters === originalCharacters && omittedSections === 0) {
473
572
  return { article, omittedSections: 0 };
@@ -512,6 +611,29 @@ function buildTruncationNotice(truncation) {
512
611
  ].filter((k) => k !== undefined);
513
612
  return `Full text was shortened to fit the requested character budget: ${truncation.returnedCharacters} of ${truncation.originalCharacters} body characters returned across ${subject} in ${truncation.mode} mode.${omitted} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
514
613
  }
614
+ /**
615
+ * Compose the recovery notice for articles the whole-response budget withheld.
616
+ * Names what was spent, which identifiers are still retrievable, and the ceiling
617
+ * the next call has to clear — so a caller reading only `content[]` can resume
618
+ * without inspecting `deferred`. (#100)
619
+ */
620
+ function buildDeferralNotice(deferred) {
621
+ const spent = deferred.returnedCharacters === 0
622
+ ? `No article fits the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
623
+ : `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
624
+ return `${spent} ${deferred.deferredCount} resolved article(s) were deferred whole: ${deferred.ids.join(', ')}. Re-call pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\` to retrieve them, or raise maxResponseCharacters to at least ${deferred.nextDeferredCharacters} — the size of the next deferred article.`;
625
+ }
626
+ /**
627
+ * Body sections an article's per-article budget dropped, derived from that
628
+ * article's own accounting by the rule {@link applyPmcBudget} counts by:
629
+ * `outline` mode keeps every heading, so it drops none. Used to take a deferred
630
+ * article's contribution back out of the response-level roll-up. (#100)
631
+ */
632
+ function countOmittedSections(entry, mode) {
633
+ if (mode !== 'truncate')
634
+ return 0;
635
+ return (entry.sections ?? []).filter((s) => s.originalCharacters > 0 && s.returnedCharacters === 0).length;
636
+ }
515
637
  // ─── Tool Definition ─────────────────────────────────────────────────────────
516
638
  /**
517
639
  * Compose the tool description for the fallback tiers enabled in this
@@ -546,7 +668,8 @@ export function buildFulltextDescription(tiers) {
546
668
  ? '; DOIs with no PMC copy recover via Unpaywall open access'
547
669
  : '';
548
670
  const input = `Provide exactly one of \`pmcids\` (PMC IDs directly), \`pmids\` (PubMed IDs, auto-resolved), or \`dois\` (DOIs, auto-resolved to PMC via the ID Converter${doiTail}).`;
549
- return `${base} ${fallback} ${input}`;
671
+ const budget = 'Two independent character controls: `maxCharacters` caps body text per article, `maxResponseCharacters` caps the whole response and defers articles past the ceiling whole, listing them in `deferred.ids` for a follow-up call.';
672
+ return `${base} ${fallback} ${input} ${budget}`;
550
673
  }
551
674
  const serverConfig = getServerConfig();
552
675
  export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
@@ -605,7 +728,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
605
728
  .min(1)
606
729
  .max(1_000_000)
607
730
  .optional()
608
- .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `includeReferences`, so semantic filtering is unaffected. The response-wide ceiling is this value times the number of articles returned. Omit for the full body.'),
731
+ .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `includeReferences`, so semantic filtering is unaffected. This knob alone bounds only bodies: the response-wide ceiling it implies is this value times the number of articles returned, plus every uncounted field. Use `maxResponseCharacters` for a true whole-response ceiling. Omit for the full body.'),
609
732
  maxCharactersPerSection: z
610
733
  .number()
611
734
  .int()
@@ -613,6 +736,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
613
736
  .max(1_000_000)
614
737
  .optional()
615
738
  .describe('Budget for a single top-level body section, in characters, counting the section text plus its subsections. Combine with `maxCharacters` to cap both one section and the article; the tighter of the two wins. Applies to `source=pmc` results only.'),
739
+ maxResponseCharacters: z
740
+ .number()
741
+ .int()
742
+ .min(1)
743
+ .max(1_000_000)
744
+ .optional()
745
+ .describe('Opt-in ceiling for the whole response, in characters — the true response-wide counterpart to the per-article `maxCharacters`. Each article is measured as the JSON record it is returned as, after every filter and the per-article body budget: title, abstract, body sections, references, identifiers, license and source metadata — every field it carries. One ledger covers all tiers, so PMC-, Europe PMC-, and Unpaywall-served articles spend the same budget. Articles are kept in response order until the next one would cross the ceiling; that article and the rest are deferred whole (never partially populated) and listed in `deferred.ids`. Response envelope fields — counts, `unavailable`, `truncation`, `deferred` itself — are not counted. Omit to return every resolved article.'),
616
746
  overflowMode: z
617
747
  .enum(['truncate', 'outline'])
618
748
  .default('truncate')
@@ -623,27 +753,31 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
623
753
  }),
624
754
  output: z.object({
625
755
  articles: z.array(ArticleSchema).describe('Full-text articles'),
626
- totalReturned: z.number().describe('Number of articles returned'),
756
+ totalReturned: z
757
+ .number()
758
+ .describe('Number of articles in this response. Under a `maxResponseCharacters` budget this counts the kept articles only; `deferred.deferredCount` covers the rest.'),
627
759
  unavailable: z
628
760
  .array(UnavailableSchema)
629
761
  .optional()
630
- .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
762
+ .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from. Distinct from `deferred`: nothing here is retrievable by re-calling, and an id never appears in both.'),
631
763
  truncation: TruncationSchema.optional(),
764
+ deferred: DeferredSchema.optional(),
632
765
  }),
633
- // Recovery guidance for three cases — a `sections` filter that removed every
766
+ // Recovery guidance for four cases — a `sections` filter that removed every
634
767
  // body section (#80), a record the chain could only retrieve as front matter
635
- // (#86), and a body the character budget shortened (#81). Agent-facing context
636
- // surfaced via ctx.enrich.notice() to structuredContent and content[]; absent
637
- // when none applies.
768
+ // (#86), a body the per-article character budget shortened (#81), and articles
769
+ // the whole-response budget withheld (#100). Agent-facing context surfaced via
770
+ // ctx.enrich.notice() to structuredContent and content[]; absent when none
771
+ // applies.
638
772
  enrichment: {
639
773
  notice: z
640
774
  .string()
641
775
  .optional()
642
- .describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A budgeted response names the characters returned versus carried and points at `truncation`. Absent when none of those applies.'),
776
+ .describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A budgeted response names the characters returned versus carried and points at `truncation`. A response-wide budget that deferred articles names the ids to re-request. Absent when none of those applies.'),
643
777
  truncated: z
644
778
  .boolean()
645
779
  .optional()
646
- .describe('True when a character budget shortened at least one returned body. Absent when every returned article carries its full post-filter body. The per-article accounting is in `truncation`.'),
780
+ .describe('True when a character budget shortened at least one returned body, or withheld a whole article. Absent when every resolved article is present with its full post-filter body. The per-article body accounting is in `truncation`; the withheld ids are in `deferred`.'),
647
781
  },
648
782
  async handler(input, ctx) {
649
783
  ctx.log.info('Executing pubmed_fetch_fulltext', {
@@ -659,6 +793,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
659
793
  // for, so we can skip them when building `unavailable[]`.
660
794
  const chainByInput = new Map();
661
795
  const recoveredIds = new Set();
796
+ // Per-input-id set of tiers this deployment has not configured AND that
797
+ // could have served that id — the `unqueriedTiers` array on unavailable
798
+ // entries. Insertion order is chain order, so the array reads in the order
799
+ // the tiers would have run. A tier skipped as inapplicable is never marked;
800
+ // that is a settled answer, not an incomplete search. (#110)
801
+ const unqueriedByInput = new Map();
802
+ const markUnqueried = (inputId, tier) => {
803
+ const tiers = unqueriedByInput.get(inputId) ?? new Set();
804
+ tiers.add(tier);
805
+ unqueriedByInput.set(inputId, tiers);
806
+ };
662
807
  // Back-map from a converter-resolved prefixed PMCID to the input id that
663
808
  // seeded it — a PMID for `pmids` input, a DOI for `dois` input — so the PMC
664
809
  // and EPMC stages attribute recoveries and misses to the original input id.
@@ -679,6 +824,11 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
679
824
  // requested or nothing exceeded it (#81).
680
825
  const truncatedArticles = [];
681
826
  let omittedSections = 0;
827
+ // The input id each returned article was requested under, so the
828
+ // whole-response budget can hand deferred articles back as identifiers the
829
+ // caller can re-submit rather than whatever id the article happens to
830
+ // carry — a `pmids` request recovers articles keyed by PMCID. (#100)
831
+ const inputIdByArticle = new Map();
682
832
  const budget = {
683
833
  overflowMode: input.overflowMode,
684
834
  ...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
@@ -843,7 +993,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
843
993
  ...budgeted.truncation,
844
994
  });
845
995
  }
846
- parsed.push({ source: 'pmc', viaSource: 'pmc', ...budgeted.article });
996
+ const article = {
997
+ source: 'pmc',
998
+ viaSource: 'pmc',
999
+ ...budgeted.article,
1000
+ };
1001
+ parsed.push(article);
1002
+ if (article.pmcId) {
1003
+ inputIdByArticle.set(article, pmcidToInputId.get(article.pmcId) ?? article.pmcId);
1004
+ }
847
1005
  }
848
1006
  pmcArticles = parsed;
849
1007
  const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
@@ -905,10 +1063,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
905
1063
  sectionFilterMisses: [],
906
1064
  truncatedArticles: [],
907
1065
  omittedSections: 0,
1066
+ articleInputIds: new Map(),
908
1067
  };
909
1068
  pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
910
1069
  truncatedArticles.push(...epmcOutcomes.truncatedArticles);
911
1070
  omittedSections += epmcOutcomes.omittedSections;
1071
+ for (const [article, candidateId] of epmcOutcomes.articleInputIds) {
1072
+ inputIdByArticle.set(article, pmcidToInputId.get(candidateId) ?? candidateId);
1073
+ }
912
1074
  // Fold EPMC outcomes into each id's chain. EPMC-served articles count as
913
1075
  // recovered, so their ids are added to `recoveredIds` here.
914
1076
  if (!epmc) {
@@ -917,14 +1079,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
917
1079
  outcome: 'not-attempted',
918
1080
  detail: 'EUROPEPMC_ENABLED=false',
919
1081
  };
1082
+ // EPMC searches by PMID, PMCID, and DOI alike, so it could have served
1083
+ // every candidate that reached this stage — no applicability test.
1084
+ const skipEpmc = (inputId) => {
1085
+ chainByInput.get(inputId)?.push(epmcDisabledEntry);
1086
+ markUnqueried(inputId, 'europepmc');
1087
+ };
920
1088
  for (const c of pmidFallbackCandidates)
921
- chainByInput.get(c.pmid)?.push(epmcDisabledEntry);
1089
+ skipEpmc(c.pmid);
922
1090
  for (const c of pmcidFallbackCandidates) {
923
1091
  const prefixed = withPmcPrefix(c.pmcid);
924
- chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push(epmcDisabledEntry);
1092
+ skipEpmc(pmcidToInputId.get(prefixed) ?? prefixed);
925
1093
  }
926
1094
  for (const c of doiCandidates)
927
- chainByInput.get(c.doi)?.push(epmcDisabledEntry);
1095
+ skipEpmc(c.doi);
928
1096
  }
929
1097
  else {
930
1098
  const foldEpmcOutcome = (inputId, outcome) => {
@@ -957,13 +1125,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
957
1125
  // Converter, which returns DOIs for PMC-indexed records. (#88)
958
1126
  if (pmcidFallbackCandidates.length > 0) {
959
1127
  if (!unpaywall) {
1128
+ // The PMCID → DOI lookup below only runs when Unpaywall is configured,
1129
+ // so a candidate arrives here with a DOI only if the EPMC stage handed
1130
+ // one over. An absent DOI therefore means "never looked up", not "this
1131
+ // record has none" — the tier stays a genuine unknown and is marked.
960
1132
  for (const c of pmcidFallbackCandidates) {
961
1133
  const prefixed = withPmcPrefix(c.pmcid);
962
- chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push({
1134
+ const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
1135
+ chainByInput.get(inputId)?.push({
963
1136
  tier: 'unpaywall',
964
1137
  outcome: 'not-attempted',
965
1138
  detail: 'UNPAYWALL_EMAIL is not set',
966
1139
  });
1140
+ markUnqueried(inputId, 'unpaywall');
967
1141
  }
968
1142
  }
969
1143
  else {
@@ -1009,6 +1183,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1009
1183
  const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
1010
1184
  if ('article' in result) {
1011
1185
  fallbackArticles.push(result.article);
1186
+ inputIdByArticle.set(result.article, inputId);
1012
1187
  if (result.truncation)
1013
1188
  truncatedArticles.push(result.truncation);
1014
1189
  recoveredIds.add(inputId);
@@ -1047,12 +1222,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1047
1222
  }
1048
1223
  }
1049
1224
  if (!unpaywall) {
1225
+ // `fetchPubmedDois` has already run, so the DOI state is settled here.
1226
+ // A candidate with no DOI could not have reached Unpaywall configured
1227
+ // or not — that is `no-doi`, a real answer, not an incomplete search.
1050
1228
  for (const c of pmidFallbackCandidates) {
1229
+ if (!c.doi) {
1230
+ chainByInput.get(c.pmid)?.push({ tier: 'unpaywall', outcome: 'no-doi' });
1231
+ continue;
1232
+ }
1051
1233
  chainByInput.get(c.pmid)?.push({
1052
1234
  tier: 'unpaywall',
1053
1235
  outcome: 'not-attempted',
1054
1236
  detail: 'UNPAYWALL_EMAIL is not set',
1055
1237
  });
1238
+ markUnqueried(c.pmid, 'unpaywall');
1056
1239
  }
1057
1240
  }
1058
1241
  else {
@@ -1065,6 +1248,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1065
1248
  for (const { candidate, result } of outcomes) {
1066
1249
  if ('article' in result) {
1067
1250
  fallbackArticles.push(result.article);
1251
+ inputIdByArticle.set(result.article, candidate.pmid);
1068
1252
  if (result.truncation)
1069
1253
  truncatedArticles.push(result.truncation);
1070
1254
  recoveredIds.add(candidate.pmid);
@@ -1082,12 +1266,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1082
1266
  }
1083
1267
  if (doiCandidates.length > 0) {
1084
1268
  if (!unpaywall) {
1269
+ // Every candidate on this branch is a DOI, so Unpaywall applies to all
1270
+ // of them.
1085
1271
  for (const c of doiCandidates) {
1086
1272
  chainByInput.get(c.doi)?.push({
1087
1273
  tier: 'unpaywall',
1088
1274
  outcome: 'not-attempted',
1089
1275
  detail: 'UNPAYWALL_EMAIL is not set',
1090
1276
  });
1277
+ markUnqueried(c.doi, 'unpaywall');
1091
1278
  }
1092
1279
  }
1093
1280
  else {
@@ -1100,6 +1287,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1100
1287
  for (const { doi, result } of outcomes) {
1101
1288
  if ('article' in result) {
1102
1289
  fallbackArticles.push(result.article);
1290
+ inputIdByArticle.set(result.article, doi);
1103
1291
  if (result.truncation)
1104
1292
  truncatedArticles.push(result.truncation);
1105
1293
  recoveredIds.add(doi);
@@ -1120,14 +1308,50 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1120
1308
  for (const [id, chain] of chainByInput) {
1121
1309
  if (recoveredIds.has(id))
1122
1310
  continue;
1311
+ const unqueried = unqueriedByInput.get(id);
1123
1312
  unavailable.push({
1124
1313
  id,
1125
1314
  idType,
1126
1315
  reason: reasonFromChain(chain),
1127
1316
  triedTiers: chain,
1317
+ ...(unqueried?.size && { unqueriedTiers: [...unqueried] }),
1128
1318
  });
1129
1319
  }
1130
- const articles = [...pmcArticles, ...fallbackArticles];
1320
+ // Whole-response budget: fill with complete records in response order and
1321
+ // hand the rest back as identifiers the caller can re-submit. One ledger for
1322
+ // every tier — a PMC-served article and an Unpaywall-served one spend the
1323
+ // same characters. Without `maxResponseCharacters` nothing is measured and
1324
+ // the response is exactly what it was before the budget existed. (#100)
1325
+ const resolved = [...pmcArticles, ...fallbackArticles];
1326
+ const ceiling = input.maxResponseCharacters;
1327
+ const fit = ceiling === undefined ? undefined : fitWholeItems(resolved, ceiling);
1328
+ const articles = fit?.kept ?? resolved;
1329
+ const nextDeferredCharacters = fit?.nextDeferredCharacters;
1330
+ const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
1331
+ ? {
1332
+ maxResponseCharacters: ceiling,
1333
+ returnedCharacters: fit.keptCharacters,
1334
+ deferredCount: fit.deferred.length,
1335
+ idType,
1336
+ // Every recovery site records the input id; `articleDisplayId` is
1337
+ // the total-function fallback, not an expected path.
1338
+ ids: fit.deferred.map((a) => inputIdByArticle.get(a) ?? articleDisplayId(a)),
1339
+ nextDeferredCharacters,
1340
+ }
1341
+ : undefined;
1342
+ // A deferred article takes its body accounting out of the response with it —
1343
+ // those counts describe text the caller never received.
1344
+ if (fit) {
1345
+ for (const article of fit.deferred) {
1346
+ const id = articleDisplayId(article);
1347
+ const index = truncatedArticles.findIndex((t) => t.id === id);
1348
+ if (index === -1)
1349
+ continue;
1350
+ const [dropped] = truncatedArticles.splice(index, 1);
1351
+ if (dropped)
1352
+ omittedSections -= countOmittedSections(dropped, input.overflowMode);
1353
+ }
1354
+ }
1131
1355
  ctx.log.info('pubmed_fetch_fulltext completed', {
1132
1356
  requested: (input.pmids ?? input.pmcids ?? input.dois)?.length ?? 0,
1133
1357
  returned: articles.length,
@@ -1135,6 +1359,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1135
1359
  epmcHits: pmcArticles.filter((a) => a.viaSource === 'europepmc').length,
1136
1360
  unpaywallHits: fallbackArticles.length,
1137
1361
  unavailable: unavailable.length,
1362
+ ...(deferred && { deferred: deferred.deferredCount }),
1138
1363
  });
1139
1364
  // Rolled up only when the budget actually removed characters, so an
1140
1365
  // under-budget request returns exactly what it did before the budget
@@ -1165,6 +1390,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1165
1390
  notices.push(buildTruncationNotice(truncation));
1166
1391
  ctx.enrich({ truncated: true });
1167
1392
  }
1393
+ if (deferred) {
1394
+ notices.push(buildDeferralNotice(deferred));
1395
+ ctx.enrich({ truncated: true });
1396
+ }
1168
1397
  if (notices.length > 0)
1169
1398
  ctx.enrich.notice(notices.join(' '));
1170
1399
  return {
@@ -1172,14 +1401,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1172
1401
  totalReturned: articles.length,
1173
1402
  ...(unavailable.length > 0 && { unavailable }),
1174
1403
  ...(truncation && { truncation }),
1404
+ ...(deferred && { deferred }),
1175
1405
  };
1176
1406
  },
1177
1407
  format: (result) => {
1178
1408
  const lines = [`## Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
1179
1409
  if (result.unavailable?.length) {
1180
1410
  lines.push(`\n**Unavailable (${result.unavailable.length}):**`);
1411
+ let anyUnqueried = false;
1181
1412
  for (const u of result.unavailable) {
1182
1413
  lines.push(`- [${u.idType}] ${u.id} — ${u.reason}`);
1414
+ if (u.unqueriedTiers?.length) {
1415
+ anyUnqueried = true;
1416
+ lines.push(` Not queried: ${formatUnqueriedTiers(u.unqueriedTiers, u.triedTiers)}`);
1417
+ }
1183
1418
  const chain = u.triedTiers
1184
1419
  .map((t) => {
1185
1420
  const detail = t.detail ? sanitizeChainDetail(t.detail) : undefined;
@@ -1189,8 +1424,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1189
1424
  if (chain)
1190
1425
  lines.push(` chain: ${chain}`);
1191
1426
  }
1427
+ // One explanation for the whole list — repeating it per entry buries the
1428
+ // ids it qualifies.
1429
+ if (anyUnqueried) {
1430
+ lines.push(`\n> Tiers marked "Not queried" were skipped because this deployment has not configured them, so those searches are incomplete — a deployment with those tiers configured may still resolve the affected ids.`);
1431
+ }
1432
+ }
1433
+ if (result.deferred) {
1434
+ const d = result.deferred;
1435
+ lines.push(`\n**Deferred by the response budget:** ${d.deferredCount} article(s) — ${d.returnedCharacters} of ${d.maxResponseCharacters} budgeted characters returned; next deferred article ${d.nextDeferredCharacters} characters`, `Re-call \`pubmed_fetch_fulltext\` with these ${d.idType} ids as \`${d.idType}s\`: ${d.ids.join(', ')}`);
1192
1436
  }
1193
- if (result.totalReturned === 0) {
1437
+ // An empty response under a budget is a deferral, not an absence — the
1438
+ // articles resolved and the ids above retrieve them.
1439
+ if (result.totalReturned === 0 && !result.deferred) {
1194
1440
  lines.push(`\n> No full-text articles returned. Articles must be open-access and indexed in PMC, Europe PMC, or recoverable via Unpaywall to retrieve full text. For metadata and abstracts only, use \`pubmed_fetch_articles\`.`);
1195
1441
  }
1196
1442
  if (result.truncation)
@@ -1272,6 +1518,7 @@ async function runEpmcStage(epmc, args) {
1272
1518
  Promise.all(args.doiCandidates.map(fetchForDoi)),
1273
1519
  ]);
1274
1520
  const articles = [];
1521
+ const articleInputIds = new Map();
1275
1522
  const remainingPmid = [];
1276
1523
  const remainingPmcid = [];
1277
1524
  const remainingDoi = [];
@@ -1281,8 +1528,9 @@ async function runEpmcStage(epmc, args) {
1281
1528
  const sectionFilterMisses = [];
1282
1529
  const truncatedArticles = [];
1283
1530
  let omittedSections = 0;
1284
- const collectHit = (run) => {
1531
+ const collectHit = (candidateId, run) => {
1285
1532
  articles.push(run.article);
1533
+ articleInputIds.set(run.article, candidateId);
1286
1534
  if (run.sectionFilterMiss)
1287
1535
  sectionFilterMisses.push(articleDisplayId(run.article));
1288
1536
  if (run.truncation)
@@ -1292,26 +1540,27 @@ async function runEpmcStage(epmc, args) {
1292
1540
  for (const run of pmidResults) {
1293
1541
  pmidOutcomes.set(run.c.pmid, run.outcome);
1294
1542
  if (run.article)
1295
- collectHit({ ...run, article: run.article });
1543
+ collectHit(run.c.pmid, { ...run, article: run.article });
1296
1544
  else
1297
1545
  remainingPmid.push(run.c);
1298
1546
  }
1299
1547
  for (const run of pmcidResults) {
1300
1548
  pmcidOutcomes.set(run.c.normalized, run.outcome);
1301
1549
  if (run.article)
1302
- collectHit({ ...run, article: run.article });
1550
+ collectHit(run.c.normalized, { ...run, article: run.article });
1303
1551
  else
1304
1552
  remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
1305
1553
  }
1306
1554
  for (const run of doiResults) {
1307
1555
  doiOutcomes.set(run.c.doi, run.outcome);
1308
1556
  if (run.article)
1309
- collectHit({ ...run, article: run.article });
1557
+ collectHit(run.c.doi, { ...run, article: run.article });
1310
1558
  else
1311
1559
  remainingDoi.push(run.c);
1312
1560
  }
1313
1561
  return {
1314
1562
  articles,
1563
+ articleInputIds,
1315
1564
  remainingPmid,
1316
1565
  remainingPmcid,
1317
1566
  remainingDoi,
@@ -1614,11 +1863,15 @@ function unpaywallReasonToTierOutcome(reason) {
1614
1863
  }
1615
1864
  }
1616
1865
  /**
1617
- * Derive the terminal `reason` shown on the unavailable entry from its chain.
1618
- * Skips `not-attempted` entries when summarizing those record config state,
1619
- * not content state, so they make a misleading `reason` when an earlier tier
1620
- * produced a real signal (`pmc:miss`, `unpaywall:no-oa`, etc.). Only when every
1621
- * tier was skipped does `reason` fall back to `no-pmc-fallback-disabled`.
1866
+ * Derive the `reason` shown on the unavailable entry from its chain: the most
1867
+ * specific content signal the last tier that actually answered reported.
1868
+ *
1869
+ * Skipped tiers are deliberately not folded in here. A configuration note in
1870
+ * place of the content signal would erase the one specific thing the chain
1871
+ * learned; the incompleteness is reported alongside it, on `unqueriedTiers`,
1872
+ * where it adds to the answer instead of replacing it. A chain where no tier
1873
+ * was attempted at all has no signal to report and stays
1874
+ * `no-pmc-fallback-disabled`.
1622
1875
  */
1623
1876
  function reasonFromChain(chain) {
1624
1877
  let lastSignal;
@@ -1628,6 +1881,13 @@ function reasonFromChain(chain) {
1628
1881
  }
1629
1882
  if (!lastSignal)
1630
1883
  return 'no-pmc-fallback-disabled';
1884
+ // Unpaywall answering `no-doi` for an id the content tiers never found adds
1885
+ // nothing: record absence is the specific signal, so it stays `not-found`.
1886
+ if (lastSignal.tier === 'unpaywall' && lastSignal.outcome === 'no-doi') {
1887
+ const lastContentSignal = chain.findLast((t) => t.tier !== 'unpaywall' && t.outcome !== 'not-attempted');
1888
+ if (lastContentSignal?.outcome === 'miss')
1889
+ return 'not-found';
1890
+ }
1631
1891
  const key = `${lastSignal.tier}:${lastSignal.outcome}`;
1632
1892
  switch (key) {
1633
1893
  case 'pmc:miss':
@@ -1655,6 +1915,26 @@ function reasonFromChain(chain) {
1655
1915
  }
1656
1916
  }
1657
1917
  // ─── format() helpers ────────────────────────────────────────────────────────
1918
+ /** Human-readable tier names for the unqueried-tier line. */
1919
+ const UNQUERIED_TIER_LABELS = {
1920
+ europepmc: 'Europe PMC',
1921
+ unpaywall: 'Unpaywall',
1922
+ };
1923
+ /**
1924
+ * Name each unqueried tier with the reason its chain entry gave for skipping it,
1925
+ * so a `content[]` reader learns which setting is missing without decoding the
1926
+ * chain line. The detail goes through the same sanitizer the chain does — it is
1927
+ * the same upstream string. (#110)
1928
+ */
1929
+ function formatUnqueriedTiers(tiers, chain) {
1930
+ return tiers
1931
+ .map((tier) => {
1932
+ const detail = chain.find((t) => t.tier === tier && t.outcome === 'not-attempted')?.detail;
1933
+ const label = UNQUERIED_TIER_LABELS[tier];
1934
+ return detail ? `${label} (${sanitizeChainDetail(detail)})` : label;
1935
+ })
1936
+ .join(', ');
1937
+ }
1658
1938
  /**
1659
1939
  * Render the response-level character accounting. Every field is rendered
1660
1940
  * unconditionally so `content[]` readers see the same budget detail
@@ -1739,20 +2019,8 @@ function formatPmcArticle(a, lines, truncation) {
1739
2019
  lines.push(truncationNote(truncation));
1740
2020
  if (a.abstract)
1741
2021
  lines.push(`\n#### Abstract\n${a.abstract}`);
1742
- for (const sec of a.sections) {
1743
- if (sec.title)
1744
- lines.push(`\n#### ${formatHeading(sec.label, sec.title)}`);
1745
- if (sec.text)
1746
- lines.push(sec.text);
1747
- if (sec.subsections?.length) {
1748
- for (const sub of sec.subsections) {
1749
- if (sub.title)
1750
- lines.push(`\n##### ${formatHeading(sub.label, sub.title)}`);
1751
- if (sub.text)
1752
- lines.push(sub.text);
1753
- }
1754
- }
1755
- }
2022
+ for (const sec of a.sections)
2023
+ formatSection(sec, lines, 4);
1756
2024
  if (a.references?.length) {
1757
2025
  lines.push(`\n#### References (${a.references.length})`);
1758
2026
  for (const ref of a.references) {
@@ -1804,6 +2072,21 @@ function formatPmcAuthor(au) {
1804
2072
  function formatHeading(label, title) {
1805
2073
  return label ? `${label} ${title}` : title;
1806
2074
  }
2075
+ /**
2076
+ * Render one body section and everything nested under it, one markdown heading
2077
+ * level per nesting level. Walks the full depth the output schema carries, so
2078
+ * `content[]` shows every section `structuredContent` does. Headings stop
2079
+ * deepening at `######`, the deepest markdown supports. (#112)
2080
+ */
2081
+ function formatSection(section, lines, depth) {
2082
+ if (section.title) {
2083
+ lines.push(`\n${'#'.repeat(Math.min(depth, 6))} ${formatHeading(section.label, section.title)}`);
2084
+ }
2085
+ if (section.text)
2086
+ lines.push(section.text);
2087
+ for (const sub of section.subsections ?? [])
2088
+ formatSection(sub, lines, depth + 1);
2089
+ }
1807
2090
  /**
1808
2091
  * Strip absolute URLs from chain detail strings. Upstream errors (e.g.
1809
2092
  * `Fetch failed for <eutils URL>. Status: 400`) leak endpoint paths and query