@cliwant/mcp-sam-gov 0.3.0 → 1.0.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 (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. package/src/usitc.ts +420 -0
package/dist/gao.js ADDED
@@ -0,0 +1,640 @@
1
+ /**
2
+ * GAO bid-protest lookup (keyless) — recent Comptroller General bid-protest
3
+ * decisions from the public GAO "Legal Products" RSS feed, optionally enriched
4
+ * from each decision's public product page.
5
+ *
6
+ * HONEST SCOPE (the defining constraint of this tool)
7
+ * ---------------------------------------------------
8
+ * Keyless access covers ONLY the RECENT decisions carried by the public
9
+ * Legal-Products RSS feed (a rolling ~25-item window). GAO's faceted historical
10
+ * bid-protest search (by protester / agency / outcome / date across all years,
11
+ * at https://www.gao.gov/legal/bid-protests/search) is WAF-protected against
12
+ * automated clients and is available cleanly only via a PAID third-party API.
13
+ * So this tool NEVER presents "recent" as "all": its `_meta` is ALWAYS
14
+ * `complete:false` + `truncated:true`, `totalAvailable` is `null` (the feed is
15
+ * not a count of all protests), and a top-level `accessNote` + a `_meta` note
16
+ * spell out the boundary. This is an intentional PARTIAL close of a capability
17
+ * that a competitor (Tango) sells as a paid feature.
18
+ *
19
+ * LIVE-VERIFIED 2026-07-03
20
+ * ------------------------
21
+ * - `GET https://www.gao.gov/rss/reportslegal.xml` with a browser UA → HTTP
22
+ * 200, application/rss+xml, ~14.6 KB, 26 <item>s. The feed carries GAO
23
+ * Legal Products — bid-protest DECISIONS *and* other legal products
24
+ * (Congressional Review Act regulatory reviews, legal opinions), so it MUST
25
+ * be filtered down to bid protests.
26
+ * - Each <item> has <title>, <link> (…/products/b-XXXXXX), <description>,
27
+ * <pubDate>, <guid>. A reconsideration reads "FCN Inc.--Reconsideration" →
28
+ * /products/b-424249.2; a costs decision "Accura Engineering--Costs".
29
+ * - Bid-protest items are reliably identified by their DESCRIPTION language
30
+ * ("protest(s)", "protester", "request(s) reconsideration of our decision")
31
+ * — regulatory reviews instead read "GAO reviewed the …'s new rule entitled".
32
+ * Protest B-numbers are the B-4xxxxx series; CRA reviews are B-33xxxx.
33
+ * - `GET https://www.gao.gov/products/b-XXXXXX` (browser UA) is cleanly
34
+ * parseable for protester (+ city/state), contracting agency, decision date,
35
+ * outcome ("We deny/sustain/dismiss the protest"), a solicitation number
36
+ * when present, and a decision-PDF link (/assets/.../NNNNNN.pdf).
37
+ */
38
+ import { ToolErrorCarrier } from "./errors.js";
39
+ import { getText as getTextPort } from "./datasource.js";
40
+ import { withMeta } from "./meta.js";
41
+ // ─── Shared HTTP ─────────────────────────────────────────────────
42
+ // GAO's edge (Cloudflare/WAF) will 403 a bare client — always send a realistic
43
+ // browser User-Agent. Mirror the shape the rest of the server uses for GAO-ish
44
+ // public HTML/RSS scraping so behavior is consistent.
45
+ const GAO_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
46
+ const RSS_URL = "https://www.gao.gov/rss/reportslegal.xml";
47
+ const PRODUCT_BASE = "https://www.gao.gov/products/";
48
+ const SOURCE = "gao.gov Legal Products RSS + decision pages (keyless)";
49
+ /**
50
+ * The mandatory honesty disclaimer. Surfaced BOTH as a top-level `accessNote`
51
+ * in `data` and inside `_meta.notes` so no consumer can miss the scope boundary.
52
+ */
53
+ const ACCESS_NOTE = "Keyless GAO access covers only RECENT decisions from the public Legal-Products RSS feed (a rolling ~25-item window). GAO's faceted historical protest search (by protester/agency/outcome/date across all years) is WAF-blocked to automated clients and available only via a paid third-party API. Do NOT treat these results as the complete protest history.";
54
+ // Thin LOCAL wrapper (ADR-0013) that injects GAO's WAF-friendly UA + RSS Accept
55
+ // for the tool's two call sites, then delegates to the shared `getText` port
56
+ // (retry defaults true → fetchWithRetry, byte-identical to the former
57
+ // hand-rolled fetcher). `timeoutMs` is preserved as a param default (never
58
+ // overridden at either call site) and passed through explicitly.
59
+ async function getText(url, label, timeoutMs = 15_000) {
60
+ return getTextPort(url, {
61
+ label,
62
+ headers: {
63
+ "User-Agent": GAO_UA,
64
+ Accept: "application/rss+xml, application/xml, text/html;q=0.9, */*;q=0.8",
65
+ },
66
+ timeoutMs,
67
+ });
68
+ }
69
+ // ─── RSS parse (no dependency — string/regex) ─────────────────────
70
+ /** Decode the handful of XML/HTML entities that appear in GAO titles/desc. */
71
+ function decodeEntities(s) {
72
+ return s
73
+ .replace(/&lt;/g, "<")
74
+ .replace(/&gt;/g, ">")
75
+ .replace(/&quot;/g, '"')
76
+ .replace(/&#0?39;/g, "'")
77
+ .replace(/&apos;/g, "'")
78
+ .replace(/&#x27;/g, "'")
79
+ // &amp; last so we don't double-decode an already-decoded entity.
80
+ .replace(/&amp;/g, "&")
81
+ .trim();
82
+ }
83
+ /** Pull the inner text of the first <tag>…</tag> in a block (CDATA-aware). */
84
+ function tagText(block, tag) {
85
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
86
+ const m = re.exec(block);
87
+ if (!m || m[1] === undefined)
88
+ return null;
89
+ let inner = m[1];
90
+ const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(inner.trim());
91
+ if (cdata && cdata[1] !== undefined)
92
+ inner = cdata[1];
93
+ return decodeEntities(inner);
94
+ }
95
+ /**
96
+ * Extract the canonical B-number from a product URL or guid. The URL path is
97
+ * lower-cased and may be percent-encoded with a comma joining companion numbers
98
+ * (e.g. `/products/b-424347%2Cb-424347.2`). We take the FIRST b-number, upcase
99
+ * it to the conventional `B-424347.2` form, and return the companions too.
100
+ */
101
+ function bNumbersFromUrl(url) {
102
+ let decoded = url;
103
+ try {
104
+ decoded = decodeURIComponent(url);
105
+ }
106
+ catch {
107
+ /* keep raw on malformed escapes */
108
+ }
109
+ const matches = decoded.match(/b-[0-9][0-9a-z.\-]*/gi) ?? [];
110
+ const norm = matches.map((b) => {
111
+ // Upcase the leading "b-" and any trailing "-O.M." style suffix letters.
112
+ return b.replace(/^b-/i, "B-").toUpperCase();
113
+ });
114
+ const unique = [...new Set(norm)];
115
+ return { primary: unique[0] ?? null, all: unique };
116
+ }
117
+ /** Parse the RSS feed into raw items (every legal product, unfiltered). */
118
+ function parseFeed(xml) {
119
+ const items = [];
120
+ const itemRe = /<item\b[^>]*>([\s\S]*?)<\/item>/gi;
121
+ let m;
122
+ while ((m = itemRe.exec(xml)) !== null) {
123
+ const block = m[1] ?? "";
124
+ const title = tagText(block, "title") ?? "";
125
+ const link = tagText(block, "link") ?? "";
126
+ const description = tagText(block, "description") ?? "";
127
+ const pubDate = tagText(block, "pubDate");
128
+ const guid = tagText(block, "guid");
129
+ // Prefer the link for the B-number; fall back to the guid path.
130
+ const fromLink = bNumbersFromUrl(link);
131
+ const fromGuid = guid ? bNumbersFromUrl(guid) : { primary: null, all: [] };
132
+ const primary = fromLink.primary ?? fromGuid.primary;
133
+ const all = fromLink.all.length ? fromLink.all : fromGuid.all;
134
+ items.push({
135
+ title,
136
+ link,
137
+ description,
138
+ pubDate,
139
+ bNumber: primary,
140
+ companionBNumbers: all,
141
+ });
142
+ }
143
+ return items;
144
+ }
145
+ /**
146
+ * Is this feed item a BID-PROTEST decision (vs a CRA regulatory review or a
147
+ * legal opinion)? The most reliable keyless signal is the DESCRIPTION language:
148
+ * a protest reads "…protests…" / "protester" / "request(s) reconsideration of
149
+ * our decision", while a CRA review reads "GAO reviewed the …'s new rule
150
+ * entitled". We also accept the "--Reconsideration" / "--Costs" title suffixes
151
+ * GAO uses on protest follow-ons, and (as a weak positive) the B-4 number
152
+ * series. Regulatory-review language is an explicit NEGATIVE so those never leak.
153
+ */
154
+ function isBidProtest(item) {
155
+ if (!item.bNumber)
156
+ return false;
157
+ const d = item.description.toLowerCase();
158
+ const t = item.title.toLowerCase();
159
+ // Explicit negatives — CRA / rule reviews and pure legal opinions.
160
+ if (/gao reviewed the .*new rule entitled/.test(d))
161
+ return false;
162
+ if (/\bcongressional review act\b/.test(d) && !/\bprotest/.test(d))
163
+ return false;
164
+ // Strong positives from the decision language.
165
+ if (/\bprotest(s|er|ers|ed|ing)?\b/.test(d))
166
+ return true;
167
+ if (/reconsideration of our decision/.test(d))
168
+ return true;
169
+ if (/request(s)? (for )?reconsideration/.test(d))
170
+ return true;
171
+ // GAO protest follow-on title conventions.
172
+ if (/--reconsideration\b/.test(t))
173
+ return true;
174
+ if (/--costs\b/.test(t))
175
+ return true;
176
+ // Fallback: the B-4xxxxx series is the bid-protest docket range. Only trust it
177
+ // when the description is not clearly a rule review (handled above).
178
+ if (/^B-4\d/.test(item.bNumber))
179
+ return true;
180
+ return false;
181
+ }
182
+ /** Strip HTML tags + collapse whitespace from a fragment. */
183
+ function stripHtml(s) {
184
+ return decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
185
+ }
186
+ const MONTHS = {
187
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
188
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
189
+ };
190
+ /**
191
+ * Normalize a "Jun 22, 2026" / "June 22, 2026" date to ISO (YYYY-MM-DD),
192
+ * parsing in UTC so we never shift a day (a local-timezone `new Date(...)` on a
193
+ * bare date string is midnight LOCAL, which `toISOString()` then rolls back a
194
+ * day in negative-offset zones — LIVE-VERIFIED that bug produced 06-21 for
195
+ * "Jun 22, 2026"). Returns the input unchanged if it can't be parsed.
196
+ */
197
+ function normalizeDate(human) {
198
+ const m = /^([A-Za-z]{3,9})\.?\s+(\d{1,2}),\s+(\d{4})$/.exec(human.trim());
199
+ if (m && m[1] && m[2] && m[3]) {
200
+ const mon = MONTHS[m[1].slice(0, 3).toLowerCase()];
201
+ if (mon !== undefined) {
202
+ const d = new Date(Date.UTC(Number(m[3]), mon, Number(m[2])));
203
+ if (!Number.isNaN(d.getTime()))
204
+ return d.toISOString().slice(0, 10);
205
+ }
206
+ }
207
+ return human;
208
+ }
209
+ /**
210
+ * Extract the contracting agency from a GAO highlights blurb. GAO phrases it
211
+ * two ways (LIVE-VERIFIED 2026-07-03):
212
+ * - "…issued by the <Agency>[, <sub-component>], for …"
213
+ * - "…protests the <Agency>'s issuance of …"
214
+ * We capture the full agency phrase (through a trailing sub-component up to the
215
+ * next clause boundary), then trim. Returns null when no agency is stated.
216
+ */
217
+ function extractAgency(text) {
218
+ // Bound to the FIRST ~2 sentences of the highlights — the contracting agency
219
+ // is named in the opening clause ("…issued by the <Agency>…"); a later "the
220
+ // Department of Defense during both peace and war" narrative sentence would
221
+ // otherwise be mis-captured (LIVE-VERIFIED that failure on USTRANSCOM).
222
+ const blurb = text.split(/(?<=\.)\s+/).slice(0, 2).join(" ");
223
+ // Anchor tokens that reliably head a federal agency name. "Department of the
224
+ // Air Force" / "Department of Defense" both allow an optional leading "the".
225
+ const AG = "(?:Department of (?:the )?[A-Z][A-Za-z.'’\\- ]+?|National [A-Z][A-Za-z.'’\\- ]+?|United States [A-Z][A-Za-z.'’\\- ]+? Command|U\\.S\\. [A-Z][A-Za-z.'’\\- ]+?|General Services Administration|Environmental Protection Agency|Small Business Administration|[A-Z][A-Za-z.'’\\- ]+? (?:Agency|Administration|Command|Corps|Bureau|Guard|Service))";
226
+ // Clause boundary the agency name ends at: a comma, a period, a bare " for"/
227
+ // " under"/" to"/" in support", or end-of-string. An optional parenthetical
228
+ // acronym ("(USTRANSCOM)") is consumed but not captured.
229
+ const STOP = "(?=,|\\.|\\s+for\\b|\\s+under\\b|\\s+to\\b|\\s+in support\\b|$)";
230
+ // "issued/awarded/conducted/solicited by [the] <Agency>[, U.S. <sub>]".
231
+ const by = new RegExp(`(?:issued|awarded|conducted|solicited|procured)\\s+by\\s+(?:the\\s+)?(${AG}(?:,\\s+U\\.S\\.[A-Za-z.'’&\\- ]+?)?)(?:\\s*\\([A-Z]{2,10}\\))?${STOP}`).exec(blurb);
232
+ if (by && by[1])
233
+ return cleanAgency(by[1]);
234
+ // "protests the <Agency>['s] issuance/award/decision".
235
+ const poss = new RegExp(`protests?\\s+the\\s+(${AG})(?:'s|’s)?\\s+(?:issuance|award|decision|evaluation|cancellation|termination|rejection)`).exec(blurb);
236
+ if (poss && poss[1])
237
+ return cleanAgency(poss[1]);
238
+ // Bare "Department of [the] X[, U.S. sub-component]" fallback in the opening.
239
+ const dept = new RegExp(`\\b(Department of (?:the )?[A-Z][A-Za-z.'’\\- ]+?(?:,\\s+U\\.S\\. [A-Z][A-Za-z.'’\\- ]+?)?)${STOP}`).exec(blurb);
240
+ if (dept && dept[1])
241
+ return cleanAgency(dept[1]);
242
+ return null;
243
+ }
244
+ function cleanAgency(s) {
245
+ return s.replace(/\s+/g, " ").trim().replace(/[.,;]+$/, "").trim();
246
+ }
247
+ /**
248
+ * Classify the protest outcome from the decision text. GAO decisions state the
249
+ * disposition in the Highlights ("We deny the protest", "We sustain…",
250
+ * "We dismiss…") and echo it in the body. Order matters: "sustained in part"
251
+ * still counts as sustained. Returns null if no clear disposition is found.
252
+ */
253
+ function classifyOutcome(text) {
254
+ const t = text.toLowerCase();
255
+ // Sustain wins over deny when both appear ("sustain in part and deny in part"
256
+ // is a win worth surfacing as sustained).
257
+ if (/\bwe sustain\b/.test(t) || /protest is sustained/.test(t) || /\bsustained in part\b/.test(t))
258
+ return "sustained";
259
+ if (/\bwe deny\b/.test(t) || /protest is denied/.test(t) || /\bdenied in part\b/.test(t))
260
+ return "denied";
261
+ if (/\bwe dismiss\b/.test(t) || /protest is dismissed/.test(t) || /\bdismissed in part\b/.test(t))
262
+ return "dismissed";
263
+ if (/\bwithdrew\b/.test(t) || /protest is withdrawn/.test(t) || /\bwe withdraw\b/.test(t))
264
+ return "withdrawn";
265
+ return null;
266
+ }
267
+ /**
268
+ * Best-effort parse of a GAO decision product page. Every field is nullable —
269
+ * GAO's HTML is not a contract, so a missing field is surfaced as null (never
270
+ * fabricated) and disclosed by the caller. `rawHtml` is the fetched page text.
271
+ */
272
+ function parseDecisionPage(rawHtml, feed) {
273
+ // The GAO product page embeds a JSON-LD / og:description with the Highlights
274
+ // text, and a visible Highlights section. We work over the whole HTML with
275
+ // targeted regexes rather than a DOM parser (no dependency).
276
+ const bodyOnly = rawHtml.slice(Math.max(0, rawHtml.indexOf("</head>")));
277
+ const flat = stripHtml(rawHtml);
278
+ // Highlights: GAO renders the FULL (un-truncated) highlights on the page —
279
+ // the <meta> og:description carries only a TRUNCATED "…" blurb (agency cut
280
+ // off). In the flattened body the real highlights sit right after a DOUBLED
281
+ // "Highlights Highlights" marker (a heading + the body heading), and the body
282
+ // begins with the protester name (LIVE-VERIFIED 2026-07-03). The single word
283
+ // "Highlights" also appears in nav furniture, so we anchor on the doubled
284
+ // marker and stop at the "What GAO Found"/"Recommendations"/"View Decision"
285
+ // section that follows. Fall back to og:description, then the feed.
286
+ let highlights = null;
287
+ const flatHl = /Highlights\s+Highlights\s+([A-Z][\s\S]*?)(?=\s*(?:View Decision|Full Report|Highlights Page|What GAO Recommends|Recommendations for Executive Action|GAO Contacts|Additional Materials)\b|$)/.exec(flat);
288
+ if (flatHl && flatHl[1] && flatHl[1].trim().length > 40) {
289
+ highlights = flatHl[1].replace(/\s+/g, " ").trim();
290
+ }
291
+ // A cleaner (truncated) blurb from the <meta> tags — used to VALIDATE the flat
292
+ // highlights and as a fallback. It always starts with the protester.
293
+ let metaBlurb = null;
294
+ const og = /<meta\s+property="og:description"\s+content="([\s\S]*?)"/i.exec(rawHtml);
295
+ if (og && og[1])
296
+ metaBlurb = decodeEntities(og[1]).trim();
297
+ if (!metaBlurb) {
298
+ const md = /<meta\s+name="description"\s+content="([\s\S]*?)"/i.exec(rawHtml);
299
+ if (md && md[1])
300
+ metaBlurb = decodeEntities(md[1]).trim();
301
+ }
302
+ // If the flat highlights don't start with the same protester lead as the meta
303
+ // blurb, the anchor grabbed the wrong section — discard it and use the blurb.
304
+ if (highlights && metaBlurb) {
305
+ const lead = metaBlurb.replace(/\.\.\.$|…$/, "").slice(0, 18).toLowerCase();
306
+ if (lead.length > 6 && !highlights.slice(0, 40).toLowerCase().includes(lead.slice(0, 10))) {
307
+ highlights = null;
308
+ }
309
+ }
310
+ if (!highlights)
311
+ highlights = metaBlurb;
312
+ if (!highlights && feed)
313
+ highlights = feed.description || null;
314
+ // Outcome: GAO states the disposition in a
315
+ // `<div class="status highlighted-status">We dismiss the protest.</div>`
316
+ // block (LIVE-VERIFIED). Prefer that; else classify over the highlights/flat
317
+ // text. The truncated og:description alone never carries the disposition.
318
+ let outcome = null;
319
+ const statusBlock = /<div class="status[^"]*">\s*([\s\S]*?)\s*<\/div>/i.exec(rawHtml);
320
+ if (statusBlock && statusBlock[1]) {
321
+ outcome = classifyOutcome(stripHtml(statusBlock[1]));
322
+ }
323
+ if (!outcome) {
324
+ outcome = classifyOutcome(`${highlights ?? ""} ${feed?.description ?? ""} ${flat}`);
325
+ }
326
+ // Decision date: no <time datetime> / JSON-LD on these pages — the visible
327
+ // decision date renders as "Jun 22, 2026" in the body (LIVE-VERIFIED). Take
328
+ // the first such date after </head>, normalized to ISO. Fall back to the feed
329
+ // pubDate (handled by the caller when this is null).
330
+ let decisionDate = null;
331
+ const dm = /\b((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},\s+20\d{2})\b/.exec(bodyOnly.replace(/<[^>]+>/g, " "));
332
+ if (dm && dm[1])
333
+ decisionDate = normalizeDate(dm[1]);
334
+ // Protester + agency parsed from the (un-truncated) highlights.
335
+ let protester = null;
336
+ let agency = null;
337
+ const blurb = highlights ?? feed?.description ?? "";
338
+ if (blurb) {
339
+ // Protester = leading clause up to the first comma OR "protests"/"requests".
340
+ const protMatch = /^(.*?)(?:,|\s+protests\b|\s+requests?\b)/i.exec(blurb);
341
+ if (protMatch && protMatch[1] && protMatch[1].trim().length > 1) {
342
+ protester = protMatch[1].trim();
343
+ }
344
+ agency = extractAgency(blurb);
345
+ }
346
+ // Fallback: parse the agency from the FULL flattened page text (the on-page
347
+ // highlights are un-truncated there even when the <meta> blurb was cut off,
348
+ // and the highlights container markup varies). LIVE-VERIFIED to recover
349
+ // "Department of Homeland Security, U.S. Customs and Border Protection" etc.
350
+ if (!agency)
351
+ agency = extractAgency(flat);
352
+ // A page-level agency facet as a last resort (GAO tags a "Federal agency").
353
+ if (!agency) {
354
+ const facet = /Federal agency<\/[^>]+>\s*<[^>]*>\s*([^<]{3,80}?)\s*</i.exec(rawHtml);
355
+ if (facet && facet[1])
356
+ agency = stripHtml(facet[1]);
357
+ }
358
+ // Solicitation / RFP / RFQ / TOPR number — GAO decisions cite it as
359
+ // "(request for proposals|solicitation|TOPR|task order proposal request)
360
+ // [(RFP)] No. XXXXXXX". Parse over the un-truncated highlights first, then the
361
+ // flat body. Best-effort — null when not stated in the highlights.
362
+ let solicitationNumber = null;
363
+ const solText = `${blurb} ${flat}`;
364
+ const sol = /\b(?:solicitation|request for (?:proposals?|quotations?)|task order proposal request|RF[PQ]|TOPR)\s*(?:\((?:RF[PQ]|TOPR)\)\s*)?No\.?\s*([A-Za-z0-9][A-Za-z0-9-]{4,})/i.exec(solText);
365
+ if (sol && sol[1])
366
+ solicitationNumber = sol[1].replace(/[.,;]$/, "").trim();
367
+ // Decision PDF: GAO links the full report as /assets/<dir>/NNNNNN.pdf.
368
+ let pdfUrl = null;
369
+ const pdf = /href="(\/assets\/[^"]*?\.pdf)"/i.exec(rawHtml) ?? /href="(https:\/\/www\.gao\.gov\/assets\/[^"]*?\.pdf)"/i.exec(rawHtml);
370
+ if (pdf && pdf[1]) {
371
+ pdfUrl = pdf[1].startsWith("http") ? pdf[1] : `https://www.gao.gov${pdf[1]}`;
372
+ }
373
+ const summary = highlights ? highlights.slice(0, 600) : null;
374
+ return { protester, agency, decisionDate, solicitationNumber, outcome, pdfUrl, summary };
375
+ }
376
+ /**
377
+ * Fetch + parse a single decision by B-number. A 404 → structured not_found; a
378
+ * 429/5xx/network fault stays a retryable upstream error (never a silent empty
379
+ * that reads as "no such protest"). The B-number is normalized to the
380
+ * `/products/b-xxxxxx` path shape GAO serves.
381
+ */
382
+ async function fetchDecision(bNumber, feed) {
383
+ const norm = bNumber.trim();
384
+ const primaryPath = norm.toLowerCase().replace(/^b-?/, "b-");
385
+ // Candidate URLs, in priority order. GAO reconsiderations are served at a
386
+ // COMMA-JOINED path (e.g. /products/b-424347,b-424347.2) — the primary
387
+ // B-number alone 404s (LIVE-VERIFIED 2026-07-03). So:
388
+ // 1. the feed's exact <link> (already carries the comma-joined path), then
389
+ // 2. the primary B-number path, then
390
+ // 3. a companion-joined path built from the feed's B-numbers.
391
+ const candidates = [];
392
+ if (feed?.link)
393
+ candidates.push(feed.link);
394
+ candidates.push(`${PRODUCT_BASE}${encodeURIComponent(primaryPath)}`);
395
+ if (feed && feed.companionBNumbers.length > 1) {
396
+ const joined = feed.companionBNumbers.map((b) => b.toLowerCase()).join(",");
397
+ candidates.push(`${PRODUCT_BASE}${joined}`);
398
+ }
399
+ // De-dup while preserving order.
400
+ const urls = [...new Set(candidates)];
401
+ let html = null;
402
+ let usedUrl = urls[0];
403
+ let lastNotFound = null;
404
+ for (const u of urls) {
405
+ try {
406
+ html = await getText(u, `gao:product:${primaryPath}`);
407
+ usedUrl = u;
408
+ break;
409
+ }
410
+ catch (e) {
411
+ if (e instanceof ToolErrorCarrier) {
412
+ if (e.toolError.kind === "not_found") {
413
+ // Try the next candidate — a 404 on the primary path is expected for
414
+ // reconsiderations served at the comma-joined path.
415
+ lastNotFound = e;
416
+ continue;
417
+ }
418
+ // A 429/5xx/network fault is retryable — surface it, never swallow.
419
+ throw e;
420
+ }
421
+ throw new ToolErrorCarrier({
422
+ kind: "upstream_unavailable",
423
+ message: `GAO decision fetch failed for '${bNumber}': ${e instanceof Error ? e.message : String(e)}`,
424
+ retryable: true,
425
+ upstreamEndpoint: `products/${primaryPath}`,
426
+ });
427
+ }
428
+ }
429
+ if (html === null) {
430
+ // Every candidate 404'd → a genuine "no such decision".
431
+ throw new ToolErrorCarrier({
432
+ kind: "not_found",
433
+ message: `No GAO decision found for B-number '${bNumber}' (tried ${urls.length} path form(s)). Verify the B-number (e.g. via gao_protest_lookup without bNumber to list recent protests).`,
434
+ retryable: false,
435
+ upstreamEndpoint: lastNotFound?.toolError.upstreamEndpoint ?? `products/${primaryPath}`,
436
+ });
437
+ }
438
+ const fields = parseDecisionPage(html, feed);
439
+ return {
440
+ bNumber: norm.toUpperCase(),
441
+ protester: fields.protester ?? feedProtester(feed),
442
+ agency: fields.agency,
443
+ decisionDate: fields.decisionDate ?? feedDate(feed),
444
+ solicitationNumber: fields.solicitationNumber,
445
+ outcome: fields.outcome,
446
+ title: feed?.title ?? fields.protester ?? norm.toUpperCase(),
447
+ decisionUrl: usedUrl,
448
+ pdfUrl: fields.pdfUrl,
449
+ summary: fields.summary ?? feed?.description ?? null,
450
+ };
451
+ }
452
+ /** A protester name derived from the feed title (company name) when present. */
453
+ function feedProtester(feed) {
454
+ if (!feed)
455
+ return null;
456
+ // Feed titles ARE the protester (minus the "--Reconsideration"/"--Costs" tag).
457
+ const t = feed.title.replace(/--(?:reconsideration|costs).*$/i, "").trim();
458
+ return t || null;
459
+ }
460
+ /** ISO/human decision date from the feed pubDate (RFC-822) when present. */
461
+ function feedDate(feed) {
462
+ if (!feed?.pubDate)
463
+ return null;
464
+ const d = new Date(feed.pubDate);
465
+ if (Number.isNaN(d.getTime()))
466
+ return feed.pubDate;
467
+ return d.toISOString().slice(0, 10);
468
+ }
469
+ /** Bounded-concurrency map (be polite to GAO's edge — no unbounded fan-out). */
470
+ async function mapWithConcurrency(items, limit, fn) {
471
+ const results = new Array(items.length);
472
+ let next = 0;
473
+ const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
474
+ while (true) {
475
+ const i = next++;
476
+ if (i >= items.length)
477
+ break;
478
+ results[i] = await fn(items[i], i);
479
+ }
480
+ });
481
+ await Promise.all(workers);
482
+ return results;
483
+ }
484
+ // ─── Public tool ─────────────────────────────────────────────────
485
+ export async function gaoProtestLookup(args) {
486
+ const outcomeFilter = args.outcome && args.outcome !== "any" ? args.outcome : null;
487
+ const limit = Math.min(50, Math.max(1, Math.floor(args.limit ?? 20)));
488
+ const enrich = args.enrich ?? true;
489
+ // ── Direct-by-B-number path (bypass the feed) ──────────────────────
490
+ if (args.bNumber) {
491
+ const decision = await fetchDecision(args.bNumber);
492
+ const notes = [ACCESS_NOTE];
493
+ const fieldsUnavailable = missingFields([decision]);
494
+ return withMeta({ accessNote: ACCESS_NOTE, decisions: [decision] }, {
495
+ source: SOURCE,
496
+ keylessMode: true,
497
+ // Even a direct lookup is a partial view of protest history.
498
+ complete: false,
499
+ truncated: true,
500
+ returned: 1,
501
+ totalAvailable: null,
502
+ filtersApplied: ["bNumber(direct)"],
503
+ filtersDropped: [],
504
+ fieldsUnavailable,
505
+ notes,
506
+ });
507
+ }
508
+ // ── Feed path ──────────────────────────────────────────────────────
509
+ const xml = await getText(RSS_URL, "gao:rss");
510
+ const rawItems = parseFeed(xml);
511
+ const protests = rawItems.filter(isBidProtest);
512
+ // Client-side filters over the feed items (case-insensitive substring).
513
+ const filtersApplied = [];
514
+ let filtered = protests;
515
+ const agencyQ = args.agency?.trim().toLowerCase();
516
+ const protesterQ = args.protester?.trim().toLowerCase();
517
+ const solQ = args.solicitationNumber?.trim().toLowerCase();
518
+ if (agencyQ) {
519
+ filtersApplied.push("agency(client-side)");
520
+ filtered = filtered.filter((i) => i.description.toLowerCase().includes(agencyQ) ||
521
+ i.title.toLowerCase().includes(agencyQ));
522
+ }
523
+ if (protesterQ) {
524
+ filtersApplied.push("protester(client-side)");
525
+ filtered = filtered.filter((i) => i.title.toLowerCase().includes(protesterQ) ||
526
+ i.description.toLowerCase().includes(protesterQ));
527
+ }
528
+ if (solQ) {
529
+ filtersApplied.push("solicitationNumber(client-side)");
530
+ filtered = filtered.filter((i) => i.description.toLowerCase().includes(solQ));
531
+ }
532
+ // Bound the number of items we enrich (be polite + fast). Outcome filtering
533
+ // requires the decision page, so when an outcome filter is set we enrich the
534
+ // candidate set then filter; otherwise we honor `limit` up front.
535
+ const candidates = outcomeFilter ? filtered : filtered.slice(0, limit);
536
+ let decisions;
537
+ let enrichFailures = 0;
538
+ let enrichedCount = 0;
539
+ if (enrich) {
540
+ const enriched = await mapWithConcurrency(candidates, 4, async (item) => {
541
+ try {
542
+ const dec = await fetchDecision(item.bNumber, item);
543
+ return { dec, ok: true };
544
+ }
545
+ catch {
546
+ // Tolerate a failed enrich: fall back to feed-level fields, disclose it.
547
+ return {
548
+ dec: feedOnlyDecision(item),
549
+ ok: false,
550
+ };
551
+ }
552
+ });
553
+ enrichedCount = enriched.filter((e) => e.ok).length;
554
+ enrichFailures = enriched.filter((e) => !e.ok).length;
555
+ decisions = enriched.map((e) => e.dec);
556
+ }
557
+ else {
558
+ decisions = candidates.map(feedOnlyDecision);
559
+ }
560
+ // Apply the outcome filter now that we (may) have parsed outcomes, then cap.
561
+ const filtersDropped = [];
562
+ if (outcomeFilter) {
563
+ if (enrich) {
564
+ filtersApplied.push("outcome(from decision page)");
565
+ decisions = decisions.filter((d) => d.outcome === outcomeFilter);
566
+ }
567
+ else {
568
+ // Can't determine outcome without enrichment → don't silently pretend to.
569
+ filtersDropped.push("outcome(requires enrichment; enrich=false)");
570
+ }
571
+ decisions = decisions.slice(0, limit);
572
+ }
573
+ const fieldsUnavailable = missingFields(decisions);
574
+ const notes = [ACCESS_NOTE];
575
+ if (enrich && enrichFailures > 0) {
576
+ notes.push(`${enrichFailures} decision(s) returned feed-level fields only; per-decision page enrichment failed (agency/outcome/solicitation may be null for those).`);
577
+ }
578
+ if (!enrich) {
579
+ notes.push("enrich=false: only RSS feed-level fields were returned (protester/title/date/summary). Agency, outcome, solicitation number, and the decision PDF require the per-decision page — call again with enrich=true or a specific bNumber.");
580
+ }
581
+ if (outcomeFilter && !enrich) {
582
+ notes.push("The outcome filter was NOT applied because it requires reading each decision page (enrich=false). Results are unfiltered on outcome.");
583
+ }
584
+ return withMeta({ accessNote: ACCESS_NOTE, decisions }, {
585
+ source: SOURCE,
586
+ keylessMode: true,
587
+ // ALWAYS incomplete — the feed is a recent window, never the full history.
588
+ complete: false,
589
+ truncated: true,
590
+ returned: decisions.length,
591
+ totalAvailable: null,
592
+ filtersApplied,
593
+ filtersDropped,
594
+ fieldsUnavailable,
595
+ enrichedCount: enrich ? enrichedCount : undefined,
596
+ notes,
597
+ });
598
+ }
599
+ /** Build a feed-only decision (no page fetch) from a feed item. */
600
+ function feedOnlyDecision(item) {
601
+ return {
602
+ bNumber: (item.bNumber ?? "").toUpperCase(),
603
+ protester: feedProtester(item),
604
+ agency: null,
605
+ decisionDate: feedDate(item),
606
+ solicitationNumber: null,
607
+ outcome: null,
608
+ title: item.title,
609
+ decisionUrl: item.link || `${PRODUCT_BASE}${(item.bNumber ?? "").toLowerCase()}`,
610
+ pdfUrl: null,
611
+ summary: item.description || null,
612
+ };
613
+ }
614
+ /**
615
+ * Which decision fields came back null across the returned set — surfaced in
616
+ * `_meta.fieldsUnavailable` so the AI knows these are "not parseable / not in
617
+ * this source", not "no data exists". A field is listed only if it is null on
618
+ * at least one returned decision.
619
+ */
620
+ function missingFields(decisions) {
621
+ if (decisions.length === 0)
622
+ return [];
623
+ const keys = [
624
+ "agency",
625
+ "outcome",
626
+ "solicitationNumber",
627
+ "pdfUrl",
628
+ "decisionDate",
629
+ "protester",
630
+ ];
631
+ const out = [];
632
+ for (const k of keys) {
633
+ if (decisions.some((d) => d[k] === null))
634
+ out.push(k);
635
+ }
636
+ // Historical-search limitation is structural, not per-field — always noted.
637
+ out.push("historicalFacetedSearch(WAF-blocked; paid API only)");
638
+ return out;
639
+ }
640
+ //# sourceMappingURL=gao.js.map