@cyanheads/congressgov-mcp-server 0.4.3 → 0.5.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.
Files changed (47) hide show
  1. package/AGENTS.md +19 -5
  2. package/CLAUDE.md +19 -5
  3. package/README.md +3 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/mcp-server/prompts/definitions/legislative-research.prompt.d.ts +9 -0
  7. package/dist/mcp-server/prompts/definitions/legislative-research.prompt.d.ts.map +1 -1
  8. package/dist/mcp-server/prompts/definitions/legislative-research.prompt.js +46 -15
  9. package/dist/mcp-server/prompts/definitions/legislative-research.prompt.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/bill-lookup.tool.d.ts +38 -0
  11. package/dist/mcp-server/tools/definitions/bill-lookup.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/bill-lookup.tool.js +72 -4
  13. package/dist/mcp-server/tools/definitions/bill-lookup.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/committee-reports.tool.d.ts +37 -0
  15. package/dist/mcp-server/tools/definitions/committee-reports.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/committee-reports.tool.js +66 -7
  17. package/dist/mcp-server/tools/definitions/committee-reports.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/daily-record.tool.d.ts +41 -3
  19. package/dist/mcp-server/tools/definitions/daily-record.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/daily-record.tool.js +61 -8
  21. package/dist/mcp-server/tools/definitions/daily-record.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/format-helpers.d.ts +9 -5
  23. package/dist/mcp-server/tools/format-helpers.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/format-helpers.js +397 -89
  25. package/dist/mcp-server/tools/format-helpers.js.map +1 -1
  26. package/dist/mcp-server/tools/tool-helpers.d.ts +67 -0
  27. package/dist/mcp-server/tools/tool-helpers.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/tool-helpers.js +85 -0
  29. package/dist/mcp-server/tools/tool-helpers.js.map +1 -1
  30. package/dist/services/congress-documents/congress-documents-service.d.ts +71 -0
  31. package/dist/services/congress-documents/congress-documents-service.d.ts.map +1 -0
  32. package/dist/services/congress-documents/congress-documents-service.js +271 -0
  33. package/dist/services/congress-documents/congress-documents-service.js.map +1 -0
  34. package/dist/services/congress-documents/document-formats.d.ts +29 -0
  35. package/dist/services/congress-documents/document-formats.d.ts.map +1 -0
  36. package/dist/services/congress-documents/document-formats.js +51 -0
  37. package/dist/services/congress-documents/document-formats.js.map +1 -0
  38. package/dist/services/congress-documents/extract-text.d.ts +21 -0
  39. package/dist/services/congress-documents/extract-text.d.ts.map +1 -0
  40. package/dist/services/congress-documents/extract-text.js +81 -0
  41. package/dist/services/congress-documents/extract-text.js.map +1 -0
  42. package/dist/services/congress-documents/types.d.ts +45 -0
  43. package/dist/services/congress-documents/types.d.ts.map +1 -0
  44. package/dist/services/congress-documents/types.js +6 -0
  45. package/dist/services/congress-documents/types.js.map +1 -0
  46. package/package.json +1 -1
  47. package/server.json +3 -3
@@ -6,11 +6,15 @@
6
6
  * NOT reliably forwarded. These formatters render complete, structured
7
7
  * markdown so the LLM can reason about all returned data.
8
8
  *
