@cyanheads/congressgov-mcp-server 0.4.2 → 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 (55) 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/definitions/enacted-laws.tool.d.ts +1 -1
  23. package/dist/mcp-server/tools/definitions/enacted-laws.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/enacted-laws.tool.js +7 -5
  25. package/dist/mcp-server/tools/definitions/enacted-laws.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/format-helpers.d.ts +16 -0
  27. package/dist/mcp-server/tools/format-helpers.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/format-helpers.js +597 -130
  29. package/dist/mcp-server/tools/format-helpers.js.map +1 -1
  30. package/dist/mcp-server/tools/tool-helpers.d.ts +88 -0
  31. package/dist/mcp-server/tools/tool-helpers.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/tool-helpers.js +133 -0
  33. package/dist/mcp-server/tools/tool-helpers.js.map +1 -1
  34. package/dist/services/congress-api/congress-api-service.d.ts +6 -0
  35. package/dist/services/congress-api/congress-api-service.d.ts.map +1 -1
  36. package/dist/services/congress-api/congress-api-service.js +41 -6
  37. package/dist/services/congress-api/congress-api-service.js.map +1 -1
  38. package/dist/services/congress-documents/congress-documents-service.d.ts +71 -0
  39. package/dist/services/congress-documents/congress-documents-service.d.ts.map +1 -0
  40. package/dist/services/congress-documents/congress-documents-service.js +271 -0
  41. package/dist/services/congress-documents/congress-documents-service.js.map +1 -0
  42. package/dist/services/congress-documents/document-formats.d.ts +29 -0
  43. package/dist/services/congress-documents/document-formats.d.ts.map +1 -0
  44. package/dist/services/congress-documents/document-formats.js +51 -0
  45. package/dist/services/congress-documents/document-formats.js.map +1 -0
  46. package/dist/services/congress-documents/extract-text.d.ts +21 -0
  47. package/dist/services/congress-documents/extract-text.d.ts.map +1 -0
  48. package/dist/services/congress-documents/extract-text.js +81 -0
  49. package/dist/services/congress-documents/extract-text.js.map +1 -0
  50. package/dist/services/congress-documents/types.d.ts +45 -0
  51. package/dist/services/congress-documents/types.d.ts.map +1 -0
  52. package/dist/services/congress-documents/types.js +6 -0
  53. package/dist/services/congress-documents/types.js.map +1 -0
  54. package/package.json +1 -1
  55. package/server.json +3 -3
@@ -6,6 +6,22 @@
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 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
18
+ * `{count, url}` sub-resource reference, rendered as "N available": the URL is
19
+ * the upstream REST endpoint, and the tool's own operation is the callable path
20
+ * to those rows. Caps belong to pagination, which has a continuation path
21
+ * (`pagination.nextOffset`, surfaced in every list header) — a nested collection
22
+ * inside one record has none, so slicing it would drop data no follow-up call
23
+ * could recover.
24
+ *
9
25
  * See: https://github.com/cyanheads/mcp-ts-core/issues/19
10
26
  *
11
27
  * @module mcp-server/tools/format-helpers
@@ -41,20 +57,40 @@ function stripHtml(html, { inline = false } = {}) {
41
57
  .replace(/\n{3,}/g, '\n\n')
42
58
  .trim();
43
59
  }
60
+ /**
61
+ * Wrap an emphasis span's content in Markdown markers with the span's own
62
+ * boundary whitespace kept *outside* them.
63
+ *
64
+ * Congress.gov summaries routinely place a space inside the tag
65
+ * (`<em>de minimis </em>treatment`, `an<em> de minimis</em> threshold`).
66
+ * Substituting the marker in place produces `*de minimis *treatment` — markers
67
+ * that no Markdown parser binds as emphasis, and altered visible spacing either
68
+ * way. A span with no non-whitespace content gets no markers at all.
69
+ */
70
+ function wrapEmphasis(inner, marker) {
71
+ const core = inner.trim();
72
+ if (!core)
73
+ return inner;
74
+ const lead = inner.slice(0, inner.length - inner.trimStart().length);
75
+ const trail = inner.slice(inner.trimEnd().length);
76
+ return `${lead}${marker}${core}${marker}${trail}`;
77
+ }
44
78
  /**
45
79
  * Convert upstream HTML (Congress.gov bill summaries are returned with `<p>`,
46
80
  * `<strong>`, `<em>`, anchor tags) into readable Markdown that preserves
47
81
  * paragraph and emphasis structure.
82
+ *
83
+ * Emphasis is matched as a whole span rather than tag-by-tag so boundary
84
+ * whitespace can be normalized (see `wrapEmphasis`) and an unclosed tag falls
85
+ * through to the generic strip instead of leaving a stray marker.
48
86
  */
