@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/src/gsa-csv.ts ADDED
@@ -0,0 +1,992 @@
1
+ /**
2
+ * GSA daily-CSV keyless backbone (opt-in) — page-completing enrichment for the
3
+ * fields the keyless SAM HAL list endpoint nulls.
4
+ *
5
+ * Why this exists
6
+ * ----------------
7
+ * `sam_search_opportunities` (keyless HAL) returns a page of notices but nulls
8
+ * each row's naics / setAside / place-of-performance / responseDeadline / type
9
+ * (the list payload omits those VALUES even though the server-side filter
10
+ * honored them). Today the only fill path is `sam_get_opportunity` PER notice —
11
+ * N detail calls to complete an N-row page.
12
+ *
13
+ * The GSA "Contract Opportunities" daily bulk CSV carries all of those fields
14
+ * keyed by NoticeId (the 32-hex id that equals the HAL `_id`). So ONE cached
15
+ * index lookup completes a whole page, and it's a durability hedge if the
16
+ * undocumented HAL list drifts.
17
+ *
18
+ * Source — LIVE-VERIFIED 2026-07-03
19
+ * ---------------------------------
20
+ * https://falextracts.s3.amazonaws.com/Contract Opportunities/datagov/ContractOpportunitiesFullCSV.csv
21
+ * HEAD 200, 225,722,960 bytes (225.7 MB), `last-modified` daily,
22
+ * `accept-ranges: bytes`, 47 columns. Key + HAL-nulled fields (0-indexed):
23
+ * NoticeId@0 (32-hex == HAL _id), Title@1, Type@10, SetASideCode@14,
24
+ * SetASide@15, ResponseDeadLine@16, NaicsCode@17, PopCity@20, PopState@21,
25
+ * PopZip@22, PopCountry@23, Active@24, Description@46.
26
+ *
27
+ * Gold-standard verification (2026-07-03): for a live notice
28
+ * (a4be592da0304872a252980925b9458f) the CSV index's naics/PoP EQUAL what
29
+ * `sam_get_opportunity` (detail) returns — naics 238990, PoP WA / Hoodsport /
30
+ * 98548 all match; setAsideCode 'SBA' equals detail's mapped setAside 'SBA'.
31
+ *
32
+ * The non-negotiable design constraints
33
+ * -------------------------------------
34
+ * 1. OFF BY DEFAULT. No forced 225 MB download. Enabled only via env
35
+ * (`SAM_GOV_CSV_CACHE` = a cache dir, or `SAM_GOV_ENABLE_CSV=1` to use a
36
+ * default dir under the OS temp). Every EXISTING keyless HAL tool is
37
+ * UNCHANGED.
38
+ * 2. BOUNDED RAM. Never hold the 225 MB in memory. Stream the download to a
39
+ * cache FILE on disk. Build a COMPACT index of only the ~11 needed columns
40
+ * (NOT Description@46), keyed by NoticeId (~24 MB for this snapshot's
41
+ * ~78k rows, verified). Persist the compact index to the cache dir and
42
+ * load THAT into a Map; refresh when > 24 h old or the CSV `last-modified`
43
+ * changed.
44
+ * 3. STREAMING RFC-4180 parser, NO new npm dependency. Fields are quoted and
45
+ * some (Title, addresses) embed commas; a state machine handles the
46
+ * in-quote toggle, `""`→`"` escape, and newlines inside quotes. Since we
47
+ * skip Description@46 (the worst offender) we only parse through column 24.
48
+ * 4. TRUTHFUL `_meta` (the product): honest source, freshness (CSV
49
+ * last-modified + index build time), degraded on download/parse failure
50
+ * (never a silent empty), and per-noticeId a notice ABSENT from the
51
+ * current snapshot → `found:false` + nulls + an explicit "not in current
52
+ * CSV snapshot" disclosure, NEVER faked. DISABLED → a structured note
53
+ * explaining how to enable it (not an error, not fake data). A cold-cache
54
+ * first call that triggers the download is slow → disclosed as "warming".
55
+ */
56
+
57
+ import { createReadStream, createWriteStream } from "node:fs";
58
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
59
+ import { createInterface } from "node:readline";
60
+ import { pipeline } from "node:stream/promises";
61
+ import * as os from "node:os";
62
+ import * as path from "node:path";
63
+ import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
64
+ import { withMeta } from "./meta.js";
65
+
66
+ // ─── Source + config ─────────────────────────────────────────────
67
+
68
+ /** The GSA daily bulk CSV (keyless). Space in the path is URL-encoded. */
69
+ export const GSA_CSV_URL =
70
+ "https://falextracts.s3.amazonaws.com/Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv";
71
+
72
+ export const GSA_CSV_SOURCE = "gsa.gov daily bulk CSV (keyless)";
73
+
74
+ /** Max noticeIds accepted per batch (completes a `sam_search_opportunities` page). */
75
+ export const MAX_NOTICE_IDS = 100;
76
+
77
+ /** Refresh the cached index when it is older than this (or last-modified drifts). */
78
+ const INDEX_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
79
+
80
+ /** File names inside the cache dir. */
81
+ const RAW_CSV_FILE = "gsa-opportunities.csv";
82
+ const INDEX_FILE = "gsa-notice-index.json";
83
+
84
+ /**
85
+ * 0-indexed CSV columns we keep. Description@46 is deliberately SKIPPED (it is
86
+ * the largest field by far and the worst quoting offender — skipping it both
87
+ * caps the index size AND means the streaming parser only has to parse cleanly
88
+ * through column 24 (Active)).
89
+ */
90
+ const COL = {
91
+ NoticeId: 0,
92
+ Title: 1,
93
+ Type: 10,
94
+ SetASideCode: 14,
95
+ SetASide: 15,
96
+ ResponseDeadLine: 16,
97
+ NaicsCode: 17,
98
+ PopCity: 20,
99
+ PopState: 21,
100
+ PopZip: 22,
101
+ PopCountry: 23,
102
+ Active: 24,
103
+ } as const;
104
+
105
+ /** Highest column index we need — we can stop parsing a record after this. */
106
+ const MAX_COL = COL.Active; // 24
107
+
108
+ /**
109
+ * Resolve the opt-in config from the environment.
110
+ *
111
+ * Enablement (in priority order):
112
+ * - `SAM_GOV_CSV_FIXTURE` = path to a LOCAL CSV file → enabled, no network
113
+ * (used by tests and for pinning a pre-downloaded file). The cache dir
114
+ * defaults next to the fixture unless `SAM_GOV_CSV_CACHE` is also set.
115
+ * - `SAM_GOV_CSV_CACHE` = a cache DIRECTORY path → enabled; downloads live.
116
+ * - `SAM_GOV_ENABLE_CSV` truthy (`1`/`true`/`yes`) → enabled with a default
117
+ * cache dir under the OS temp (`<tmp>/mcp-sam-gov-csv`).
118
+ * - otherwise DISABLED (default) — the backbone never touches the network.
119
+ */
120
+ export type CsvConfig = {
121
+ enabled: boolean;
122
+ cacheDir: string;
123
+ /** A local CSV to index instead of downloading (tests / pinned file). */
124
+ fixturePath: string | null;
125
+ };
126
+
127
+ export function resolveCsvConfig(env: NodeJS.ProcessEnv = process.env): CsvConfig {
128
+ const fixture = env.SAM_GOV_CSV_FIXTURE?.trim() || null;
129
+ const cacheEnv = env.SAM_GOV_CSV_CACHE?.trim() || null;
130
+ const enableFlag = /^(1|true|yes|on)$/i.test(env.SAM_GOV_ENABLE_CSV?.trim() ?? "");
131
+
132
+ if (fixture) {
133
+ return {
134
+ enabled: true,
135
+ cacheDir: cacheEnv ?? path.join(path.dirname(fixture), ".mcp-sam-gov-csv-cache"),
136
+ fixturePath: fixture,
137
+ };
138
+ }
139
+ if (cacheEnv) {
140
+ return { enabled: true, cacheDir: cacheEnv, fixturePath: null };
141
+ }
142
+ if (enableFlag) {
143
+ return {
144
+ enabled: true,
145
+ cacheDir: path.join(os.tmpdir(), "mcp-sam-gov-csv"),
146
+ fixturePath: null,
147
+ };
148
+ }
149
+ return { enabled: false, cacheDir: "", fixturePath: null };
150
+ }
151
+
152
+ // ─── Streaming RFC-4180 parser ───────────────────────────────────
153
+
154
+ /**
155
+ * Parse one LOGICAL CSV record's text into fields 0..maxCol (inclusive).
156
+ *
157
+ * A proper state machine (NOT `split(",")`): tracks an in-quote toggle, turns
158
+ * `""` into a single `"`, and treats commas/CR/quotes literally while inside a
159
+ * quoted field. It also reports whether parsing ended INSIDE an open quote —
160
+ * the record-assembler uses that to know a physical newline fell inside a
161
+ * quoted field and the logical record continues on the next line.
162
+ *
163
+ * CRITICAL — we must scan the WHOLE text to get quote parity right, even though
164
+ * we only KEEP fields up to `maxCol`. Once we're past `maxCol` we stop STORING
165
+ * fields (so the giant Description@46 is never materialized — bounded memory),
166
+ * but we KEEP walking the characters to maintain the in-quote toggle. Early-
167
+ * returning at `maxCol` would misjudge a newline that falls inside a LATER
168
+ * quoted field (e.g. an embedded newline in Description@46): the record would
169
+ * be wrongly treated as complete and the field's tail would corrupt the next
170
+ * record. (This was a real bug — the fix is: cap storage, never cap scanning.)
171
+ */
172
+ export function parseRecordFields(
173
+ text: string,
174
+ maxCol: number = MAX_COL,
175
+ ): { fields: string[]; inQuotes: boolean } {
176
+ const fields: string[] = [];
177
+ let field = "";
178
+ let inQuotes = false;
179
+ let col = 0;
180
+ const keeping = () => col <= maxCol;
181
+ for (let i = 0; i < text.length; i++) {
182
+ const c = text[i];
183
+ if (inQuotes) {
184
+ if (c === '"') {
185
+ if (text[i + 1] === '"') {
186
+ if (keeping()) field += '"';
187
+ i++; // consume the escaped quote
188
+ } else {
189
+ inQuotes = false;
190
+ }
191
+ } else if (keeping()) {
192
+ field += c;
193
+ }
194
+ } else {
195
+ if (c === '"') {
196
+ inQuotes = true;
197
+ } else if (c === ",") {
198
+ if (keeping()) fields.push(field);
199
+ field = "";
200
+ col++;
201
+ } else if (c === "\r") {
202
+ // Strip stray CR (records are assembled by \n; CRLF → LF handled here).
203
+ } else if (keeping()) {
204
+ field += c;
205
+ }
206
+ }
207
+ }
208
+ if (keeping()) fields.push(field);
209
+ return { fields, inQuotes };
210
+ }
211
+
212
+ /**
213
+ * Assemble physical lines (from readline) into LOGICAL records, correctly
214
+ * re-joining a record whose quoted field contained a newline. Calls `onRecord`
215
+ * with the parsed fields (0..MAX_COL) of each complete logical record.
216
+ */
217
+ function makeRecordAssembler(onRecord: (fields: string[]) => void) {
218
+ let pending: string | null = null;
219
+ return {
220
+ push(line: string) {
221
+ const candidate = pending === null ? line : pending + "\n" + line;
222
+ const res = parseRecordFields(candidate, MAX_COL);
223
+ if (res.inQuotes) {
224
+ // Newline fell inside a quoted field → keep accumulating.
225
+ pending = candidate;
226
+ return;
227
+ }
228
+ pending = null;
229
+ onRecord(res.fields);
230
+ },
231
+ flush() {
232
+ if (pending !== null) {
233
+ const res = parseRecordFields(pending, MAX_COL);
234
+ onRecord(res.fields);
235
+ pending = null;
236
+ }
237
+ },
238
+ };
239
+ }
240
+
241
+ // ─── Compact index ───────────────────────────────────────────────
242
+
243
+ /** The compact per-notice record we keep in the index (NOT Description). */
244
+ export type NoticeFields = {
245
+ title: string;
246
+ type: string;
247
+ setAsideCode: string;
248
+ setAside: string;
249
+ responseDeadline: string;
250
+ naicsCode: string;
251
+ popCity: string;
252
+ popState: string;
253
+ popZip: string;
254
+ popCountry: string;
255
+ active: string;
256
+ };
257
+
258
+ /** The persisted index file shape. */
259
+ type PersistedIndex = {
260
+ builtAt: string; // ISO — when we built the compact index
261
+ csvLastModified: string | null; // the CSV `last-modified` at download time
262
+ csvBytes: number | null;
263
+ rowCount: number;
264
+ notices: Record<string, NoticeFields>;
265
+ };
266
+
267
+ /** In-memory loaded index (a Map for O(1) lookup) + its freshness metadata. */
268
+ type LoadedIndex = {
269
+ map: Map<string, NoticeFields>;
270
+ builtAt: string;
271
+ csvLastModified: string | null;
272
+ csvBytes: number | null;
273
+ rowCount: number;
274
+ };
275
+
276
+ const EMPTY_FIELDS: NoticeFields = {
277
+ title: "",
278
+ type: "",
279
+ setAsideCode: "",
280
+ setAside: "",
281
+ responseDeadline: "",
282
+ naicsCode: "",
283
+ popCity: "",
284
+ popState: "",
285
+ popZip: "",
286
+ popCountry: "",
287
+ active: "",
288
+ };
289
+
290
+ /** Build a compact NoticeFields from a parsed record's field array. */
291
+ function fieldsFromRecord(rec: string[]): NoticeFields {
292
+ return {
293
+ title: rec[COL.Title] ?? "",
294
+ type: rec[COL.Type] ?? "",
295
+ setAsideCode: rec[COL.SetASideCode] ?? "",
296
+ setAside: rec[COL.SetASide] ?? "",
297
+ responseDeadline: rec[COL.ResponseDeadLine] ?? "",
298
+ naicsCode: rec[COL.NaicsCode] ?? "",
299
+ popCity: rec[COL.PopCity] ?? "",
300
+ popState: rec[COL.PopState] ?? "",
301
+ popZip: rec[COL.PopZip] ?? "",
302
+ popCountry: rec[COL.PopCountry] ?? "",
303
+ active: rec[COL.Active] ?? "",
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Stream a CSV file on disk line-by-line and build the compact index. NEVER
309
+ * loads the whole file into memory — readline yields one physical line at a
310
+ * time and the assembler holds at most one in-progress (quote-spanning) record.
311
+ */
312
+ async function buildIndexFromFile(
313
+ csvPath: string,
314
+ ): Promise<{ notices: Record<string, NoticeFields>; rowCount: number }> {
315
+ const notices: Record<string, NoticeFields> = Object.create(null);
316
+ let headerSeen = false;
317
+ let rowCount = 0;
318
+
319
+ const rl = createInterface({
320
+ input: createReadStream(csvPath, { encoding: "utf8" }),
321
+ crlfDelay: Infinity,
322
+ });
323
+ const asm = makeRecordAssembler((rec) => {
324
+ if (!headerSeen) {
325
+ headerSeen = true; // first logical record is the 47-column header
326
+ return;
327
+ }
328
+ rowCount++;
329
+ const noticeId = (rec[COL.NoticeId] ?? "").trim();
330
+ // Only index a well-formed 32-hex NoticeId (equals the HAL `_id`). A
331
+ // malformed key would never match a real lookup — skip it.
332
+ if (!/^[0-9a-f]{32}$/i.test(noticeId)) return;
333
+ notices[noticeId.toLowerCase()] = fieldsFromRecord(rec);
334
+ });
335
+
336
+ for await (const line of rl) asm.push(line);
337
+ asm.flush();
338
+
339
+ return { notices, rowCount };
340
+ }
341
+
342
+ // ─── Download + refresh ──────────────────────────────────────────
343
+
344
+ /** HEAD the CSV to read `last-modified` + size without downloading the body. */
345
+ async function headCsv(): Promise<{ lastModified: string | null; bytes: number | null }> {
346
+ try {
347
+ const r = await fetchWithRetry(
348
+ GSA_CSV_URL,
349
+ { method: "HEAD", signal: AbortSignal.timeout(15_000) },
350
+ "gsa:csv:head",
351
+ );
352
+ const len = r.headers.get("content-length");
353
+ return {
354
+ lastModified: r.headers.get("last-modified"),
355
+ bytes: len ? Number(len) : null,
356
+ };
357
+ } catch {
358
+ // A HEAD failure is non-fatal for staleness checks — treat as "unknown".
359
+ return { lastModified: null, bytes: null };
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Stream the 225 MB CSV to a file on disk (bounded RAM — the body is piped
365
+ * straight to a write stream, never buffered). Downloads to a `.tmp` then
366
+ * atomically renames so a crashed download never leaves a truncated cache file.
367
+ */
368
+ async function downloadCsvToDisk(cacheDir: string): Promise<{ path: string; bytes: number }> {
369
+ await mkdir(cacheDir, { recursive: true });
370
+ const finalPath = path.join(cacheDir, RAW_CSV_FILE);
371
+ const tmpPath = finalPath + ".tmp";
372
+ const r = await fetchWithRetry(
373
+ GSA_CSV_URL,
374
+ { signal: AbortSignal.timeout(180_000) },
375
+ "gsa:csv:download",
376
+ );
377
+ if (!r.body) {
378
+ throw new ToolErrorCarrier({
379
+ kind: "upstream_unavailable",
380
+ message: "GSA CSV download returned no response body.",
381
+ retryable: true,
382
+ upstreamEndpoint: "gsa:csv:download",
383
+ });
384
+ }
385
+ // `r.body` is a web ReadableStream — pipeline accepts it directly on Node 20+.
386
+ await pipeline(
387
+ r.body as unknown as NodeJS.ReadableStream,
388
+ createWriteStream(tmpPath),
389
+ );
390
+ await rename(tmpPath, finalPath);
391
+ const st = await stat(finalPath);
392
+ return { path: finalPath, bytes: st.size };
393
+ }
394
+
395
+ /** Read a persisted index file, or null if absent/unreadable/corrupt. */
396
+ async function readPersistedIndex(cacheDir: string): Promise<PersistedIndex | null> {
397
+ try {
398
+ const raw = await readFile(path.join(cacheDir, INDEX_FILE), "utf8");
399
+ const parsed = JSON.parse(raw) as PersistedIndex;
400
+ if (!parsed || typeof parsed !== "object" || !parsed.notices) return null;
401
+ return parsed;
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+
407
+ /** Persist the compact index atomically (`.tmp` + rename). */
408
+ async function writePersistedIndex(cacheDir: string, idx: PersistedIndex): Promise<void> {
409
+ await mkdir(cacheDir, { recursive: true });
410
+ const finalPath = path.join(cacheDir, INDEX_FILE);
411
+ const tmpPath = finalPath + ".tmp";
412
+ await writeFile(tmpPath, JSON.stringify(idx), "utf8");
413
+ await rename(tmpPath, finalPath);
414
+ }
415
+
416
+ // Process-lifetime memo of the loaded index + an in-flight refresh guard so
417
+ // concurrent tool calls don't each trigger a 225 MB download.
418
+ let loaded: LoadedIndex | null = null;
419
+ let refreshInFlight: Promise<{ index: LoadedIndex; warmed: boolean }> | null = null;
420
+
421
+ /** Is a persisted index stale (older than TTL, or CSV last-modified drifted)? */
422
+ function isStale(idx: PersistedIndex, head: { lastModified: string | null }): boolean {
423
+ const ageMs = Date.now() - new Date(idx.builtAt).getTime();
424
+ if (!Number.isFinite(ageMs) || ageMs > INDEX_TTL_MS) return true;
425
+ if (
426
+ head.lastModified &&
427
+ idx.csvLastModified &&
428
+ head.lastModified !== idx.csvLastModified
429
+ ) {
430
+ return true;
431
+ }
432
+ return false;
433
+ }
434
+
435
+ function toLoaded(idx: PersistedIndex): LoadedIndex {
436
+ return {
437
+ map: new Map(Object.entries(idx.notices)),
438
+ builtAt: idx.builtAt,
439
+ csvLastModified: idx.csvLastModified,
440
+ csvBytes: idx.csvBytes,
441
+ rowCount: idx.rowCount,
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Ensure a fresh index is loaded, (re)building from a fixture or a fresh
447
+ * download as needed. Returns the loaded index and whether THIS call warmed
448
+ * the cache (i.e. paid the slow download/build) so the tool can disclose it.
449
+ *
450
+ * Concurrency: a single in-flight refresh is shared across callers.
451
+ */
452
+ async function ensureIndex(cfg: CsvConfig): Promise<{ index: LoadedIndex; warmed: boolean }> {
453
+ // ── Fixture mode: index the local file (no network, no staleness/HEAD). ──
454
+ if (cfg.fixturePath) {
455
+ if (loaded) return { index: loaded, warmed: false };
456
+ if (refreshInFlight) return refreshInFlight;
457
+ refreshInFlight = (async () => {
458
+ const st = await stat(cfg.fixturePath as string).catch(() => {
459
+ throw new ToolErrorCarrier({
460
+ kind: "not_found",
461
+ message: `SAM_GOV_CSV_FIXTURE points at a file that does not exist: ${cfg.fixturePath}`,
462
+ retryable: false,
463
+ upstreamEndpoint: "gsa:csv:fixture",
464
+ });
465
+ });
466
+ const { notices, rowCount } = await buildIndexFromFile(cfg.fixturePath as string);
467
+ const persisted: PersistedIndex = {
468
+ builtAt: new Date().toISOString(),
469
+ csvLastModified: st.mtime.toUTCString(),
470
+ csvBytes: st.size,
471
+ rowCount,
472
+ notices,
473
+ };
474
+ loaded = toLoaded(persisted);
475
+ return { index: loaded, warmed: true };
476
+ })();
477
+ try {
478
+ return await refreshInFlight;
479
+ } finally {
480
+ refreshInFlight = null;
481
+ }
482
+ }
483
+
484
+ // ── Download mode: reuse a fresh persisted/loaded index; else refresh. ──
485
+ // Fast path: an in-memory index that isn't past its TTL (skip the HEAD).
486
+ if (loaded && Date.now() - new Date(loaded.builtAt).getTime() <= INDEX_TTL_MS) {
487
+ return { index: loaded, warmed: false };
488
+ }
489
+ if (refreshInFlight) return refreshInFlight;
490
+
491
+ refreshInFlight = (async () => {
492
+ const head = await headCsv();
493
+
494
+ // Try the on-disk persisted index first.
495
+ const persisted = await readPersistedIndex(cfg.cacheDir);
496
+ if (persisted && !isStale(persisted, head)) {
497
+ loaded = toLoaded(persisted);
498
+ return { index: loaded, warmed: false };
499
+ }
500
+
501
+ // Stale or missing → download + rebuild (the slow, disclosed path).
502
+ const { path: csvPath, bytes } = await downloadCsvToDisk(cfg.cacheDir);
503
+ const { notices, rowCount } = await buildIndexFromFile(csvPath);
504
+ const fresh: PersistedIndex = {
505
+ builtAt: new Date().toISOString(),
506
+ csvLastModified: head.lastModified,
507
+ csvBytes: head.bytes ?? bytes,
508
+ rowCount,
509
+ notices,
510
+ };
511
+ await writePersistedIndex(cfg.cacheDir, fresh).catch(() => {
512
+ // A failed persist is non-fatal — we still serve the in-memory index this
513
+ // process; it just won't survive a restart. Don't throw.
514
+ });
515
+ loaded = toLoaded(fresh);
516
+ return { index: loaded, warmed: true };
517
+ })();
518
+
519
+ try {
520
+ return await refreshInFlight;
521
+ } finally {
522
+ refreshInFlight = null;
523
+ }
524
+ }
525
+
526
+ /** TEST-ONLY: drop the process-lifetime memo so a test can re-point the env. */
527
+ export function _resetIndexForTests(): void {
528
+ loaded = null;
529
+ refreshInFlight = null;
530
+ }
531
+
532
+ // ─── Non-blocking accessor (for inline search enrichment) ────────
533
+ //
534
+ // `sam_search_opportunities` must stay fast: it must NEVER pay a synchronous
535
+ // 225 MB download to enrich a page. This accessor returns the ALREADY-loaded/
536
+ // fresh index if one is in memory, else returns null IMMEDIATELY — optionally
537
+ // kicking off a background refresh (fire-and-forget) so a LATER search can
538
+ // enrich once the cache warms. It never awaits I/O on the hot path.
539
+
540
+ /** A read-only view of a loaded index for enrichment consumers. */
541
+ export type ReadyIndex = {
542
+ get(noticeId: string): NoticeFields | undefined;
543
+ csvLastModified: string | null;
544
+ indexBuiltAt: string;
545
+ rowCount: number;
546
+ };
547
+
548
+ /** Wrap a LoadedIndex as the read-only ReadyIndex the enrichment path uses. */
549
+ function toReady(idx: LoadedIndex): ReadyIndex {
550
+ return {
551
+ get: (noticeId: string) => idx.map.get((noticeId ?? "").trim().toLowerCase()),
552
+ csvLastModified: idx.csvLastModified,
553
+ indexBuiltAt: idx.builtAt,
554
+ rowCount: idx.rowCount,
555
+ };
556
+ }
557
+
558
+ /**
559
+ * NON-BLOCKING: return a ready CSV index if one is loaded + fresh, else null.
560
+ *
561
+ * Guarantees for the search hot-path:
562
+ * - Disabled config → null (no work, no network) — caller skips enrichment.
563
+ * - An in-memory index within its TTL → returned synchronously (no HEAD, no
564
+ * download).
565
+ * - No in-memory index (cold) → returns null IMMEDIATELY and, unless a
566
+ * refresh is already in flight, kicks off a background `ensureIndex` (its
567
+ * result is memoized into `loaded` for a subsequent call). The promise is
568
+ * deliberately NOT awaited here and its rejection is swallowed so a failed
569
+ * warm never surfaces on the search path.
570
+ * - An in-memory index PAST its TTL → still returned (stale-but-usable) while
571
+ * a background refresh is kicked off; the caller discloses the age via
572
+ * freshness so a slightly-stale snapshot is honest, never a stall.
573
+ *
574
+ * This never throws — any misconfiguration or I/O error degrades to null.
575
+ */
576
+ export function tryGetReadyIndex(
577
+ cfg: CsvConfig = resolveCsvConfig(),
578
+ ): ReadyIndex | null {
579
+ try {
580
+ if (!cfg.enabled) return null;
581
+
582
+ if (loaded) {
583
+ const ageMs = Date.now() - new Date(loaded.builtAt).getTime();
584
+ // Past TTL (download mode only — a fixture never expires): serve the
585
+ // stale index now, refresh in the background for next time.
586
+ if (!cfg.fixturePath && (!Number.isFinite(ageMs) || ageMs > INDEX_TTL_MS)) {
587
+ kickBackgroundRefresh(cfg);
588
+ }
589
+ return toReady(loaded);
590
+ }
591
+
592
+ // Cold: nothing loaded yet — never block the search. Warm in the
593
+ // background and return null so this call proceeds un-enriched.
594
+ kickBackgroundRefresh(cfg);
595
+ return null;
596
+ } catch {
597
+ return null;
598
+ }
599
+ }
600
+
601
+ /** Fire-and-forget a single shared refresh; swallow errors (never block/throw). */
602
+ function kickBackgroundRefresh(cfg: CsvConfig): void {
603
+ if (refreshInFlight) return;
604
+ // ensureIndex sets/reuses `refreshInFlight` itself; we just make sure its
605
+ // rejection is handled so an unhandledRejection never escapes the warm.
606
+ void ensureIndex(cfg).catch(() => {
607
+ /* a failed background warm is non-fatal — the next call retries */
608
+ });
609
+ }
610
+
611
+ // ─── Inline enrichment merge (used by sam_search_opportunities) ──
612
+ //
613
+ // Pure, testable merge: given the keyless HAL search page (whose
614
+ // naics/setAside/PoP/deadline/type are null) and a ready CSV index, fill the
615
+ // null fields from the CSV snapshot and report EXACTLY what changed so the
616
+ // caller can rebuild `_meta` truthfully. Notices absent from the snapshot are
617
+ // left untouched and counted. Never fabricates: only null→value, only for a
618
+ // noticeId present in the index.
619
+
620
+ /** One opportunity row as emitted by the keyless sam_search handler. */
621
+ export type SearchOppRow = {
622
+ noticeId: string;
623
+ title?: string | null;
624
+ agency?: string | null;
625
+ solicitationNumber?: string | null;
626
+ responseDeadline?: string | null;
627
+ naics?: string | null;
628
+ setAside?: string | null;
629
+ uiLink?: string | null;
630
+ // Enrichment MAY add these (absent on the un-enriched/disabled shape):
631
+ type?: string | null;
632
+ placeOfPerformance?: SamCsvPlaceOfPerformance | null;
633
+ [k: string]: unknown;
634
+ };
635
+
636
+ /** Place-of-performance shape composed from the CSV Pop* columns. */
637
+ export type SamCsvPlaceOfPerformance = {
638
+ city: string | null;
639
+ state: string | null;
640
+ zip: string | null;
641
+ country: string | null;
642
+ };
643
+
644
+ export type EnrichmentOutcome = {
645
+ /** The page with null fields filled from the CSV where the notice was found. */
646
+ opportunities: SearchOppRow[];
647
+ /** Notices in the page that were present in the CSV snapshot. */
648
+ foundCount: number;
649
+ /** Notices in the page absent from the CSV snapshot (left un-enriched). */
650
+ missingCount: number;
651
+ /** Union of field names filled on ≥1 row (subset of naics/setAside/… ). */
652
+ fieldsFilled: Set<string>;
653
+ /** Snapshot freshness, mirrored into data + _meta by the caller. */
654
+ freshness: {
655
+ csvLastModified: string | null;
656
+ indexBuiltAt: string;
657
+ indexAgeHours: number | null;
658
+ rowCount: number;
659
+ };
660
+ };
661
+
662
+ /** Compose a CSV place-of-performance, or null when every Pop* cell is empty. */
663
+ function popFromFields(f: NoticeFields): SamCsvPlaceOfPerformance | null {
664
+ const city = nn(f.popCity);
665
+ const state = nn(f.popState);
666
+ const zip = nn(f.popZip);
667
+ const country = nn(f.popCountry);
668
+ if (city === null && state === null && zip === null && country === null) {
669
+ return null;
670
+ }
671
+ return { city, state, zip, country };
672
+ }
673
+
674
+ /**
675
+ * Fill the keyless search page's null naics/setAside/placeOfPerformance (and
676
+ * responseDeadline/type when currently null) from the ready CSV index.
677
+ *
678
+ * Rules (honesty):
679
+ * - Only a null field is filled — a value already present (e.g. keyed mode,
680
+ * or a HAL row that carried the value) is NEVER overwritten.
681
+ * - A field is filled only when the CSV cell is a real non-empty value (an
682
+ * empty CSV cell stays null — absence ≠ empty).
683
+ * - `type`/`placeOfPerformance` keys are ADDED only when a value is actually
684
+ * filled from the CSV, so a not-in-snapshot row keeps the exact original
685
+ * shape (no spurious null keys).
686
+ * - A noticeId absent from the snapshot is left byte-identical + counted.
687
+ */
688
+ export function enrichSearchOpportunities(
689
+ opportunities: SearchOppRow[],
690
+ index: ReadyIndex,
691
+ ): EnrichmentOutcome {
692
+ const fieldsFilled = new Set<string>();
693
+ let foundCount = 0;
694
+ let missingCount = 0;
695
+
696
+ const enriched = opportunities.map((row) => {
697
+ const rec = index.get(row.noticeId);
698
+ if (!rec) {
699
+ missingCount++;
700
+ return row; // absent from snapshot — untouched, disclosed via counts
701
+ }
702
+ foundCount++;
703
+ const out: SearchOppRow = { ...row };
704
+
705
+ // naics — fill only if currently null/absent.
706
+ if (out.naics == null) {
707
+ const v = nn(rec.naicsCode);
708
+ if (v !== null) {
709
+ out.naics = v;
710
+ fieldsFilled.add("naics");
711
+ }
712
+ }
713
+ // setAside — the keyless HAL nulls typeOfSetAside; the CSV's short code
714
+ // (e.g. 'SBA') is the value that matches sam_get_opportunity's setAside.
715
+ if (out.setAside == null) {
716
+ const v = nn(rec.setAsideCode);
717
+ if (v !== null) {
718
+ out.setAside = v;
719
+ fieldsFilled.add("setAside");
720
+ }
721
+ }
722
+ // responseDeadline — fill only if currently null.
723
+ if (out.responseDeadline == null) {
724
+ const v = nn(rec.responseDeadline);
725
+ if (v !== null) {
726
+ out.responseDeadline = v;
727
+ fieldsFilled.add("responseDeadline");
728
+ }
729
+ }
730
+ // type — add the key only when the CSV has a real value.
731
+ if (out.type == null) {
732
+ const v = nn(rec.type);
733
+ if (v !== null) {
734
+ out.type = v;
735
+ fieldsFilled.add("type");
736
+ }
737
+ }
738
+ // placeOfPerformance — add the key only when ≥1 Pop* cell is populated.
739
+ if (out.placeOfPerformance == null) {
740
+ const pop = popFromFields(rec);
741
+ if (pop !== null) {
742
+ out.placeOfPerformance = pop;
743
+ fieldsFilled.add("placeOfPerformance");
744
+ }
745
+ }
746
+ return out;
747
+ });
748
+
749
+ const builtAtMs = new Date(index.indexBuiltAt).getTime();
750
+ const indexAgeHours = Number.isFinite(builtAtMs)
751
+ ? Math.round(((Date.now() - builtAtMs) / 3_600_000) * 10) / 10
752
+ : null;
753
+
754
+ return {
755
+ opportunities: enriched,
756
+ foundCount,
757
+ missingCount,
758
+ fieldsFilled,
759
+ freshness: {
760
+ csvLastModified: index.csvLastModified,
761
+ indexBuiltAt: index.indexBuiltAt,
762
+ indexAgeHours,
763
+ rowCount: index.rowCount,
764
+ },
765
+ };
766
+ }
767
+
768
+ // ─── The tool: sam_lookup_notice_fields ──────────────────────────
769
+
770
+ export type LookupResult = {
771
+ noticeId: string;
772
+ found: boolean;
773
+ naicsCode: string | null;
774
+ setAside: string | null;
775
+ setAsideCode: string | null;
776
+ popState: string | null;
777
+ popCity: string | null;
778
+ popZip: string | null;
779
+ popCountry: string | null;
780
+ responseDeadline: string | null;
781
+ type: string | null;
782
+ active: boolean | null;
783
+ title: string | null;
784
+ };
785
+
786
+ /** Map an empty-string CSV cell to null (an absent value ≠ a real empty). */
787
+ function nn(s: string): string | null {
788
+ const t = s.trim();
789
+ return t === "" ? null : t;
790
+ }
791
+
792
+ /** CSV Active is "Yes"/"No" — normalize to a boolean, null if neither. */
793
+ function activeBool(s: string): boolean | null {
794
+ const t = s.trim().toLowerCase();
795
+ if (t === "yes") return true;
796
+ if (t === "no") return false;
797
+ return null;
798
+ }
799
+
800
+ /**
801
+ * BATCH keyless enrichment: for each 32-hex noticeId return the HAL-nulled
802
+ * fields (naics / setAside / place-of-performance / responseDeadline / type /
803
+ * active / title) from the cached GSA CSV index — completing a whole
804
+ * `sam_search_opportunities` page in ONE call.
805
+ *
806
+ * Honesty contract (the product):
807
+ * - DISABLED (default) → `data.enabled:false`, every row `found:false`+nulls,
808
+ * and `_meta.notes` explains how to enable the backbone. NOT an error, NOT
809
+ * fake data.
810
+ * - a noticeId ABSENT from the current snapshot → `found:false` + nulls + a
811
+ * disclosure that it is "not in the current CSV snapshot", NEVER faked.
812
+ * - download/parse failure → `degraded` (a structured error is thrown and
813
+ * surfaced by the server as a retryable envelope) — never a silent empty.
814
+ * - `_meta.freshness` carries the CSV `last-modified` + the index build time
815
+ * so the AI knows how stale the snapshot is; a cold-cache first call that
816
+ * triggered the download is disclosed as "warming".
817
+ */
818
+ export async function lookupNoticeFields(
819
+ args: { noticeIds: string[] },
820
+ env: NodeJS.ProcessEnv = process.env,
821
+ ) {
822
+ const requested = Array.isArray(args.noticeIds) ? args.noticeIds : [];
823
+ // Normalize + validate the batch. Cap at MAX_NOTICE_IDS.
824
+ if (requested.length === 0) {
825
+ throw new ToolErrorCarrier({
826
+ kind: "invalid_input",
827
+ message: "sam_lookup_notice_fields requires a non-empty noticeIds array (1..100).",
828
+ retryable: false,
829
+ upstreamEndpoint: "gsa:csv:lookup",
830
+ });
831
+ }
832
+ if (requested.length > MAX_NOTICE_IDS) {
833
+ throw new ToolErrorCarrier({
834
+ kind: "invalid_input",
835
+ message: `sam_lookup_notice_fields accepts at most ${MAX_NOTICE_IDS} noticeIds per call (got ${requested.length}). Split the page into batches.`,
836
+ retryable: false,
837
+ upstreamEndpoint: "gsa:csv:lookup",
838
+ });
839
+ }
840
+
841
+ const cfg = resolveCsvConfig(env);
842
+
843
+ // ── DISABLED (default): a structured "how to enable" note, never fake data. ──
844
+ if (!cfg.enabled) {
845
+ const results: LookupResult[] = requested.map((id) => ({
846
+ noticeId: id,
847
+ found: false,
848
+ naicsCode: null,
849
+ setAside: null,
850
+ setAsideCode: null,
851
+ popState: null,
852
+ popCity: null,
853
+ popZip: null,
854
+ popCountry: null,
855
+ responseDeadline: null,
856
+ type: null,
857
+ active: null,
858
+ title: null,
859
+ }));
860
+ return withMeta(
861
+ { results, enabled: false, freshness: null },
862
+ {
863
+ source: GSA_CSV_SOURCE,
864
+ keylessMode: true,
865
+ complete: false,
866
+ truncated: false,
867
+ returned: results.length,
868
+ totalAvailable: null,
869
+ filtersApplied: [],
870
+ filtersDropped: [],
871
+ fieldsUnavailable: [
872
+ "naicsCode",
873
+ "setAside",
874
+ "setAsideCode",
875
+ "popState",
876
+ "popCity",
877
+ "popZip",
878
+ "popCountry",
879
+ "responseDeadline",
880
+ "type",
881
+ "active",
882
+ "title",
883
+ ],
884
+ notes: [
885
+ "The GSA daily-CSV keyless backbone is DISABLED (default). No data was looked up and NO network download occurred — every result is found:false with null fields (this is NOT 'not found', it means the backbone is off).",
886
+ "To enable it, set the env var SAM_GOV_CSV_CACHE to a writable cache-directory path (e.g. SAM_GOV_CSV_CACHE=/var/cache/mcp-sam-gov), OR set SAM_GOV_ENABLE_CSV=1 to use a default cache dir under the OS temp. On first use (and each daily refresh) the server streams the ~226 MB GSA CSV to disk once and builds a compact on-disk index (~24 MB) — subsequent lookups are instant.",
887
+ "Until enabled, complete a sam_search_opportunities page by calling sam_get_opportunity per noticeId (one detail call each) to read naics/setAside/place-of-performance/deadline/type.",
888
+ ],
889
+ },
890
+ );
891
+ }
892
+
893
+ // ── ENABLED: ensure the index, then batch-look-up. ──
894
+ // A download/parse failure throws a classified ToolErrorCarrier (retryable) —
895
+ // the server surfaces it as { ok:false, error } rather than a silent empty.
896
+ let index: LoadedIndex;
897
+ let warmed: boolean;
898
+ try {
899
+ const r = await ensureIndex(cfg);
900
+ index = r.index;
901
+ warmed = r.warmed;
902
+ } catch (e) {
903
+ if (e instanceof ToolErrorCarrier) throw e;
904
+ throw new ToolErrorCarrier({
905
+ kind: "upstream_unavailable",
906
+ message: `GSA CSV backbone failed to build its index: ${(e as Error).message}. The 225 MB CSV download or parse did not complete; retry (the cache may warm on a subsequent call).`,
907
+ retryable: true,
908
+ upstreamEndpoint: "gsa:csv:index",
909
+ });
910
+ }
911
+
912
+ let foundCount = 0;
913
+ let missingCount = 0;
914
+ const results: LookupResult[] = requested.map((rawId) => {
915
+ const key = (rawId ?? "").trim().toLowerCase();
916
+ const rec = /^[0-9a-f]{32}$/i.test(key) ? index.map.get(key) : undefined;
917
+ const f = rec ?? EMPTY_FIELDS;
918
+ const found = rec !== undefined;
919
+ if (found) foundCount++;
920
+ else missingCount++;
921
+ return {
922
+ noticeId: rawId,
923
+ found,
924
+ naicsCode: found ? nn(f.naicsCode) : null,
925
+ setAside: found ? nn(f.setAside) : null,
926
+ setAsideCode: found ? nn(f.setAsideCode) : null,
927
+ popState: found ? nn(f.popState) : null,
928
+ popCity: found ? nn(f.popCity) : null,
929
+ popZip: found ? nn(f.popZip) : null,
930
+ popCountry: found ? nn(f.popCountry) : null,
931
+ responseDeadline: found ? nn(f.responseDeadline) : null,
932
+ type: found ? nn(f.type) : null,
933
+ active: found ? activeBool(f.active) : null,
934
+ title: found ? nn(f.title) : null,
935
+ };
936
+ });
937
+
938
+ const notes: string[] = [];
939
+ notes.push(
940
+ `Enrichment fields are read from the GSA daily bulk CSV snapshot (keyless), keyed by NoticeId. Snapshot last-modified: ${index.csvLastModified ?? "unknown"}; index built: ${index.builtAt} from ${index.rowCount.toLocaleString()} CSV rows.`,
941
+ );
942
+ if (warmed) {
943
+ notes.push(
944
+ "CSV index WARMING (first call this process / daily refresh): the ~226 MB CSV was just streamed to disk and indexed, so THIS call was slow (tens of seconds). Subsequent calls hit the cached on-disk index and return instantly until the daily refresh.",
945
+ );
946
+ }
947
+ notes.push(
948
+ "setAside is the CSV's human-readable label (e.g. 'Total Small Business Set-Aside (FAR 19.5)'); setAsideCode is the short code (e.g. 'SBA', '8A') that matches sam_get_opportunity's setAside. type is the CSV's procedure-type label (e.g. 'Solicitation', 'Combined Synopsis/Solicitation').",
949
+ );
950
+ if (missingCount > 0) {
951
+ notes.push(
952
+ `${missingCount} of ${requested.length} requested noticeId(s) were NOT in the current CSV snapshot (found:false + null fields) — a snapshot is a point-in-time daily file, so a very new notice (posted after the snapshot) or one dropped from the extract will be absent. This is an explicit "not in current CSV snapshot" disclosure, NOT fabricated data; fall back to sam_get_opportunity for those noticeIds.`,
953
+ );
954
+ }
955
+ notes.push(
956
+ "The CSV snapshot can lag the live HAL by up to ~24 h; for a field that must be real-time (e.g. a deadline just amended), confirm with sam_get_opportunity.",
957
+ );
958
+
959
+ // Machine-readable freshness object mirrored into `data` (the ResponseMeta
960
+ // type has no typed `freshness` field, and meta.ts is out of scope to change,
961
+ // so we surface freshness structurally here AND in _meta.notes for the AI).
962
+ const builtAtMs = new Date(index.builtAt).getTime();
963
+ const ageHours = Number.isFinite(builtAtMs)
964
+ ? Math.round(((Date.now() - builtAtMs) / 3_600_000) * 10) / 10
965
+ : null;
966
+ const freshness = {
967
+ csvLastModified: index.csvLastModified,
968
+ indexBuiltAt: index.builtAt,
969
+ indexAgeHours: ageHours,
970
+ rowCount: index.rowCount,
971
+ warming: warmed,
972
+ };
973
+
974
+ return withMeta(
975
+ { results, enabled: true, freshness, foundCount, missingCount },
976
+ {
977
+ source: GSA_CSV_SOURCE,
978
+ keylessMode: true,
979
+ // A batch lookup against a known snapshot is "complete" for the ids asked
980
+ // — there is no pagination and nothing was truncated/dropped. Absent ids
981
+ // are disclosed per-row (found:false) + in notes, not via truncation.
982
+ complete: true,
983
+ truncated: false,
984
+ returned: results.length,
985
+ totalAvailable: results.length,
986
+ filtersApplied: ["noticeIds(exact, CSV index)"],
987
+ filtersDropped: [],
988
+ fieldsUnavailable: [],
989
+ notes,
990
+ },
991
+ );
992
+ }