9
- * **Completeness invariant.** A record reaching a detail renderer is rendered
10
- * whole: every upstream field appears, and no value is cut mid-string. A
11
- * hand-built header may curate presentation, but it ends in `renderDetailRest`,
12
- * and a curated collection row ends in `withUnnamedFields` — so a field neither
13
- * one names still reaches the reader. The single deliberate exception is a
9
+ * **Completeness invariant.** A record reaching any renderer — detail view or
10
+ * list row — is rendered whole: every upstream field appears, and no value is
11
+ * cut mid-string. A hand-built header may curate presentation, but a detail
12
+ * header ends in `renderDetailRest`, a list row ends in `withRowRest`, and a
13
+ * curated single-line row ends in `withUnnamedFields` — so a field none of them
14
+ * names still reaches the reader. A collection a curated line renders lossily
15
+ * (its latest entry only, or only some of each entry's fields) is deliberately
16
+ * left out of the row's named set, so the fall-through carries it whole. The
17
+ * single deliberate exception is a
14
18
  * `{count, url}` sub-resource reference, rendered as "N available": the URL is
15
19
  * the upstream REST endpoint, and the tool's own operation is the callable path
16
20
  * to those rows. Caps belong to pagination, which has a continuation path
@@ -218,11 +222,10 @@ function renderDetail(obj) {
218
222
  lines.push(`**${key}:** ${count} available`);
219
223
  continue;
220
224
  }
221
- // latestAction: { actionDate, text }
222
225
  if (key === 'latestAction') {
223
- const date = s(nested, 'actionDate');
224
- const text = s(nested, 'text');
225
- lines.push(`**Latest Action:** ${[date, text].filter(Boolean).join(' — ')}`);
226
+ const line = latestActionLine(record);
227
+ if (line)
228
+ lines.push(line);
226
229
  continue;
227
230
  }
228
231
  // Small objects inline, larger ones nested
@@ -248,22 +251,28 @@ function renderDetail(obj) {
248
251
  }
249
252
  /** Render any list item with all its fields. */
250
253
  function renderGenericItem(item, index) {
251
- // Build heading from common identifier fields
252
- const id = item.type && item.number != null
253
- ? `${String(item.type).toUpperCase()} ${item.number}`
254
- : (s(item, 'citation') ?? s(item, 'bioguideId') ?? s(item, 'systemCode'));
255
- const name = s(item, 'title') ??
256
- s(item, 'name') ??
257
- s(item, 'fullName') ??
258
- s(item, 'directOrderName') ??
259
- s(item, 'description') ??
260
- s(item, 'question');
254
+ /**
255
+ * Only the keys the heading actually consumed are skipped below. A fixed
256
+ * skip-list swallows the alternates: a committee-report row carries both a
257
+ * `citation` and a `type`/`number` pair, and the heading built from the pair
258
+ * would drop `H. Rept. 113-118` — the label Congress.gov itself publishes.
259
+ */
260
+ const named = new Set();
261
+ let id;
262
+ if (item.type && item.number != null) {
263
+ named.add('type').add('number');
264
+ id = `${String(item.type).toUpperCase()} ${item.number}`;
265
+ }
266
+ else {
267
+ id = pick(item, named, 'citation', 'bioguideId', 'systemCode');
268
+ }
269
+ const name = pick(item, named, 'title', 'name', 'fullName', 'directOrderName', 'description', 'question');
261
270
  const heading = [id, name].filter(Boolean).join(': ') || 'Item';
262
271
  const lines = [`### ${index + 1}. ${heading}`];
263
272
  for (const [key, val] of Object.entries(item)) {
264
273
  if (val == null || val === '')
265
274
  continue;
266
- if (HEADING_FIELDS.has(key))
275
+ if (named.has(key))
267
276
  continue;
268
277
  if (typeof val === 'string') {
269
278
  const inline = stripHtml(val, { inline: true });
@@ -279,10 +288,9 @@ function renderGenericItem(item, index) {
279
288
  lines.push(`**${key}:** ${val}`);
280
289
  }
281
290
  else if (typeof val === 'object' && !Array.isArray(val) && val !== null) {
282
- if (key === 'latestAction') {
283
- const date = s(val, 'actionDate');
284
- const text = s(val, 'text');
285
- lines.push(`**Latest Action:** ${[date, text].filter(Boolean).join(' — ')}`);
291
+ const action = key === 'latestAction' ? latestActionLine(item) : undefined;
292
+ if (action) {
293
+ lines.push(action);
286
294
  }
287
295
  else {
288
296
  lines.push(`**${key}:** ${renderInline(val)}`);
@@ -303,19 +311,25 @@ function renderGenericItem(item, index) {
303
311
  }
304
312
  return lines.join('\n');
305
313
  }
306
- const HEADING_FIELDS = new Set([
307
- 'type',
308
- 'number',
309
- 'citation',
310
- 'bioguideId',
311
- 'systemCode',
312
- 'title',
313
- 'name',
314
- 'fullName',
315
- 'directOrderName',
316
- 'description',
317
- 'question',
318
- ]);
314
+ /** Fields of a `latestAction` object the curated line names — `actionTime` is not one. */
315
+ const LATEST_ACTION_KEYS = new Set(['actionDate', 'text']);
316
+ /**
317
+ * The `**Latest Action:** date — text` line, followed by any sub-field that
318
+ * curation does not name. Every renderer that shows a `latestAction` object
319
+ * shows it the same lossy way, so the fall-through lives here rather than in
320
+ * each caller's key set — upstream carries `actionTime` on bill rows reached
321
+ * through `member_lookup sponsored`, and it reached no `content[]` client.
322
+ * Returns undefined when the row carries no latest action at all.
323
+ */
324
+ function latestActionLine(item) {
325
+ const action = item.latestAction;
326
+ if (!action || typeof action !== 'object' || Array.isArray(action))
327
+ return;
328
+ const record = action;
329
+ const curated = [s(record, 'actionDate'), s(record, 'text')].filter(Boolean).join(' — ');
330
+ const line = withUnnamedFields(record, curated, LATEST_ACTION_KEYS);
331
+ return line ? `**Latest Action:** ${line.replace(/^ — /, '')}` : undefined;
332
+ }
319
333
  /**
320
334
  * Compact one-line render of a small object. Compact but never lossy: values are
321
335
  * carried in full and nested objects/arrays recurse, so no field or character is
@@ -370,6 +384,35 @@ function withUnnamedFields(row, curated, named) {
370
384
  const unnamed = inlineFields(rest);
371
385
  return unnamed ? `${curated} — ${unnamed}` : curated;
372
386
  }
387
+ /**
388
+ * A curated multi-line list row followed by every field of the row the curation
389
+ * never named — the list-row counterpart to a detail header's `renderDetailRest`
390
+ * tail. Without it a hand-built row renders a fixed field set, and anything
391
+ * upstream adds (or the curation never reached, like a subcommittee's `parent`)
392
+ * stays in `structuredContent` and never reaches a `content[]`-only client.
393
+ */
394
+ function withRowRest(item, lines, named) {
395
+ const rest = renderDetailRest(item, named);
396
+ return rest ? [...lines, '', rest].join('\n') : lines.join('\n');
397
+ }
398
+ /**
399
+ * First present value among `keys`, marking only the key actually consumed as
400
+ * named. An alias chain (`updateDate ?? publishDate`) otherwise swallows the
401
+ * alternates: they are distinct upstream facts, so the ones the header did not
402
+ * render have to stay available to the fall-through. Renderers that call this
403
+ * hold their key constant as an array and copy it into a fresh set per row —
404
+ * `pick` mutates the set it is given.
405
+ */
406
+ function pick(item, named, ...keys) {
407
+ for (const key of keys) {
408
+ const val = s(item, key);
409
+ if (val != null && val !== '') {
410
+ named.add(key);
411
+ return val;
412
+ }
413
+ }
414
+ return;
415
+ }
373
416
  // ── Domain-Specific Item Renderers ──────────────────────────────────
374
417
  /**
375
418
  * Public/private law citations from a bill or law record's `laws[]`, e.g.
@@ -412,17 +455,30 @@ function renderBillItem(item, i) {
412
455
  });
413
456
  lines.push(`**Sponsor:** ${sponsors.join(', ')}`);
414
457
  }
415
- const actionDate = s(item, 'latestAction', 'actionDate');
416
- const actionText = s(item, 'latestAction', 'text');
417
- if (actionDate || actionText)
418
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
458
+ const latestAction = latestActionLine(item);
459
+ if (latestAction)
460
+ lines.push(latestAction);
419
461
  const updated = s(item, 'updateDate');
420
462
  if (updated)
421
463
  lines.push(`**Updated:** ${updated}`);
422
464
  if (url)
423
465
  lines.push(`**URL:** ${url}`);
424
- return lines.join('\n');
466
+ return withRowRest(item, lines, BILL_ROW_KEYS);
425
467
  }
468
+ /** `sponsors` is absent: the curated line drops each sponsor's `bioguideId`. */
469
+ const BILL_ROW_KEYS = new Set([
470
+ 'type',
471
+ 'number',
472
+ 'title',
473
+ 'congress',
474
+ 'originChamber',
475
+ 'originChamberCode',
476
+ 'policyArea',
477
+ 'laws',
478
+ 'latestAction',
479
+ 'updateDate',
480
+ 'url',
481
+ ]);
426
482
  function renderMemberItem(item, i) {
427
483
  const name = s(item, 'name') ?? s(item, 'directOrderName') ?? s(item, 'fullName') ?? 'Unknown Member';
428
484
  const url = s(item, 'url');
@@ -455,15 +511,28 @@ function renderMemberItem(item, i) {
455
511
  }
456
512
  if (url)
457
513
  lines.push(`**URL:** ${url}`);
458
- return lines.join('\n');
514
+ return withRowRest(item, lines, MEMBER_ROW_KEYS);
459
515
  }
516
+ /** `terms` is absent: the curated line renders the latest term only. */
517
+ const MEMBER_ROW_KEYS = new Set([
518
+ 'bioguideId',
519
+ 'name',
520
+ 'directOrderName',
521
+ 'fullName',
522
+ 'partyName',
523
+ 'party',
524
+ 'state',
525
+ 'district',
526
+ 'url',
527
+ ]);
460
528
  function renderSummaryItem(item, i) {
529
+ const named = new Set(SUMMARY_ROW_KEYS);
461
530
  const billType = s(item, 'bill', 'type')?.toUpperCase() ?? '';
462
531
  const billNum = s(item, 'bill', 'number') ?? '';
463
532
  const congress = s(item, 'bill', 'congress') ?? '';
464
- const version = s(item, 'actionDesc') ?? s(item, 'versionCode') ?? '';
533
+ const version = pick(item, named, 'actionDesc', 'versionCode') ?? '';
465
534
  const actionDate = s(item, 'actionDate') ?? '';
466
- const summaryUpdate = s(item, 'lastSummaryUpdateDate') ?? s(item, 'updateDate') ?? '';
535
+ const summaryUpdate = pick(item, named, 'lastSummaryUpdateDate', 'updateDate') ?? '';
467
536
  const rawText = typeof item.text === 'string' ? item.text : '';
468
537
  const text = rawText ? htmlToMarkdown(rawText) : '';
469
538
  const url = s(item, 'url') ?? s(item, 'bill', 'url');
@@ -478,19 +547,28 @@ function renderSummaryItem(item, i) {
478
547
  if (meta)
479
548
  lines.push(meta);
480
549
  const billTitle = s(item, 'bill', 'title');
481
- lines.push(`**Bill Title:** ${billTitle ?? 'Not available'}`);
550
+ const billLine = `**Bill Title:** ${billTitle ?? 'Not available'}`;
551
+ const bill = item.bill;
552
+ lines.push(bill && typeof bill === 'object' && !Array.isArray(bill)
553
+ ? withUnnamedFields(bill, billLine, BILL_REF_KEYS)
554
+ : billLine);
482
555
  // The summary text is the critical data — the whole point of this tool
483
556
  lines.push('');
484
557
  lines.push(text || '_Summary text not available._');
485
558
  if (url)
486
559
  lines.push(`\n**URL:** ${url}`);
487
- return lines.join('\n');
560
+ return withRowRest(item, lines, named);
488
561
  }
562
+ /** `actionDesc`/`versionCode` and the two update dates are resolved by `pick`. */
563
+ const SUMMARY_ROW_KEYS = ['actionDate', 'text', 'url', 'bill'];
564
+ /** Fields of a summary row's nested bill reference the heading already renders. */
565
+ const BILL_REF_KEYS = new Set(['congress', 'type', 'number', 'title', 'url']);
489
566
  function renderCrsReportItem(item, i) {
567
+ const named = new Set(CRS_REPORT_ROW_KEYS);
490
568
  const reportNumber = s(item, 'reportNumber') ?? s(item, 'number') ?? s(item, 'id') ?? 'Report number not available';
491
569
  const title = s(item, 'title') ?? 'Title not available';
492
- const updated = s(item, 'updateDate') ?? s(item, 'publishDate') ?? s(item, 'date') ?? '';
493
- const summary = s(item, 'summary') ?? s(item, 'abstract') ?? '';
570
+ const updated = pick(item, named, 'updateDate', 'publishDate', 'date') ?? '';
571
+ const summary = pick(item, named, 'summary', 'abstract') ?? '';
494
572
  const contentType = s(item, 'contentType');
495
573
  const status = s(item, 'status');
496
574
  const version = s(item, 'version');
@@ -508,8 +586,23 @@ function renderCrsReportItem(item, i) {
508
586
  lines.push(summary);
509
587
  if (url)
510
588
  lines.push(`**URL:** ${url}`);
511
- return lines.join('\n');
589
+ return withRowRest(item, lines, named);
512
590
  }
591
+ /**
592
+ * The three identifier aliases are all named: upstream sends one of them, and
593
+ * the heading renders whichever arrived. The date and summary aliases are
594
+ * distinct facts, so `pick` names only the one the header consumed.
595
+ */
596
+ const CRS_REPORT_ROW_KEYS = [
597
+ 'reportNumber',
598
+ 'number',
599
+ 'id',
600
+ 'title',
601
+ 'contentType',
602
+ 'status',
603
+ 'version',
604
+ 'url',
605
+ ];
513
606
  /** Daily Congressional Record articles — flattened from section-wrapped shape. */
514
607
  function renderDailyArticleItem(item, i) {
515
608
  const title = s(item, 'title') ?? 'Untitled article';
@@ -530,8 +623,9 @@ function renderDailyArticleItem(item, i) {
530
623
  lines.push(`**${type}:** ${url}`);
531
624
  }
532
625
  }
533
- return lines.join('\n');
626
+ return withRowRest(item, lines, DAILY_ARTICLE_ROW_KEYS);
534
627
  }
628
+ const DAILY_ARTICLE_ROW_KEYS = new Set(['title', 'sectionName', 'startPage', 'endPage', 'text']);
535
629
  /** Daily Congressional Record volumes and issues. */
536
630
  function renderDailyRecordItem(item, i) {
537
631
  const volume = s(item, 'volumeNumber');
@@ -554,8 +648,17 @@ function renderDailyRecordItem(item, i) {
554
648
  lines.push(meta);
555
649
  if (url)
556
650
  lines.push(`**URL:** ${url}`);
557
- return lines.join('\n');
651
+ return withRowRest(item, lines, DAILY_RECORD_ROW_KEYS);
558
652
  }
653
+ /** `issueDate` is absent: the heading shows the date, the fall-through the time. */
654
+ const DAILY_RECORD_ROW_KEYS = new Set([
655
+ 'volumeNumber',
656
+ 'issueNumber',
657
+ 'congress',
658
+ 'sessionNumber',
659
+ 'updateDate',
660
+ 'url',
661
+ ]);
559
662
  /** House roll call votes. */
560
663
  function renderRollVoteItem(item, i) {
561
664
  const roll = s(item, 'rollCallNumber');
@@ -592,8 +695,23 @@ function renderRollVoteItem(item, i) {
592
695
  lines.push(`**URL:** ${url}`);
593
696
  if (sourceUrl)
594
697
  lines.push(`**Source Data URL:** ${sourceUrl}`);
595
- return lines.join('\n');
698
+ return withRowRest(item, lines, ROLL_VOTE_ROW_KEYS);
596
699
  }
700
+ const ROLL_VOTE_ROW_KEYS = new Set([
701
+ 'rollCallNumber',
702
+ 'identifier',
703
+ 'legislationType',
704
+ 'legislationNumber',
705
+ 'legislationUrl',
706
+ 'result',
707
+ 'voteType',
708
+ 'startDate',
709
+ 'congress',
710
+ 'sessionNumber',
711
+ 'updateDate',
712
+ 'sourceDataURL',
713
+ 'url',
714
+ ]);
597
715
  /** Bill legislative actions. */
598
716
  function renderBillActionItem(item, i) {
599
717
  const actionDate = s(item, 'actionDate');
@@ -612,8 +730,10 @@ function renderBillActionItem(item, i) {
612
730
  if (names.length > 0)
613
731
  lines.push(`**Committees:** ${names.join(', ')}`);
614
732
  }
615
- return lines.join('\n');
733
+ return withRowRest(item, lines, BILL_ACTION_ROW_KEYS);
616
734
  }
735
+ /** `committees` is absent: the name list drops each committee's `systemCode`. */
736
+ const BILL_ACTION_ROW_KEYS = new Set(['actionDate', 'text', 'type', 'actionCode', 'sourceSystem']);
617
737
  /** Committee report text — items wrap a `formats` array of {type, url, isErrata}. */
618
738
  function renderCommitteeReportTextItem(item, i) {
619
739
  const formats = item.formats;
@@ -635,8 +755,9 @@ function renderCommitteeReportTextItem(item, i) {
635
755
  const label = e.isErrata ? `${e.type} (Errata)` : e.type;
636
756
  lines.push(`**${label}:** ${e.url}`);
637
757
  }
638
- return lines.join('\n');
758
+ return withRowRest(item, lines, COMMITTEE_REPORT_TEXT_ROW_KEYS);
639
759
  }
760
+ const COMMITTEE_REPORT_TEXT_ROW_KEYS = new Set(['formats']);
640
761
  /** Member-sponsored amendments — `type`/`title` are null upstream; identify by `amendmentNumber`. */
641
762
  function renderAmendmentItem(item, i) {
642
763
  const number = s(item, 'amendmentNumber');
@@ -657,14 +778,20 @@ function renderAmendmentItem(item, i) {
657
778
  ]);
658
779
  if (meta)
659
780
  lines.push(meta);
660
- const actionDate = s(item, 'latestAction', 'actionDate');
661
- const actionText = s(item, 'latestAction', 'text');
662
- if (actionDate || actionText)
663
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
781
+ const latestAction = latestActionLine(item);
782
+ if (latestAction)
783
+ lines.push(latestAction);
664
784
  if (url)
665
785
  lines.push(`**URL:** ${url}`);
666
- return lines.join('\n');
786
+ return withRowRest(item, lines, AMENDMENT_ROW_KEYS);
667
787
  }
788
+ const AMENDMENT_ROW_KEYS = new Set([
789
+ 'amendmentNumber',
790
+ 'congress',
791
+ 'introducedDate',
792
+ 'latestAction',
793
+ 'url',
794
+ ]);
668
795
  /** Bill text versions — heading from `type` (e.g. "Enrolled Bill"), formats[] as labeled URLs. */
669
796
  function renderBillTextItem(item, i) {
670
797
  const type = s(item, 'type') ?? 'Bill Text';
@@ -681,8 +808,9 @@ function renderBillTextItem(item, i) {
681
808
  lines.push(`**${fType}:** ${fUrl}`);
682
809
  }
683
810
  }
684
- return lines.join('\n');
811
+ return withRowRest(item, lines, BILL_TEXT_ROW_KEYS);
685
812
  }
813
+ const BILL_TEXT_ROW_KEYS = new Set(['type', 'date', 'formats']);
686
814
  /** Nomination type wrapper: `{isCivilian: true}` / `{isMilitary: true}` → readable label. */
687
815
  function nominationTypeLabel(raw) {
688
816
  if (!raw || typeof raw !== 'object')
@@ -722,15 +850,27 @@ function renderNominationListItem(item, i) {
722
850
  ]);
723
851
  if (meta)
724
852
  lines.push(meta);
725
- const actionDate = s(item, 'latestAction', 'actionDate');
726
- const actionText = s(item, 'latestAction', 'text');
727
- if (actionDate || actionText)
728
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
853
+ const latestAction = latestActionLine(item);
854
+ if (latestAction)
855
+ lines.push(latestAction);
729
856
  const url = s(item, 'url');
730
857
  if (url)
731
858
  lines.push(`**URL:** ${url}`);
732
- return lines.join('\n');
859
+ return withRowRest(item, lines, NOMINATION_ROW_KEYS);
733
860
  }
861
+ const NOMINATION_ROW_KEYS = new Set([
862
+ 'citation',
863
+ 'number',
864
+ 'partNumber',
865
+ 'nominationType',
866
+ 'description',
867
+ 'congress',
868
+ 'receivedDate',
869
+ 'authorityDate',
870
+ 'updateDate',
871
+ 'latestAction',
872
+ 'url',
873
+ ]);
734
874
  /** Nomination committee items — shape {name, systemCode, chamber, type, activities[], url}. */
735
875
  function renderNominationCommitteeItem(item, i) {
736
876
  const name = s(item, 'name') ?? s(item, 'systemCode') ?? 'Committee';
@@ -754,8 +894,10 @@ function renderNominationCommitteeItem(item, i) {
754
894
  const url = s(item, 'url');
755
895
  if (url)
756
896
  lines.push(`**URL:** ${url}`);
757
- return lines.join('\n');
897
+ return withRowRest(item, lines, NOMINATION_COMMITTEE_ROW_KEYS);
758
898
  }
899
+ /** `activities` is absent: the curated lines cut each activity date to 10 chars. */
900
+ const NOMINATION_COMMITTEE_ROW_KEYS = new Set(['name', 'systemCode', 'chamber', 'type', 'url']);
759
901
  /** Individual nominee items — shape {firstName, middleName, lastName, ordinal, state, prefix?, suffix?}. */
760
902
  function renderNomineeItem(item, i) {
761
903
  const prefix = s(item, 'prefix');
@@ -769,8 +911,17 @@ function renderNomineeItem(item, i) {
769
911
  const meta = join([f('Ordinal', s(item, 'ordinal')), f('State', s(item, 'state'))]);
770
912
  if (meta)
771
913
  lines.push(meta);
772
- return lines.join('\n');
914
+ return withRowRest(item, lines, NOMINEE_ITEM_KEYS);
773
915
  }
916
+ const NOMINEE_ITEM_KEYS = new Set([
917
+ 'prefix',
918
+ 'firstName',
919
+ 'middleName',
920
+ 'lastName',
921
+ 'suffix',
922
+ 'ordinal',
923
+ 'state',
924
+ ]);
774
925
  /** Nomination hearing items — shape {chamber, citation, date, jacketNumber, number, partNumber, errata?}. */
775
926
  function renderNominationHearingItem(item, i) {
776
927
  const citation = s(item, 'citation');
@@ -780,7 +931,7 @@ function renderNominationHearingItem(item, i) {
780
931
  const partNumber = s(item, 'partNumber');
781
932
  const meta = join([
782
933
  f('Chamber', s(item, 'chamber')),
783
- f('Date', s(item, 'date')?.slice(0, 10)),
934
+ f('Date', s(item, 'date')),
784
935
  f('Number', number),
785
936
  partNumber && partNumber !== '1' && partNumber !== '01' ? f('Part', partNumber) : undefined,
786
937
  f('Jacket', s(item, 'jacketNumber')),
@@ -788,8 +939,17 @@ function renderNominationHearingItem(item, i) {
788
939
  ]);
789
940
  if (meta)
790
941
  lines.push(meta);
791
- return lines.join('\n');
942
+ return withRowRest(item, lines, NOMINATION_HEARING_ROW_KEYS);
792
943
  }
944
+ const NOMINATION_HEARING_ROW_KEYS = new Set([
945
+ 'citation',
946
+ 'number',
947
+ 'chamber',
948
+ 'date',
949
+ 'partNumber',
950
+ 'jacketNumber',
951
+ 'errata',
952
+ ]);
793
953
  /** Dispatch nomination list rows to the right renderer by shape signal. */
794
954
  function pickNominationListRenderer(first) {
795
955
  /** Action rows share the bill-action shape (actionDate, text, type, actionCode);
@@ -825,10 +985,9 @@ function renderNominationDetail(item) {
825
985
  ]);
826
986
  if (meta)
827
987
  lines.push(meta);
828
- const actionDate = s(item, 'latestAction', 'actionDate');
829
- const actionText = s(item, 'latestAction', 'text');
830
- if (actionDate || actionText)
831
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
988
+ const latestAction = latestActionLine(item);
989
+ if (latestAction)
990
+ lines.push(latestAction);
832
991
  const subResources = [];
833
992
  for (const key of ['actions', 'committees', 'hearings']) {
834
993
  const sub = item[key];
@@ -946,9 +1105,10 @@ const HEADER_ROLL_VOTE_KEYS = new Set([
946
1105
  ]);
947
1106
  /** One member's position: "Warren Davidson (R-OH) → Nay". */
948
1107
  function renderMemberVoteRow(r) {
1108
+ const named = new Set(MEMBER_VOTE_ROW_KEYS);
949
1109
  const first = s(r, 'firstName');
950
1110
  const last = s(r, 'lastName');
951
- const name = first && last ? `${first} ${last}` : (last ?? first ?? s(r, 'bioguideId') ?? '?');
1111
+ const name = (first && last ? `${first} ${last}` : (last ?? first)) ?? pick(r, named, 'bioguideId') ?? '?';
952
1112
  const party = s(r, 'voteParty');
953
1113
  const state = s(r, 'voteState');
954
1114
  const cast = s(r, 'voteCast');
@@ -959,8 +1119,11 @@ function renderMemberVoteRow(r) {
959
1119
  : state
960
1120
  ? `(${state})`
961
1121
  : undefined;
962
- return `- ${[name, loc, cast ? `→ ${cast}` : undefined].filter(Boolean).join(' ')}`;
1122
+ const curated = `- ${[name, loc, cast ? `→ ${cast}` : undefined].filter(Boolean).join(' ')}`;
1123
+ return withUnnamedFields(r, curated, named);
963
1124
  }
1125
+ /** `bioguideId` is named only when it stood in for a missing name. */
1126
+ const MEMBER_VOTE_ROW_KEYS = ['firstName', 'lastName', 'voteParty', 'voteState', 'voteCast'];
964
1127
  /**
965
1128
  * Member voting positions for one roll call — vote-context header, the
966
1129
  * `pagination`-derived range footer, then the paginated roster from `data[]`.
@@ -986,6 +1149,9 @@ function renderVoteMembers(result) {
986
1149
  const legNum = s(vote, 'legislationNumber');
987
1150
  if (legType && legNum)
988
1151
  lines.push(`**Legislation:** ${legType} ${legNum}`);
1152
+ const voteRest = renderDetailRest(vote, VOTE_CONTEXT_KEYS);
1153
+ if (voteRest)
1154
+ lines.push('', voteRest);
989
1155
  if (rows.length === 0) {
990
1156
  lines.push('', total > 0
991
1157
  ? `_Page is empty — offset is past the end of ${total} member position${total !== 1 ? 's' : ''}._`
@@ -1003,6 +1169,16 @@ function renderVoteMembers(result) {
1003
1169
  }
1004
1170
  return lines.join('\n');
1005
1171
  }
1172
+ /** Fields of the `vote` context sibling the members header already renders. */
1173
+ const VOTE_CONTEXT_KEYS = new Set([
1174
+ 'rollCallNumber',
1175
+ 'congress',
1176
+ 'sessionNumber',
1177
+ 'voteQuestion',
1178
+ 'result',
1179
+ 'legislationType',
1180
+ 'legislationNumber',
1181
+ ]);
1006
1182
  // ── Senate roll call votes (LIS feed) ───────────────────────────────
1007
1183
  /**
1008
1184
  * Senate vote payloads come from the LIS XML feed, not the Congress.gov JSON API,
@@ -1032,17 +1208,34 @@ function renderSenateVoteItem(item, i) {
1032
1208
  const title = s(item, 'title');
1033
1209
  if (title)
1034
1210
  lines.push(title);
1035
- return lines.join('\n');
1211
+ return withRowRest(item, lines, SENATE_VOTE_ROW_KEYS);
1036
1212
  }
1213
+ /** `chamber` is the dispatch marker, implicit in the Senate heading. */
1214
+ const SENATE_VOTE_ROW_KEYS = new Set([
1215
+ 'chamber',
1216
+ 'voteNumber',
1217
+ 'issue',
1218
+ 'result',
1219
+ 'question',
1220
+ 'measure',
1221
+ 'voteDate',
1222
+ 'yeas',
1223
+ 'nays',
1224
+ 'title',
1225
+ ]);
1037
1226
  /** One Senate member's position — uses the feed's pre-formatted "Baldwin (D-WI)" label. */
1038
1227
  function renderSenateMemberRow(r) {
1039
1228
  const cast = s(r, 'voteCast');
1040
1229
  const full = s(r, 'memberFull');
1041
- if (full)
1042
- return `- ${full}${cast ? ` → ${cast}` : ''}`;
1230
+ if (full) {
1231
+ /** The feed composes memberFull from the last name, party, and state. */
1232
+ const named = new Set([...SENATE_MEMBER_ROW_KEYS, 'memberFull', 'lastName', 'party', 'state']);
1233
+ return withUnnamedFields(r, `- ${full}${cast ? ` → ${cast}` : ''}`, named);
1234
+ }
1235
+ const named = new Set([...SENATE_MEMBER_ROW_KEYS, 'firstName', 'lastName', 'party', 'state']);
1043
1236
  const first = s(r, 'firstName');
1044
1237
  const last = s(r, 'lastName');
1045
- const name = first && last ? `${first} ${last}` : (last ?? first ?? s(r, 'lisMemberId') ?? '?');
1238
+ const name = (first && last ? `${first} ${last}` : (last ?? first)) ?? pick(r, named, 'lisMemberId') ?? '?';
1046
1239
  const party = s(r, 'party');
1047
1240
  const state = s(r, 'state');
1048
1241
  const loc = party && state
@@ -1052,8 +1245,10 @@ function renderSenateMemberRow(r) {
1052
1245
  : state
1053
1246
  ? `(${state})`
1054
1247
  : undefined;
1055
- return `- ${[name, loc, cast ? `→ ${cast}` : undefined].filter(Boolean).join(' ')}`;
1248
+ const curated = `- ${[name, loc, cast ? `→ ${cast}` : undefined].filter(Boolean).join(' ')}`;
1249
+ return withUnnamedFields(r, curated, named);
1056
1250
  }
1251
+ const SENATE_MEMBER_ROW_KEYS = ['chamber', 'voteCast'];
1057
1252
  /** Senate vote detail — question, result, tally, derived party totals, document/amendment. */
1058
1253
  function renderSenateVoteDetail(item) {
1059
1254
  const num = s(item, 'voteNumber');
@@ -1170,6 +1365,9 @@ function renderSenateVoteMembers(result) {
1170
1365
  ], ' — ');
1171
1366
  if (context)
1172
1367
  lines.push(context);
1368
+ const voteRest = renderDetailRest(vote, SENATE_VOTE_CONTEXT_KEYS);
1369
+ if (voteRest)
1370
+ lines.push('', voteRest);
1173
1371
  if (rows.length === 0) {
1174
1372
  lines.push('', total > 0
1175
1373
  ? `_Page is empty — offset is past the end of ${total} member position${total !== 1 ? 's' : ''}._`
@@ -1185,6 +1383,17 @@ function renderSenateVoteMembers(result) {
1185
1383
  }
1186
1384
  return lines.join('\n');
1187
1385
  }
1386
+ /** Fields of the Senate `vote` context sibling the members header already renders. */
1387
+ const SENATE_VOTE_CONTEXT_KEYS = new Set([
1388
+ 'chamber',
1389
+ 'voteNumber',
1390
+ 'congress',
1391
+ 'session',
1392
+ 'voteQuestionText',
1393
+ 'question',
1394
+ 'voteResultText',
1395
+ 'voteResult',
1396
+ ]);
1188
1397
  /** Senate payloads carry a `chamber: 'senate'` marker on the envelope, vote, and items. */
1189
1398
  function isSenateResult(result) {
1190
1399
  if (result.chamber === 'senate')
@@ -1213,10 +1422,9 @@ function renderBillDetail(item) {
1213
1422
  ]);
1214
1423
  if (meta)
1215
1424
  lines.push(meta);
1216
- const actionDate = s(item, 'latestAction', 'actionDate');
1217
- const actionText = s(item, 'latestAction', 'text');
1218
- if (actionDate || actionText)
1219
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
1425
+ const latestAction = latestActionLine(item);
1426
+ if (latestAction)
1427
+ lines.push(latestAction);
1220
1428
  const citation = lawCitations(item);
1221
1429
  if (citation)
1222
1430
  lines.push(`**Law:** ${citation}`);
@@ -1402,8 +1610,18 @@ function renderCommitteeListItem(item, i) {
1402
1610
  const url = s(item, 'url');
1403
1611
  if (url)
1404
1612
  lines.push(`**URL:** ${url}`);
1405
- return lines.join('\n');
1613
+ return withRowRest(item, lines, COMMITTEE_ROW_KEYS);
1406
1614
  }
1615
+ /** `parent` is absent: a subcommittee row's owning committee code has to reach the reader. */
1616
+ const COMMITTEE_ROW_KEYS = new Set([
1617
+ 'name',
1618
+ 'systemCode',
1619
+ 'chamber',
1620
+ 'committeeTypeCode',
1621
+ 'updateDate',
1622
+ 'url',
1623
+ 'approximate',
1624
+ ]);
1407
1625
  /** Committee report list item — citation-first; upstream omits title and bill ref. */
1408
1626
  function renderCommitteeReportListItem(item, i) {
1409
1627
  const citation = s(item, 'citation');
@@ -1428,8 +1646,76 @@ function renderCommitteeReportListItem(item, i) {
1428
1646
  lines.push(meta);
1429
1647
  if (url)
1430
1648
  lines.push(`**URL:** ${url}`);
1649
+ return withRowRest(item, lines, COMMITTEE_REPORT_ROW_KEYS);
1650
+ }
1651
+ const COMMITTEE_REPORT_ROW_KEYS = new Set([
1652
+ 'citation',
1653
+ 'type',
1654
+ 'number',
1655
+ 'part',
1656
+ 'congress',
1657
+ 'chamber',
1658
+ 'updateDate',
1659
+ 'url',
1660
+ ]);
1661
+ // ── Document Content (the `content` operation) ──────────────────────
1662
+ /**
1663
+ * One character window of a legislative document's text.
1664
+ *
1665
+ * The other renderers in this file reshape upstream JSON records; this one
1666
+ * carries a document body, so the text is emitted verbatim — no entity work, no
1667
+ * whitespace collapsing, no markdown wrapper. GPO's pre-formatted layout (column
1668
+ * alignment, indentation, blank-line structure) *is* the document's structure,
1669
+ * and a code fence would break on the doubled backticks bill text uses for
1670
+ * opening quotation marks.
1671
+ *
1672
+ * The header states the window in both vocabularies: a 1-based human range, and
1673
+ * the 0-based `offset` / `nextOffset` a caller actually feeds back.
1674
+ */
1675
+ function renderDocumentContent(content) {
1676
+ const total = typeof content.totalCharacters === 'number' ? content.totalCharacters : 0;
1677
+ const offset = typeof content.offset === 'number' ? content.offset : 0;
1678
+ const text = typeof content.text === 'string' ? content.text : '';
1679
+ const nextOffset = typeof content.nextOffset === 'number' ? content.nextOffset : null;
1680
+ const truncated = content.truncated === true;
1681
+ const title = s(content, 'documentTitle') ?? 'Document';
1682
+ const lines = [`# ${title}`];
1683
+ const range = text.length > 0
1684
+ ? `**Characters ${(offset + 1).toLocaleString('en-US')}–${(offset + text.length).toLocaleString('en-US')} of ${total.toLocaleString('en-US')}**`
1685
+ : `**0 characters**`;
1686
+ lines.push(join([
1687
+ f('Format', s(content, 'format')),
1688
+ range,
1689
+ `**Truncated:** ${truncated}`,
1690
+ `offset: ${offset}`,
1691
+ nextOffset != null ? `next offset: ${nextOffset}` : '_end of document_',
1692
+ ]));
1693
+ const sourceUrl = s(content, 'sourceUrl');
1694
+ if (sourceUrl)
1695
+ lines.push(`**Source:** ${sourceUrl}`);
1696
+ const rest = renderDetailRest(content, DOCUMENT_CONTENT_KEYS);
1697
+ if (rest)
1698
+ lines.push('', rest);
1699
+ lines.push('', text || '_This document is empty._');
1431
1700
  return lines.join('\n');
1432
1701
  }
1702
+ const DOCUMENT_CONTENT_KEYS = new Set([
1703
+ 'documentTitle',
1704
+ 'format',
1705
+ 'sourceUrl',
1706
+ 'text',
1707
+ 'totalCharacters',
1708
+ 'offset',
1709
+ 'truncated',
1710
+ 'nextOffset',
1711
+ ]);
1712
+ /** The `content` payload, when the result carries one. */
1713
+ function documentContentOf(result) {
1714
+ const content = result.content;
1715
+ return typeof content === 'object' && content !== null && !Array.isArray(content)
1716
+ ? content
1717
+ : undefined;
1718
+ }
1433
1719
  // ── Per-Tool Format Exports ─────────────────────────────────────────
1434
1720
  function makeFormatter(detailKeys, itemRenderer, detailRenderer) {
1435
1721
  return (result) => {
@@ -1449,6 +1735,9 @@ function makeFormatter(detailKeys, itemRenderer, detailRenderer) {
1449
1735
  }
1450
1736
  /** Bill browse, detail, and sub-resources (actions, amendments, cosponsors, etc.). */
1451
1737
  export function formatBills(result) {
1738
+ const content = documentContentOf(result);
1739
+ if (content)
1740
+ return tb(renderDocumentContent(content));
1452
1741
  if (Array.isArray(result.data)) {
1453
1742
  const first = result.data[0];
1454
1743
  const firstRecord = typeof first === 'object' && first !== null ? first : undefined;
@@ -1465,7 +1754,8 @@ export function formatBills(result) {
1465
1754
  * survive into the rendered Markdown.
1466
1755
  */
1467
1756
  function renderBillSubresourceSummaryItem(item, i) {
1468
- const version = s(item, 'actionDesc') ?? s(item, 'versionCode') ?? 'Summary';
1757
+ const named = new Set(BILL_SUMMARY_SUBRESOURCE_ROW_KEYS);
1758
+ const version = pick(item, named, 'actionDesc', 'versionCode') ?? 'Summary';
1469
1759
  const actionDate = s(item, 'actionDate');
1470
1760
  const updated = s(item, 'updateDate');
1471
1761
  const lines = [`### ${i + 1}. ${version}`];
@@ -1475,8 +1765,9 @@ function renderBillSubresourceSummaryItem(item, i) {
1475
1765
  const text = typeof item.text === 'string' ? htmlToMarkdown(item.text) : '';
1476
1766
  if (text)
1477
1767
  lines.push('', text);
1478
- return lines.join('\n');
1768
+ return withRowRest(item, lines, named);
1479
1769
  }
1770
+ const BILL_SUMMARY_SUBRESOURCE_ROW_KEYS = ['actionDate', 'updateDate', 'text'];
1480
1771
  function pickBillListRenderer(first) {
1481
1772
  if ('title' in first && 'number' in first)
1482
1773
  return renderBillItem;
@@ -1522,8 +1813,19 @@ function renderSearchBillItem(item, i) {
1522
1813
  const summary = s(item, 'summaryPreview');
1523
1814
  if (summary)
1524
1815
  lines.push('', summary);
1525
- return lines.join('\n');
1816
+ return withRowRest(item, lines, SEARCH_BILL_ROW_KEYS);
1526
1817
  }
1818
+ const SEARCH_BILL_ROW_KEYS = new Set([
1819
+ 'billId',
1820
+ 'billType',
1821
+ 'billNumber',
1822
+ 'congress',
1823
+ 'title',
1824
+ 'originChamber',
1825
+ 'latestActionDate',
1826
+ 'latestActionText',
1827
+ 'summaryPreview',
1828
+ ]);
1527
1829
  /** Local bill keyword search over the FTS mirror. */
1528
1830
  export const formatSearchBills = makeFormatter([], renderSearchBillItem);
1529
1831
  /** Member browse, detail, and sponsored/cosponsored legislation. */
@@ -1578,6 +1880,9 @@ export function formatCommittees(result) {
1578
1880
  }
1579
1881
  /** Committee reports — list, detail, and text. */
1580
1882
  export function formatCommitteeReports(result) {
1883
+ const content = documentContentOf(result);
1884
+ if (content)
1885
+ return tb(renderDocumentContent(content));
1581
1886
  if (Array.isArray(result.data))
1582
1887
  return tb(renderList(result, renderCommitteeReportListItem));
1583
1888
  if (Array.isArray(result.text)) {
@@ -1600,6 +1905,9 @@ export function formatCrsReports(result) {
1600
1905
  }
1601
1906
  /** Daily Congressional Record. Dispatches between volumes/issues and flattened articles. */
1602
1907
  export function formatDailyRecord(result) {
1908
+ const content = documentContentOf(result);
1909
+ if (content)
1910
+ return tb(renderDocumentContent(content));
1603
1911
  if (Array.isArray(result.data)) {
1604
1912
  const first = result.data[0];
1605
1913
  const firstRecord = typeof first === 'object' && first !== null ? first : undefined;