49
87
  function htmlToMarkdown(html) {
50
88
  return html
51
89
  .replace(/<\s*br\s*\/?\s*>/gi, '\n')
52
90
  .replace(/<\s*\/p\s*>/gi, '\n\n')
53
91
  .replace(/<\s*p[^>]*>/gi, '')
54
- .replace(/<\s*(strong|b)\s*>/gi, '**')
55
- .replace(/<\s*\/\s*(strong|b)\s*>/gi, '**')
56
- .replace(/<\s*(em|i)\s*>/gi, '*')
57
- .replace(/<\s*\/\s*(em|i)\s*>/gi, '*')
92
+ .replace(/<\s*(?:strong|b)\s*>([\s\S]*?)<\s*\/\s*(?:strong|b)\s*>/gi, (_m, inner) => wrapEmphasis(inner, '**'))
93
+ .replace(/<\s*(?:em|i)\s*>([\s\S]*?)<\s*\/\s*(?:em|i)\s*>/gi, (_m, inner) => wrapEmphasis(inner, '*'))
58
94
  .replace(/<\s*a[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\s*\/\s*a\s*>/gi, '[$2]($1)')
59
95
  .replace(/<[^>]*>/g, '')
60
96
  .replace(/&nbsp;/g, ' ')
@@ -167,7 +203,7 @@ function renderDetail(obj) {
167
203
  if (val.length === 0)
168
204
  continue;
169
205
  lines.push(`\n**${key}** (${val.length}):`);
170
- for (const item of val.slice(0, 20)) {
206
+ for (const item of val) {
171
207
  if (typeof item === 'object' && item) {
172
208
  lines.push(`- ${renderInline(item)}`);
173
209
  }
@@ -175,8 +211,6 @@ function renderDetail(obj) {
175
211
  lines.push(`- ${String(item)}`);
176
212
  }
177
213
  }
178
- if (val.length > 20)
179
- lines.push(`- _...${val.length - 20} more_`);
180
214
  }
181
215
  else if (typeof val === 'object') {
182
216
  const nested = val;
@@ -188,11 +222,10 @@ function renderDetail(obj) {
188
222
  lines.push(`**${key}:** ${count} available`);
189
223
  continue;
190
224
  }
191
- // latestAction: { actionDate, text }
192
225
  if (key === 'latestAction') {
193
- const date = s(nested, 'actionDate');
194
- const text = s(nested, 'text');
195
- lines.push(`**Latest Action:** ${[date, text].filter(Boolean).join(' — ')}`);
226
+ const line = latestActionLine(record);
227
+ if (line)
228
+ lines.push(line);
196
229
  continue;
197
230
  }
198
231
  // Small objects inline, larger ones nested
@@ -218,22 +251,28 @@ function renderDetail(obj) {
218
251
  }
219
252
  /** Render any list item with all its fields. */
220
253
  function renderGenericItem(item, index) {
221
- // Build heading from common identifier fields
222
- const id = item.type && item.number != null
223
- ? `${String(item.type).toUpperCase()} ${item.number}`
224
- : (s(item, 'citation') ?? s(item, 'bioguideId') ?? s(item, 'systemCode'));
225
- const name = s(item, 'title') ??
226
- s(item, 'name') ??
227
- s(item, 'fullName') ??
228
- s(item, 'directOrderName') ??
229
- s(item, 'description') ??
230
- 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');
231
270
  const heading = [id, name].filter(Boolean).join(': ') || 'Item';
232
271
  const lines = [`### ${index + 1}. ${heading}`];
233
272
  for (const [key, val] of Object.entries(item)) {
234
273
  if (val == null || val === '')
235
274
  continue;
236
- if (HEADING_FIELDS.has(key))
275
+ if (named.has(key))
237
276
  continue;
238
277
  if (typeof val === 'string') {
239
278
  const inline = stripHtml(val, { inline: true });
@@ -249,10 +288,9 @@ function renderGenericItem(item, index) {
249
288
  lines.push(`**${key}:** ${val}`);
250
289
  }
251
290
  else if (typeof val === 'object' && !Array.isArray(val) && val !== null) {
252
- if (key === 'latestAction') {
253
- const date = s(val, 'actionDate');
254
- const text = s(val, 'text');
255
- lines.push(`**Latest Action:** ${[date, text].filter(Boolean).join(' — ')}`);
291
+ const action = key === 'latestAction' ? latestActionLine(item) : undefined;
292
+ if (action) {
293
+ lines.push(action);
256
294
  }
257
295
  else {
258
296
  lines.push(`**${key}:** ${renderInline(val)}`);
@@ -264,50 +302,133 @@ function renderGenericItem(item, index) {
264
302
  }
265
303
  else {
266
304
  lines.push(`**${key}:** ${val.length} items`);
267
- for (const sub of val.slice(0, 5)) {
305
+ for (const sub of val) {
268
306
  if (typeof sub === 'object' && sub !== null)
269
307
  lines.push(` - ${renderInline(sub)}`);
270
308
  }
271
- if (val.length > 5)
272
- lines.push(` - _...${val.length - 5} more_`);
273
309
  }
274
310
  }
275
311
  }
276
312
  return lines.join('\n');
277
313
  }
278
- const HEADING_FIELDS = new Set([
279
- 'type',
280
- 'number',
281
- 'citation',
282
- 'bioguideId',
283
- 'systemCode',
284
- 'title',
285
- 'name',
286
- 'fullName',
287
- 'directOrderName',
288
- 'description',
289
- 'question',
290
- ]);
291
- /** Compact inline render of a small object. */
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
+ }
333
+ /**
334
+ * Compact one-line render of a small object. Compact but never lossy: values are
335
+ * carried in full and nested objects/arrays recurse, so no field or character is
336
+ * dropped at any depth.
337
+ */
292
338
  function renderInline(obj) {
339
+ return inlineFields(obj) || JSON.stringify(obj);
340
+ }
341
+ /** `key: value` pairs for one object level, recursing into nested values. */
342
+ function inlineFields(obj) {
293
343
  const parts = [];
294
344
  for (const [key, val] of Object.entries(obj)) {
295
345
  if (val == null || val === '')
296
346
  continue;
297
347
  if (typeof val === 'string') {
298
- const cleaned = stripHtml(val, { inline: true });
299
- const preview = cleaned.length > 120 ? `${cleaned.slice(0, 117)}...` : cleaned;
300
- parts.push(`${key}: ${preview}`);
348
+ parts.push(`${key}: ${stripHtml(val, { inline: true })}`);
301
349
  }
302
- else if (typeof val === 'number' || typeof val === 'boolean')
350
+ else if (typeof val === 'number' || typeof val === 'boolean') {
303
351
  parts.push(`${key}: ${val}`);
352
+ }
353
+ else if (Array.isArray(val)) {
354
+ if (val.length === 0)
355
+ continue;
356
+ const items = val.map((item) => typeof item === 'object' && item
357
+ ? `{${inlineFields(item)}}`
358
+ : String(item));
359
+ parts.push(`${key}: [${items.join('; ')}]`);
360
+ }
361
+ else if (typeof val === 'object') {
362
+ const nested = inlineFields(val);
363
+ if (nested)
364
+ parts.push(`${key}: {${nested}}`);
365
+ }
366
+ }
367
+ return parts.join(', ');
368
+ }
369
+ /**
370
+ * A curated one-line render of a collection row, with any of the row's own
371
+ * fields the curation never named appended.
372
+ *
373
+ * The `renderDetailRest` fall-through only reaches a record's top level: a
374
+ * collection the header renders itself is skipped there, so a field its curated
375
+ * line omits would otherwise leave `content[]` while staying in
376
+ * `structuredContent` — the same loss for a term's `district` or a nominee's
377
+ * `url` that a dropped top-level field used to be.
378
+ */
379
+ function withUnnamedFields(row, curated, named) {
380
+ const rest = {};
381
+ for (const [k, v] of Object.entries(row))
382
+ if (!named.has(k))
383
+ rest[k] = v;
384
+ const unnamed = inlineFields(rest);
385
+ return unnamed ? `${curated} — ${unnamed}` : curated;
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
+ }
304
413
  }
305
- if (parts.length > 0)
306
- return parts.join(', ');
307
- const json = JSON.stringify(obj);
308
- return json.length > 200 ? `${json.slice(0, 197)}...` : json;
414
+ return;
309
415
  }
310
416
  // ── Domain-Specific Item Renderers ──────────────────────────────────
417
+ /**
418
+ * Public/private law citations from a bill or law record's `laws[]`, e.g.
419
+ * "Public Law 118-90". Upstream `/law` rows mirror `/bill`, so the citation is
420
+ * the only field distinguishing an enacted law from the bill it came from — it
421
+ * has to reach both the list rows and the detail view.
422
+ */
423
+ function lawCitations(item) {
424
+ const laws = item.laws;
425
+ if (!Array.isArray(laws) || laws.length === 0)
426
+ return;
427
+ const cites = laws
428
+ .map((law) => join([s(law, 'type'), s(law, 'number')], ' '))
429
+ .filter(Boolean);
430
+ return cites.length > 0 ? cites.join(', ') : undefined;
431
+ }
311
432
  function renderBillItem(item, i) {
312
433
  const type = s(item, 'type')?.toUpperCase() ?? '';
313
434
  const number = s(item, 'number') ?? '';
@@ -322,6 +443,9 @@ function renderBillItem(item, i) {
322
443
  ]);
323
444
  if (meta)
324
445
  lines.push(meta);
446
+ const citation = lawCitations(item);
447
+ if (citation)
448
+ lines.push(`**Law:** ${citation}`);
325
449
  if (Array.isArray(item.sponsors) && item.sponsors.length > 0) {
326
450
  const sponsors = item.sponsors.map((sp) => {
327
451
  const name = s(sp, 'fullName') ?? s(sp, 'firstName') ?? '?';
@@ -331,17 +455,30 @@ function renderBillItem(item, i) {
331
455
  });
332
456
  lines.push(`**Sponsor:** ${sponsors.join(', ')}`);
333
457
  }
334
- const actionDate = s(item, 'latestAction', 'actionDate');
335
- const actionText = s(item, 'latestAction', 'text');
336
- if (actionDate || actionText)
337
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
458
+ const latestAction = latestActionLine(item);
459
+ if (latestAction)
460
+ lines.push(latestAction);
338
461
  const updated = s(item, 'updateDate');
339
462
  if (updated)
340
463
  lines.push(`**Updated:** ${updated}`);
341
464
  if (url)
342
465
  lines.push(`**URL:** ${url}`);
343
- return lines.join('\n');
466
+ return withRowRest(item, lines, BILL_ROW_KEYS);
344
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
+ ]);
345
482
  function renderMemberItem(item, i) {
346
483
  const name = s(item, 'name') ?? s(item, 'directOrderName') ?? s(item, 'fullName') ?? 'Unknown Member';
347
484
  const url = s(item, 'url');
@@ -374,15 +511,28 @@ function renderMemberItem(item, i) {
374
511
  }
375
512
  if (url)
376
513
  lines.push(`**URL:** ${url}`);
377
- return lines.join('\n');
514
+ return withRowRest(item, lines, MEMBER_ROW_KEYS);
378
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
+ ]);
379
528
  function renderSummaryItem(item, i) {
529
+ const named = new Set(SUMMARY_ROW_KEYS);
380
530
  const billType = s(item, 'bill', 'type')?.toUpperCase() ?? '';
381
531
  const billNum = s(item, 'bill', 'number') ?? '';
382
532
  const congress = s(item, 'bill', 'congress') ?? '';
383
- const version = s(item, 'actionDesc') ?? s(item, 'versionCode') ?? '';
533
+ const version = pick(item, named, 'actionDesc', 'versionCode') ?? '';
384
534
  const actionDate = s(item, 'actionDate') ?? '';
385
- const summaryUpdate = s(item, 'lastSummaryUpdateDate') ?? s(item, 'updateDate') ?? '';
535
+ const summaryUpdate = pick(item, named, 'lastSummaryUpdateDate', 'updateDate') ?? '';
386
536
  const rawText = typeof item.text === 'string' ? item.text : '';
387
537
  const text = rawText ? htmlToMarkdown(rawText) : '';
388
538
  const url = s(item, 'url') ?? s(item, 'bill', 'url');
@@ -397,19 +547,28 @@ function renderSummaryItem(item, i) {
397
547
  if (meta)
398
548
  lines.push(meta);
399
549
  const billTitle = s(item, 'bill', 'title');
400
- 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);
401
555
  // The summary text is the critical data — the whole point of this tool
402
556
  lines.push('');
403
557
  lines.push(text || '_Summary text not available._');
404
558
  if (url)
405
559
  lines.push(`\n**URL:** ${url}`);
406
- return lines.join('\n');
560
+ return withRowRest(item, lines, named);
407
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']);
408
566
  function renderCrsReportItem(item, i) {
567
+ const named = new Set(CRS_REPORT_ROW_KEYS);
409
568
  const reportNumber = s(item, 'reportNumber') ?? s(item, 'number') ?? s(item, 'id') ?? 'Report number not available';
410
569
  const title = s(item, 'title') ?? 'Title not available';
411
- const updated = s(item, 'updateDate') ?? s(item, 'publishDate') ?? s(item, 'date') ?? '';
412
- 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') ?? '';
413
572
  const contentType = s(item, 'contentType');
414
573
  const status = s(item, 'status');
415
574
  const version = s(item, 'version');
@@ -427,8 +586,23 @@ function renderCrsReportItem(item, i) {
427
586
  lines.push(summary);
428
587
  if (url)
429
588
  lines.push(`**URL:** ${url}`);
430
- return lines.join('\n');
589
+ return withRowRest(item, lines, named);
431
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
+ ];
432
606
  /** Daily Congressional Record articles — flattened from section-wrapped shape. */
433
607
  function renderDailyArticleItem(item, i) {
434
608
  const title = s(item, 'title') ?? 'Untitled article';
@@ -449,8 +623,9 @@ function renderDailyArticleItem(item, i) {
449
623
  lines.push(`**${type}:** ${url}`);
450
624
  }
451
625
  }
452
- return lines.join('\n');
626
+ return withRowRest(item, lines, DAILY_ARTICLE_ROW_KEYS);
453
627
  }
628
+ const DAILY_ARTICLE_ROW_KEYS = new Set(['title', 'sectionName', 'startPage', 'endPage', 'text']);
454
629
  /** Daily Congressional Record volumes and issues. */
455
630
  function renderDailyRecordItem(item, i) {
456
631
  const volume = s(item, 'volumeNumber');
@@ -473,8 +648,17 @@ function renderDailyRecordItem(item, i) {
473
648
  lines.push(meta);
474
649
  if (url)
475
650
  lines.push(`**URL:** ${url}`);
476
- return lines.join('\n');
651
+ return withRowRest(item, lines, DAILY_RECORD_ROW_KEYS);
477
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
+ ]);
478
662
  /** House roll call votes. */
479
663
  function renderRollVoteItem(item, i) {
480
664
  const roll = s(item, 'rollCallNumber');
@@ -511,8 +695,23 @@ function renderRollVoteItem(item, i) {
511
695
  lines.push(`**URL:** ${url}`);
512
696
  if (sourceUrl)
513
697
  lines.push(`**Source Data URL:** ${sourceUrl}`);
514
- return lines.join('\n');
698
+ return withRowRest(item, lines, ROLL_VOTE_ROW_KEYS);
515
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
+ ]);
516
715
  /** Bill legislative actions. */
517
716
  function renderBillActionItem(item, i) {
518
717
  const actionDate = s(item, 'actionDate');
@@ -531,8 +730,10 @@ function renderBillActionItem(item, i) {
531
730
  if (names.length > 0)
532
731
  lines.push(`**Committees:** ${names.join(', ')}`);
533
732
  }
534
- return lines.join('\n');
733
+ return withRowRest(item, lines, BILL_ACTION_ROW_KEYS);
535
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']);
536
737
  /** Committee report text — items wrap a `formats` array of {type, url, isErrata}. */
537
738
  function renderCommitteeReportTextItem(item, i) {
538
739
  const formats = item.formats;
@@ -554,8 +755,9 @@ function renderCommitteeReportTextItem(item, i) {
554
755
  const label = e.isErrata ? `${e.type} (Errata)` : e.type;
555
756
  lines.push(`**${label}:** ${e.url}`);
556
757
  }
557
- return lines.join('\n');
758
+ return withRowRest(item, lines, COMMITTEE_REPORT_TEXT_ROW_KEYS);
558
759
  }
760
+ const COMMITTEE_REPORT_TEXT_ROW_KEYS = new Set(['formats']);
559
761
  /** Member-sponsored amendments — `type`/`title` are null upstream; identify by `amendmentNumber`. */
560
762
  function renderAmendmentItem(item, i) {
561
763
  const number = s(item, 'amendmentNumber');
@@ -576,14 +778,20 @@ function renderAmendmentItem(item, i) {
576
778
  ]);
577
779
  if (meta)
578
780
  lines.push(meta);
579
- const actionDate = s(item, 'latestAction', 'actionDate');
580
- const actionText = s(item, 'latestAction', 'text');
581
- if (actionDate || actionText)
582
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
781
+ const latestAction = latestActionLine(item);
782
+ if (latestAction)
783
+ lines.push(latestAction);
583
784
  if (url)
584
785
  lines.push(`**URL:** ${url}`);
585
- return lines.join('\n');
786
+ return withRowRest(item, lines, AMENDMENT_ROW_KEYS);
586
787
  }
788
+ const AMENDMENT_ROW_KEYS = new Set([
789
+ 'amendmentNumber',
790
+ 'congress',
791
+ 'introducedDate',
792
+ 'latestAction',
793
+ 'url',
794
+ ]);
587
795
  /** Bill text versions — heading from `type` (e.g. "Enrolled Bill"), formats[] as labeled URLs. */
588
796
  function renderBillTextItem(item, i) {
589
797
  const type = s(item, 'type') ?? 'Bill Text';
@@ -600,8 +808,9 @@ function renderBillTextItem(item, i) {
600
808
  lines.push(`**${fType}:** ${fUrl}`);
601
809
  }
602
810
  }
603
- return lines.join('\n');
811
+ return withRowRest(item, lines, BILL_TEXT_ROW_KEYS);
604
812
  }
813
+ const BILL_TEXT_ROW_KEYS = new Set(['type', 'date', 'formats']);
605
814
  /** Nomination type wrapper: `{isCivilian: true}` / `{isMilitary: true}` → readable label. */
606
815
  function nominationTypeLabel(raw) {
607
816
  if (!raw || typeof raw !== 'object')
@@ -641,15 +850,27 @@ function renderNominationListItem(item, i) {
641
850
  ]);
642
851
  if (meta)
643
852
  lines.push(meta);
644
- const actionDate = s(item, 'latestAction', 'actionDate');
645
- const actionText = s(item, 'latestAction', 'text');
646
- if (actionDate || actionText)
647
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
853
+ const latestAction = latestActionLine(item);
854
+ if (latestAction)
855
+ lines.push(latestAction);
648
856
  const url = s(item, 'url');
649
857
  if (url)
650
858
  lines.push(`**URL:** ${url}`);
651
- return lines.join('\n');
859
+ return withRowRest(item, lines, NOMINATION_ROW_KEYS);
652
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
+ ]);
653
874
  /** Nomination committee items — shape {name, systemCode, chamber, type, activities[], url}. */
654
875
  function renderNominationCommitteeItem(item, i) {
655
876
  const name = s(item, 'name') ?? s(item, 'systemCode') ?? 'Committee';
@@ -673,8 +894,10 @@ function renderNominationCommitteeItem(item, i) {
673
894
  const url = s(item, 'url');
674
895
  if (url)
675
896
  lines.push(`**URL:** ${url}`);
676
- return lines.join('\n');
897
+ return withRowRest(item, lines, NOMINATION_COMMITTEE_ROW_KEYS);
677
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']);
678
901
  /** Individual nominee items — shape {firstName, middleName, lastName, ordinal, state, prefix?, suffix?}. */
679
902
  function renderNomineeItem(item, i) {
680
903
  const prefix = s(item, 'prefix');
@@ -688,8 +911,17 @@ function renderNomineeItem(item, i) {
688
911
  const meta = join([f('Ordinal', s(item, 'ordinal')), f('State', s(item, 'state'))]);
689
912
  if (meta)
690
913
  lines.push(meta);
691
- return lines.join('\n');
914
+ return withRowRest(item, lines, NOMINEE_ITEM_KEYS);
692
915
  }
916
+ const NOMINEE_ITEM_KEYS = new Set([
917
+ 'prefix',
918
+ 'firstName',
919
+ 'middleName',
920
+ 'lastName',
921
+ 'suffix',
922
+ 'ordinal',
923
+ 'state',
924
+ ]);
693
925
  /** Nomination hearing items — shape {chamber, citation, date, jacketNumber, number, partNumber, errata?}. */
694
926
  function renderNominationHearingItem(item, i) {
695
927
  const citation = s(item, 'citation');
@@ -699,7 +931,7 @@ function renderNominationHearingItem(item, i) {
699
931
  const partNumber = s(item, 'partNumber');
700
932
  const meta = join([
701
933
  f('Chamber', s(item, 'chamber')),
702
- f('Date', s(item, 'date')?.slice(0, 10)),
934
+ f('Date', s(item, 'date')),
703
935
  f('Number', number),
704
936
  partNumber && partNumber !== '1' && partNumber !== '01' ? f('Part', partNumber) : undefined,
705
937
  f('Jacket', s(item, 'jacketNumber')),
@@ -707,8 +939,17 @@ function renderNominationHearingItem(item, i) {
707
939
  ]);
708
940
  if (meta)
709
941
  lines.push(meta);
710
- return lines.join('\n');
942
+ return withRowRest(item, lines, NOMINATION_HEARING_ROW_KEYS);
711
943
  }
944
+ const NOMINATION_HEARING_ROW_KEYS = new Set([
945
+ 'citation',
946
+ 'number',
947
+ 'chamber',
948
+ 'date',
949
+ 'partNumber',
950
+ 'jacketNumber',
951
+ 'errata',
952
+ ]);
712
953
  /** Dispatch nomination list rows to the right renderer by shape signal. */
713
954
  function pickNominationListRenderer(first) {
714
955
  /** Action rows share the bill-action shape (actionDate, text, type, actionCode);
@@ -744,10 +985,9 @@ function renderNominationDetail(item) {
744
985
  ]);
745
986
  if (meta)
746
987
  lines.push(meta);
747
- const actionDate = s(item, 'latestAction', 'actionDate');
748
- const actionText = s(item, 'latestAction', 'text');
749
- if (actionDate || actionText)
750
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
988
+ const latestAction = latestActionLine(item);
989
+ if (latestAction)
990
+ lines.push(latestAction);
751
991
  const subResources = [];
752
992
  for (const key of ['actions', 'committees', 'hearings']) {
753
993
  const sub = item[key];
@@ -759,7 +999,7 @@ function renderNominationDetail(item) {
759
999
  const nominees = item.nominees;
760
1000
  if (Array.isArray(nominees) && nominees.length > 0) {
761
1001
  lines.push(`\n**Nominees (${nominees.length}):**`);
762
- for (const n of nominees.slice(0, 20)) {
1002
+ for (const n of nominees) {
763
1003
  const ord = s(n, 'ordinal');
764
1004
  const count = s(n, 'nomineeCount');
765
1005
  const org = s(n, 'organization');
@@ -770,10 +1010,8 @@ function renderNominationDetail(item) {
770
1010
  org,
771
1011
  title,
772
1012
  ].filter(Boolean);
773
- lines.push(`- ${parts.join(' — ')}`);
1013
+ lines.push(`- ${withUnnamedFields(n, parts.join(' — '), NOMINEE_ROW_KEYS)}`);
774
1014
  }
775
- if (nominees.length > 20)
776
- lines.push(`- _...${nominees.length - 20} more_`);
777
1015
  }
778
1016
  else if (s(item, 'partNumber') === '00') {
779
1017
  /** Parent nominations (partNumber=00) have no nominees array. Sub-resources
@@ -783,8 +1021,29 @@ function renderNominationDetail(item) {
783
1021
  const url = s(item, 'url');
784
1022
  if (url)
785
1023
  lines.push(`\n**URL:** ${url}`);
1024
+ const rest = renderDetailRest(item, HEADER_NOMINATION_KEYS);
1025
+ if (rest)
1026
+ lines.push('', rest);
786
1027
  return lines.join('\n');
787
1028
  }
1029
+ const NOMINEE_ROW_KEYS = new Set(['ordinal', 'nomineeCount', 'organization', 'positionTitle']);
1030
+ const HEADER_NOMINATION_KEYS = new Set([
1031
+ 'citation',
1032
+ 'number',
1033
+ 'partNumber',
1034
+ 'nominationType',
1035
+ 'description',
1036
+ 'congress',
1037
+ 'receivedDate',
1038
+ 'authorityDate',
1039
+ 'updateDate',
1040
+ 'latestAction',
1041
+ 'actions',
1042
+ 'committees',
1043
+ 'hearings',
1044
+ 'nominees',
1045
+ 'url',
1046
+ ]);
788
1047
  /** Roll call vote detail — question, result, date, party totals. */
789
1048
  function renderRollVoteDetail(item) {
790
1049
  const roll = s(item, 'rollCallNumber');
@@ -826,13 +1085,30 @@ function renderRollVoteDetail(item) {
826
1085
  }
827
1086
  if (sourceUrl)
828
1087
  lines.push(`\n**Source Data URL:** ${sourceUrl}`);
1088
+ const rest = renderDetailRest(item, HEADER_ROLL_VOTE_KEYS);
1089
+ if (rest)
1090
+ lines.push('', rest);
829
1091
  return lines.join('\n');
830
1092
  }
1093
+ const HEADER_ROLL_VOTE_KEYS = new Set([
1094
+ 'rollCallNumber',
1095
+ 'congress',
1096
+ 'sessionNumber',
1097
+ 'result',
1098
+ 'voteQuestion',
1099
+ 'voteType',
1100
+ 'startDate',
1101
+ 'updateDate',
1102
+ 'identifier',
1103
+ 'sourceDataURL',
1104
+ 'votePartyTotal',
1105
+ ]);
831
1106
  /** One member's position: "Warren Davidson (R-OH) → Nay". */
832
1107
  function renderMemberVoteRow(r) {
1108
+ const named = new Set(MEMBER_VOTE_ROW_KEYS);
833
1109
  const first = s(r, 'firstName');
834
1110
  const last = s(r, 'lastName');
835
- const name = first && last ? `${first} ${last}` : (last ?? first ?? s(r, 'bioguideId') ?? '?');
1111
+ const name = (first && last ? `${first} ${last}` : (last ?? first)) ?? pick(r, named, 'bioguideId') ?? '?';
836
1112
  const party = s(r, 'voteParty');
837
1113
  const state = s(r, 'voteState');
838
1114
  const cast = s(r, 'voteCast');
@@ -843,8 +1119,11 @@ function renderMemberVoteRow(r) {
843
1119
  : state
844
1120
  ? `(${state})`
845
1121
  : undefined;
846
- 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);
847
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'];
848
1127
  /**
849
1128
  * Member voting positions for one roll call — vote-context header, the
850
1129
  * `pagination`-derived range footer, then the paginated roster from `data[]`.
@@ -870,6 +1149,9 @@ function renderVoteMembers(result) {
870
1149
  const legNum = s(vote, 'legislationNumber');
871
1150
  if (legType && legNum)
872
1151
  lines.push(`**Legislation:** ${legType} ${legNum}`);
1152
+ const voteRest = renderDetailRest(vote, VOTE_CONTEXT_KEYS);
1153
+ if (voteRest)
1154
+ lines.push('', voteRest);
873
1155
  if (rows.length === 0) {
874
1156
  lines.push('', total > 0
875
1157
  ? `_Page is empty — offset is past the end of ${total} member position${total !== 1 ? 's' : ''}._`
@@ -887,6 +1169,16 @@ function renderVoteMembers(result) {
887
1169
  }
888
1170
  return lines.join('\n');
889
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
+ ]);
890
1182
  // ── Senate roll call votes (LIS feed) ───────────────────────────────
891
1183
  /**
892
1184
  * Senate vote payloads come from the LIS XML feed, not the Congress.gov JSON API,
@@ -916,17 +1208,34 @@ function renderSenateVoteItem(item, i) {
916
1208
  const title = s(item, 'title');
917
1209
  if (title)
918
1210
  lines.push(title);
919
- return lines.join('\n');
1211
+ return withRowRest(item, lines, SENATE_VOTE_ROW_KEYS);
920
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
+ ]);
921
1226
  /** One Senate member's position — uses the feed's pre-formatted "Baldwin (D-WI)" label. */
922
1227
  function renderSenateMemberRow(r) {
923
1228
  const cast = s(r, 'voteCast');
924
1229
  const full = s(r, 'memberFull');
925
- if (full)
926
- 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']);
927
1236
  const first = s(r, 'firstName');
928
1237
  const last = s(r, 'lastName');
929
- const name = first && last ? `${first} ${last}` : (last ?? first ?? s(r, 'lisMemberId') ?? '?');
1238
+ const name = (first && last ? `${first} ${last}` : (last ?? first)) ?? pick(r, named, 'lisMemberId') ?? '?';
930
1239
  const party = s(r, 'party');
931
1240
  const state = s(r, 'state');
932
1241
  const loc = party && state
@@ -936,8 +1245,10 @@ function renderSenateMemberRow(r) {
936
1245
  : state
937
1246
  ? `(${state})`
938
1247
  : undefined;
939
- 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);
940
1250
  }
1251
+ const SENATE_MEMBER_ROW_KEYS = ['chamber', 'voteCast'];
941
1252
  /** Senate vote detail — question, result, tally, derived party totals, document/amendment. */
942
1253
  function renderSenateVoteDetail(item) {
943
1254
  const num = s(item, 'voteNumber');
@@ -988,8 +1299,11 @@ function renderSenateVoteDetail(item) {
988
1299
  if (amd) {
989
1300
  const amdNum = s(amd, 'number');
990
1301
  const to = s(amd, 'toDocumentNumber');
1302
+ const toTitle = s(amd, 'toDocumentShortTitle');
991
1303
  amendmentPurpose = s(amd, 'purpose');
992
- const head = [amdNum, to ? `to ${to}` : undefined].filter(Boolean).join(' ');
1304
+ const head = [amdNum, to ? `to ${to}` : undefined, toTitle ? `— ${toTitle}` : undefined]
1305
+ .filter(Boolean)
1306
+ .join(' ');
993
1307
  if (head)
994
1308
  lines.push(`\n**Amendment:** ${head}`);
995
1309
  if (amendmentPurpose)
@@ -1000,8 +1314,34 @@ function renderSenateVoteDetail(item) {
1000
1314
  const docText = s(item, 'voteDocumentText');
1001
1315
  if (docText && docText !== amendmentPurpose && docText !== docTitle)
1002
1316
  lines.push(`\n${docText}`);
1317
+ const rest = renderDetailRest(item, HEADER_SENATE_VOTE_KEYS);
1318
+ if (rest)
1319
+ lines.push('', rest);
1003
1320
  return lines.join('\n');
1004
1321
  }
1322
+ /**
1323
+ * `chamber` is implicit in the "Senate Vote" heading; `voteResult` / `question`
1324
+ * are the short forms of `voteResultText` / `voteQuestionText`, one of which the
1325
+ * header always renders.
1326
+ */
1327
+ const HEADER_SENATE_VOTE_KEYS = new Set([
1328
+ 'chamber',
1329
+ 'voteNumber',
1330
+ 'voteResult',
1331
+ 'voteResultText',
1332
+ 'question',
1333
+ 'voteQuestionText',
1334
+ 'voteTitle',
1335
+ 'congress',
1336
+ 'session',
1337
+ 'voteDate',
1338
+ 'majorityRequirement',
1339
+ 'count',
1340
+ 'partyTotals',
1341
+ 'document',
1342
+ 'amendment',
1343
+ 'voteDocumentText',
1344
+ ]);
1005
1345
  /** Senate member voting positions for one roll call — mirrors the House members view. */
1006
1346
  function renderSenateVoteMembers(result) {
1007
1347
  const vote = (result.vote ?? {});
@@ -1025,6 +1365,9 @@ function renderSenateVoteMembers(result) {
1025
1365
  ], ' — ');
1026
1366
  if (context)
1027
1367
  lines.push(context);
1368
+ const voteRest = renderDetailRest(vote, SENATE_VOTE_CONTEXT_KEYS);
1369
+ if (voteRest)
1370
+ lines.push('', voteRest);
1028
1371
  if (rows.length === 0) {
1029
1372
  lines.push('', total > 0
1030
1373
  ? `_Page is empty — offset is past the end of ${total} member position${total !== 1 ? 's' : ''}._`
@@ -1040,6 +1383,17 @@ function renderSenateVoteMembers(result) {
1040
1383
  }
1041
1384
  return lines.join('\n');
1042
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
+ ]);
1043
1397
  /** Senate payloads carry a `chamber: 'senate'` marker on the envelope, vote, and items. */
1044
1398
  function isSenateResult(result) {
1045
1399
  if (result.chamber === 'senate')
@@ -1068,22 +1422,12 @@ function renderBillDetail(item) {
1068
1422
  ]);
1069
1423
  if (meta)
1070
1424
  lines.push(meta);
1071
- const actionDate = s(item, 'latestAction', 'actionDate');
1072
- const actionText = s(item, 'latestAction', 'text');
1073
- if (actionDate || actionText)
1074
- lines.push(`**Latest Action:** ${[actionDate, actionText].filter(Boolean).join(' — ')}`);
1075
- const laws = item.laws;
1076
- if (Array.isArray(laws) && laws.length > 0) {
1077
- const cites = laws
1078
- .map((law) => {
1079
- const num = s(law, 'number');
1080
- const lawType = s(law, 'type');
1081
- return num && lawType ? `${lawType} ${num}` : (num ?? lawType);
1082
- })
1083
- .filter(Boolean);
1084
- if (cites.length)
1085
- lines.push(`**Law:** ${cites.join(', ')}`);
1086
- }
1425
+ const latestAction = latestActionLine(item);
1426
+ if (latestAction)
1427
+ lines.push(latestAction);
1428
+ const citation = lawCitations(item);
1429
+ if (citation)
1430
+ lines.push(`**Law:** ${citation}`);
1087
1431
  const rest = renderDetailRest(item, HEADER_BILL_KEYS);
1088
1432
  if (rest)
1089
1433
  lines.push('', rest);
@@ -1175,8 +1519,7 @@ function renderMemberDetail(item) {
1175
1519
  : undefined;
1176
1520
  if (termsArr && termsArr.length > 0) {
1177
1521
  lines.push(`\n**Terms (${termsArr.length}):**`);
1178
- const recent = termsArr.slice(-5);
1179
- for (const term of recent) {
1522
+ for (const term of termsArr) {
1180
1523
  const chamber = s(term, 'chamber');
1181
1524
  const start = s(term, 'startYear');
1182
1525
  const end = s(term, 'endYear');
@@ -1184,10 +1527,8 @@ function renderMemberDetail(item) {
1184
1527
  const stateName = s(term, 'stateName');
1185
1528
  const range = start && end ? `${start}–${end}` : start;
1186
1529
  const parts = [chamber, range, party, stateName].filter(Boolean);
1187
- lines.push(`- ${parts.join(', ')}`);
1530
+ lines.push(`- ${withUnnamedFields(term, parts.join(', '), TERM_ROW_KEYS)}`);
1188
1531
  }
1189
- if (termsArr.length > 5)
1190
- lines.push(`- _...${termsArr.length - 5} earlier_`);
1191
1532
  }
1192
1533
  const partyHistory = item.partyHistory;
1193
1534
  if (Array.isArray(partyHistory) && partyHistory.length > 0) {
@@ -1198,19 +1539,20 @@ function renderMemberDetail(item) {
1198
1539
  const end = s(p, 'endYear');
1199
1540
  const range = start && end ? `${start}–${end}` : start;
1200
1541
  const parts = [partyName, range && `(${range})`].filter(Boolean);
1201
- lines.push(`- ${parts.join(' ')}`);
1542
+ lines.push(`- ${withUnnamedFields(p, parts.join(' '), PARTY_HISTORY_ROW_KEYS)}`);
1202
1543
  }
1203
1544
  }
1204
1545
  const leadership = item.leadership;
1205
1546
  if (Array.isArray(leadership) && leadership.length > 0) {
1206
1547
  lines.push(`\n**Leadership Roles (${leadership.length}):**`);
1207
- for (const l of leadership.slice(0, 10)) {
1548
+ for (const l of leadership) {
1208
1549
  const type = s(l, 'type');
1209
1550
  const congress = s(l, 'congress');
1210
- lines.push(`- ${[type, congress ? `Congress ${congress}` : undefined].filter(Boolean).join(' — ')}`);
1551
+ const curated = [type, congress ? `Congress ${congress}` : undefined]
1552
+ .filter(Boolean)
1553
+ .join(' — ');
1554
+ lines.push(`- ${withUnnamedFields(l, curated, LEADERSHIP_ROW_KEYS)}`);
1211
1555
  }
1212
- if (leadership.length > 10)
1213
- lines.push(`- _...${leadership.length - 10} more_`);
1214
1556
  }
1215
1557
  const subResources = [];
1216
1558
  for (const key of ['sponsoredLegislation', 'cosponsoredLegislation']) {
@@ -1225,8 +1567,33 @@ function renderMemberDetail(item) {
1225
1567
  const url = s(item, 'url');
1226
1568
  if (url)
1227
1569
  lines.push(`\n**URL:** ${url}`);
1570
+ const rest = renderDetailRest(item, HEADER_MEMBER_KEYS);
1571
+ if (rest)
1572
+ lines.push('', rest);
1228
1573
  return lines.join('\n');
1229
1574
  }
1575
+ const TERM_ROW_KEYS = new Set(['chamber', 'startYear', 'endYear', 'partyName', 'stateName']);
1576
+ const PARTY_HISTORY_ROW_KEYS = new Set(['partyName', 'startYear', 'endYear']);
1577
+ const LEADERSHIP_ROW_KEYS = new Set(['type', 'congress']);
1578
+ const HEADER_MEMBER_KEYS = new Set([
1579
+ 'bioguideId',
1580
+ 'directOrderName',
1581
+ 'invertedOrderName',
1582
+ 'honorificName',
1583
+ 'partyName',
1584
+ 'currentParty',
1585
+ 'state',
1586
+ 'district',
1587
+ 'currentMember',
1588
+ 'birthYear',
1589
+ 'updateDate',
1590
+ 'terms',
1591
+ 'partyHistory',
1592
+ 'leadership',
1593
+ 'sponsoredLegislation',
1594
+ 'cosponsoredLegislation',
1595
+ 'url',
1596
+ ]);
1230
1597
  /** Committee list item — name + key fields. Fuzzy-matched rows carry `approximate: true`. */
1231
1598
  function renderCommitteeListItem(item, i) {
1232
1599
  const name = s(item, 'name') ?? s(item, 'systemCode') ?? 'Committee';
@@ -1243,8 +1610,18 @@ function renderCommitteeListItem(item, i) {
1243
1610
  const url = s(item, 'url');
1244
1611
  if (url)
1245
1612
  lines.push(`**URL:** ${url}`);
1246
- return lines.join('\n');
1613
+ return withRowRest(item, lines, COMMITTEE_ROW_KEYS);
1247
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
+ ]);
1248
1625
  /** Committee report list item — citation-first; upstream omits title and bill ref. */
1249
1626
  function renderCommitteeReportListItem(item, i) {
1250
1627
  const citation = s(item, 'citation');
@@ -1269,8 +1646,76 @@ function renderCommitteeReportListItem(item, i) {
1269
1646
  lines.push(meta);
1270
1647
  if (url)
1271
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._');
1272
1700
  return lines.join('\n');
1273
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
+ }
1274
1719
  // ── Per-Tool Format Exports ─────────────────────────────────────────
1275
1720
  function makeFormatter(detailKeys, itemRenderer, detailRenderer) {
1276
1721
  return (result) => {
@@ -1290,6 +1735,9 @@ function makeFormatter(detailKeys, itemRenderer, detailRenderer) {
1290
1735
  }
1291
1736
  /** Bill browse, detail, and sub-resources (actions, amendments, cosponsors, etc.). */
1292
1737
  export function formatBills(result) {
1738
+ const content = documentContentOf(result);
1739
+ if (content)
1740
+ return tb(renderDocumentContent(content));
1293
1741
  if (Array.isArray(result.data)) {
1294
1742
  const first = result.data[0];
1295
1743
  const firstRecord = typeof first === 'object' && first !== null ? first : undefined;
@@ -1306,7 +1754,8 @@ export function formatBills(result) {
1306
1754
  * survive into the rendered Markdown.
1307
1755
  */
1308
1756
  function renderBillSubresourceSummaryItem(item, i) {
1309
- 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';
1310
1759
  const actionDate = s(item, 'actionDate');
1311
1760
  const updated = s(item, 'updateDate');
1312
1761
  const lines = [`### ${i + 1}. ${version}`];
@@ -1316,8 +1765,9 @@ function renderBillSubresourceSummaryItem(item, i) {
1316
1765
  const text = typeof item.text === 'string' ? htmlToMarkdown(item.text) : '';
1317
1766
  if (text)
1318
1767
  lines.push('', text);
1319
- return lines.join('\n');
1768
+ return withRowRest(item, lines, named);
1320
1769
  }
1770
+ const BILL_SUMMARY_SUBRESOURCE_ROW_KEYS = ['actionDate', 'updateDate', 'text'];
1321
1771
  function pickBillListRenderer(first) {
1322
1772
  if ('title' in first && 'number' in first)
1323
1773
  return renderBillItem;
@@ -1363,8 +1813,19 @@ function renderSearchBillItem(item, i) {
1363
1813
  const summary = s(item, 'summaryPreview');
1364
1814
  if (summary)
1365
1815
  lines.push('', summary);
1366
- return lines.join('\n');
1816
+ return withRowRest(item, lines, SEARCH_BILL_ROW_KEYS);
1367
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
+ ]);
1368
1829
  /** Local bill keyword search over the FTS mirror. */
1369
1830
  export const formatSearchBills = makeFormatter([], renderSearchBillItem);
1370
1831
  /** Member browse, detail, and sponsored/cosponsored legislation. */
@@ -1419,6 +1880,9 @@ export function formatCommittees(result) {
1419
1880
  }
1420
1881
  /** Committee reports — list, detail, and text. */
1421
1882
  export function formatCommitteeReports(result) {
1883
+ const content = documentContentOf(result);
1884
+ if (content)
1885
+ return tb(renderDocumentContent(content));
1422
1886
  if (Array.isArray(result.data))
1423
1887
  return tb(renderList(result, renderCommitteeReportListItem));
1424
1888
  if (Array.isArray(result.text)) {
@@ -1441,6 +1905,9 @@ export function formatCrsReports(result) {
1441
1905
  }
1442
1906
  /** Daily Congressional Record. Dispatches between volumes/issues and flattened articles. */
1443
1907
  export function formatDailyRecord(result) {
1908
+ const content = documentContentOf(result);
1909
+ if (content)
1910
+ return tb(renderDocumentContent(content));
1444
1911
  if (Array.isArray(result.data)) {
1445
1912
  const first = result.data[0];
1446
1913
  const firstRecord = typeof first === 'object' && first !== null ? first : undefined;