@pipeworx/mcp-edgar 0.3.2 → 0.3.3

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.
package/src/index.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  interface McpToolDefinition {
2
2
  name: string;
3
3
  description: string;
4
+ /** Human-facing one-liner (fleet #1967). Optional; consumers fall back to
5
+ * description. Kept in step with shared/src/types.ts — scripts/lib/
6
+ * check-inlined-types.mjs reports drift at publish time. */
7
+ summary?: string;
4
8
  inputSchema: {
5
9
  type: 'object';
6
10
  properties: Record<string, unknown>;
7
11
  required?: string[];
12
+ anyOf?: Array<{ required: string[] }>;
13
+ oneOf?: Array<{ required: string[] }>;
14
+ allOf?: Array<{ required: string[] }>;
8
15
  };
16
+ outputSchema?: Record<string, unknown>;
9
17
  }
10
18
 
11
19
  interface McpToolExport {
@@ -16,6 +24,619 @@ interface McpToolExport {
16
24
  provider?: string;
17
25
  }
18
26
 
27
+ /**
28
+ * Was this failure OUR OWN web service? — the other half of `internal-db-class.ts`.
29
+ *
30
+ * fleet #1089 pulled failures from our own Postgres out of `upstream_down` by
31
+ * keying on the SQLSTATE inside PostgREST's four-key error envelope. That
32
+ * covered the majority and structurally could not cover the rest: the rest
33
+ * never reach Postgres, so they carry no SQLSTATE. What was left, measured over
34
+ * the 24h to 2026-09-02T15:00Z (fleet #1096):
35
+ *
36
+ * 5 pipeworx-catalog get_pack_tools Pipeworx catalog error: 522 — error code: 522
37
+ * 3 fleet fleet_list_open … upstream_down: Fleet task queue did not respond within 25s
38
+ *
39
+ * 521/522/523/526 are Cloudflare saying its edge could not reach an ORIGIN, and
40
+ * in both of those rows the origin is ours — `gateway.pipeworx.io` for the
41
+ * catalog pack (it self-fetches when the gateway hasn't injected a manifest),
42
+ * our own Supabase for fleet. There is no third party anywhere in either call.
43
+ * Same defect as #1089: our own outage filed under `upstream_down`, the one
44
+ * class that means "the source is unreachable and there is nothing for us to
45
+ * fix", which is why the problem-tools triage skips it.
46
+ *
47
+ * WHY NOT A WORDING RULE. The obvious fix is to match `fleet db error:` and
48
+ * `Pipeworx catalog error:` in classifyToolError. Each is emitted from exactly
49
+ * one site today, so it would work today. It would also rot the first time
50
+ * somebody rewords a label — silently, and in the direction of hiding our own
51
+ * outage, which is worse than the bug being fixed. Every prose rule in
52
+ * error-class.ts has needed widening as packs invented new wording (#409/#450/
53
+ * #584); that history is most of that file's comment budget.
54
+ *
55
+ * WHAT THIS KEYS ON INSTEAD: **the host the call actually reached.** A URL's
56
+ * hostname is a fact about the call, not a guess about its prose. Two
57
+ * consequences that a pack-level flag could not give us, and the reason the
58
+ * flag was rejected:
59
+ *
60
+ * - It describes the CALL, not the pack. `govcon-intel` fans out to our own
61
+ * Supabase AND to genuine third parties; `court-listener` holds our cache
62
+ * in Supabase and fetches courtlistener.com. An `internallyHosted: true` on
63
+ * either pack would relabel a real third-party outage as ours — inventing
64
+ * work, which is the same class of error in the opposite direction.
65
+ * - It covers every future internal pack for free, instead of one declared
66
+ * slug at a time.
67
+ *
68
+ * WHY IT SURVIVES A REWORD. The marker below is not matched as a literal by two
69
+ * separate files. `markInternalOrigin()` writes it and `internalHostMetricsClass()`
70
+ * reads it, both from the single exported `INTERNAL_ORIGIN_MARKER` constant in
71
+ * this module — so changing the wording changes both sides in the same edit and
72
+ * cannot desynchronise them. The pack's own label (`fleet db error:`,
73
+ * `Pipeworx catalog error:`) is not read at all: reword it freely, the class is
74
+ * unaffected. That is the property `stripClassPrefix` lacked when it drifted
75
+ * from its own classifier three times and needed a CI gate to hold them
76
+ * together.
77
+ *
78
+ * WHERE THE 5xx TEST LIVES. `markInternalOrigin` is called from the places that
79
+ * hold the real `Response` — `httpError`/`httpErrorMessage` and the timeout
80
+ * branch of `fetchWithTimeout` in `shared/src/http.ts` — so "is this an
81
+ * availability failure" is decided from the actual status code, never re-derived
82
+ * by scraping a number out of a sentence. A 404 from our own registry for a slug
83
+ * that does not exist is a caller's bad argument and is deliberately NOT marked.
84
+ */
85
+
86
+ /**
87
+ * OUR OWN web service was unreachable — not an upstream, and never `upstream_down`.
88
+ *
89
+ * ONE value, not three, unlike `internal_db_*`. That split existed because a
90
+ * slow query, an exhausted pool and an unknown SQLSTATE have different owners
91
+ * and different fixes. Here there is only one story to tell — an origin we run
92
+ * did not answer the edge — and one owner. A bucket with no distinct owner per
93
+ * value is decoration; #724 is what happens when a class holds several
94
+ * situations, and inventing sub-values ahead of a reason to act on them
95
+ * differently is the same mistake with the sign flipped.
96
+ *
97
+ * METRICS ONLY, exactly like PLATFORM_KEY_ERROR_CLASS and the internal_db
98
+ * values. `classifyToolError` still answers `upstream_down` for the retry and
99
+ * hint paths, which only care whether retrying or a sibling tool might work —
100
+ * and it might. Nothing a caller sees or is charged changes here.
101
+ *
102
+ * READ SIDE: this value is in BROKEN_TOOL_CLASSES, FAULT_CLASSES and
103
+ * ALL_ERROR_CLASSES in `workers/registry-api/src/index.ts`. All three, or it
104
+ * lands on no dashboard — fleet #721 is the warning, where the #719 split
105
+ * worked on the write side and was invisible for weeks.
106
+ */
107
+ const INTERNAL_SERVICE_UNREACHABLE_CLASS = 'internal_service_unreachable';
108
+
109
+ /**
110
+ * The token that carries "this origin is ours" from the call site to the
111
+ * classifier.
112
+ *
113
+ * Appended to the error message rather than attached to the Error object,
114
+ * because the object does not survive the trip: 275 packs return `{ error:
115
+ * string }` instead of throwing, the gateway reads `observedError` as a string,
116
+ * and the fleet pack rebuilds its error from a captured status + body across a
117
+ * retry loop. A property on an Error would be dropped by every one of those
118
+ * paths and the class would work in tests and vanish in production.
119
+ *
120
+ * WORDING IS LOAD-BEARING, same rule as labelAge's note in authority.ts. This
121
+ * string is appended to a pack's thrown Error message (shared/src/http.ts),
122
+ * and a thrown Error's message is exactly what the gateway hands back to the
123
+ * caller as `content[0].text` when nothing rewrites it (workers/gateway/src
124
+ * catches the throw and sets `rawResult.message = stripClassPrefix(error)`,
125
+ * which does not touch this suffix) — so the original wording,
126
+ * " [pipeworx-hosted origin — our own service, not a third party]", was not a
127
+ * theoretical leak: it shipped live on pipeworx-catalog's 522s, 7 times in 6
128
+ * hours on 2026-09-02 (see tests/golden-internal-service.test.ts), verbatim
129
+ * naming Pipeworx as the host. check:hosting-claims never caught it because it
130
+ * did not scan shared/ at all (task #2009). Reworded to describe the
131
+ * OBSERVATION (the origin did not answer) without a claim about who runs it —
132
+ * the identical fix labelAge got: drop the possessive, keep the fact.
133
+ */
134
+ const INTERNAL_ORIGIN_MARKER = ' [origin did not respond — retry before concluding the named source is down]';
135
+
136
+ /**
137
+ * Supabase's data plane for a project is `<ref>.supabase.co`, where the ref is
138
+ * exactly twenty lowercase letters (ours is `pqauisounztsgdgfkhke`).
139
+ *
140
+ * Matching the shape rather than listing the ref keeps this correct when we add
141
+ * a project — `supabaseEnv` on a pack entry already points some packs at a
142
+ * second one — while still excluding `status.supabase.co`, which is Supabase's
143
+ * own status page and emphatically not our database. Verified 2026-09-02 by
144
+ * `grep -rhoE '[a-z0-9-]+\.supabase\.(co|in)' mcps shared workers scripts`: the
145
+ * only real project ref anywhere in the tree is ours, the rest are doc
146
+ * placeholders (`abc`, `xyz`, `example`) which this pattern also excludes. Same
147
+ * finding internal-db-class.ts relies on for the PostgREST envelope being ours
148
+ * by construction.
149
+ */
150
+ const SUPABASE_PROJECT_HOST = /^[a-z]{20}\.supabase\.(co|in)$/;
151
+
152
+ /**
153
+ * Is this a host WE run?
154
+ *
155
+ * Deliberately NOT including `*.workers.dev`: plenty of third-party APIs are
156
+ * hosted on workers.dev, so the suffix says where something runs and not who
157
+ * owns it. Every internal call we actually make goes to a `pipeworx.io`
158
+ * hostname or to our Supabase project, both of which are ownership facts.
159
+ *
160
+ * `workers/gateway/src/provenance.ts`'s `OUR_HOSTS` answers the same
161
+ * question and DOES include `workers.dev` — a documented divergence
162
+ * (task #2051), not a bug to converge. That list decides what a response may
163
+ * cite as a data SOURCE, where a false negative (citing our own worker as an
164
+ * external source) is the hosting-disclosure leak this whole file exists to
165
+ * prevent, so it errs broad. This one decides who gets BLAMED for a 5xx in
166
+ * outage metrics read by on-call, where a false positive (crediting our own
167
+ * infra with a third party's outage) hides the real failure, so it errs
168
+ * narrow. Same suffix, opposite direction, because they are never called for
169
+ * the same reason.
170
+ *
171
+ * Returns false on anything unparseable rather than throwing — this runs inside
172
+ * an error path, and an error path that can itself throw turns a diagnosable
173
+ * failure into a mystery.
174
+ */
175
+ function isPipeworxOrigin(url: string | URL | undefined | null): boolean {
176
+ if (!url) return false;
177
+ let host: string;
178
+ try {
179
+ host = new URL(url instanceof URL ? url.href : url).hostname.toLowerCase();
180
+ } catch {
181
+ return false;
182
+ }
183
+ if (host === 'pipeworx.io' || host.endsWith('.pipeworx.io')) return true;
184
+ return SUPABASE_PROJECT_HOST.test(host);
185
+ }
186
+
187
+ /**
188
+ * Append the marker when this failure was OUR origin failing to answer.
189
+ *
190
+ * `status` is the HTTP status when there is one, and omitted for a timeout —
191
+ * where there is no response at all, and "the origin did not answer" is the
192
+ * whole observation. Statuses below 500 are left alone: a 404 from our own
193
+ * registry for a slug that does not exist is the caller's argument, not our
194
+ * outage, and marking it would put ordinary 404s on the incident dashboard.
195
+ *
196
+ * Idempotent, so a message that is wrapped and re-marked on the way up (the
197
+ * fleet pack's retry loop re-throws through two layers) carries the marker once.
198
+ */
199
+ function markInternalOrigin(
200
+ message: string,
201
+ url: string | URL | undefined | null,
202
+ status?: number,
203
+ ): string {
204
+ if (status !== undefined && status < 500) return message;
205
+ if (!isPipeworxOrigin(url)) return message;
206
+ if (message.includes(INTERNAL_ORIGIN_MARKER)) return message;
207
+ return message + INTERNAL_ORIGIN_MARKER;
208
+ }
209
+
210
+ /**
211
+ * Which blob4 value a failure from our own web services books as, or undefined
212
+ * if this is not one.
213
+ *
214
+ * Ordered AFTER `internalDbMetricsClass` at the call site: a PostgREST envelope
215
+ * from our own Supabase is a strictly more specific statement about the same
216
+ * row (which of our services, and why), and the two cannot disagree about
217
+ * whether the failure is ours.
218
+ */
219
+ function internalHostMetricsClass(error: string): string | undefined {
220
+ return error.includes(INTERNAL_ORIGIN_MARKER) ? INTERNAL_SERVICE_UNREACHABLE_CLASS : undefined;
221
+ }
222
+
223
+
224
+ /**
225
+ * One place to turn a failed `fetch` into an error a caller can act on.
226
+ *
227
+ * Nearly every pack was written the same way:
228
+ *
229
+ * if (!res.ok) throw new Error(`Unsplash: ${res.status}`);
230
+ *
231
+ * which discards the response body — and the body is usually where the upstream
232
+ * says what was actually wrong ("**symbol** not found: GBP", "parameter `year`
233
+ * out of range", "unknown taxonomy id"). The caller gets a number, cannot
234
+ * self-correct, and retries the same broken call. A 2026-07-31 sweep found this
235
+ * shape in 481 of 1,400 packs, 47 of them PLATFORM-keyed.
236
+ *
237
+ * It also hides bugs one level down. Two of the first three packs audited had a
238
+ * second defect that only existed because of this line: unsplash's rate-limit
239
+ * branch sat BELOW a catch-all and was unreachable, and bea-gov parsed
240
+ * `BEAAPI.Error.APIErrorDescription` below a `!res.ok` throw that made the
241
+ * parsing dead code for every non-200.
242
+ *
243
+ * DELIBERATELY NOT A CLASSIFIER. It does not add `user_error:` /
244
+ * `upstream_down:` prefixes. Those decide which tier a failure lands in, and the
245
+ * `error` tier is what the daily problem-tools list is built from — it means
246
+ * "Pipeworx has a defect". A 400 is genuinely ambiguous: often a caller's bad
247
+ * argument, but sometimes a query WE built wrong (ted-eu comma-joined its CPV
248
+ * values into something TED rejected, and that bug was found only because it sat
249
+ * in `error`). Blanket-classifying 400s as caller mistakes would have hidden it.
250
+ * A pack that KNOWS which it is should keep saying so explicitly; this helper is
251
+ * for the 481 that say nothing at all.
252
+ */
253
+
254
+ /** Longest upstream explanation we'll pass through. Enough for a real message,
255
+ * short enough that an HTML page or a stack trace can't swamp the error. */
256
+
257
+ const MAX_DETAIL = 300;
258
+
259
+ /**
260
+ * Default bound for `fetchWithTimeout` when a pack doesn't state its own.
261
+ *
262
+ * 25s mirrors the number `epo-ops` landed on after measuring the real failure:
263
+ * a degraded upstream that doesn't error, it just never answers, and a Worker
264
+ * sits in `await fetch()` until ITS OWN execution budget kills the request —
265
+ * which can take minutes, not seconds (epo_ops_search_patents measured 4-8
266
+ * MINUTE hangs before this existed). 25s is short enough that a caller gets a
267
+ * fast, actionable error instead of holding the connection, and long enough
268
+ * that it doesn't false-trip on a merely-slow-but-alive upstream.
269
+ */
270
+ const DEFAULT_FETCH_TIMEOUT_MS = 25_000;
271
+
272
+ /**
273
+ * Read the body of a failed response and fold it into a throwable Error.
274
+ *
275
+ * Usage — note the `await`, which is the one thing that makes this a mechanical
276
+ * change rather than a drop-in:
277
+ *
278
+ * if (!res.ok) throw await httpError(res, 'Unsplash');
279
+ *
280
+ * Safe to call on any non-ok response: a body that is missing, empty, unreadable
281
+ * or HTML degrades to exactly the old `Name: 404` string rather than throwing
282
+ * something new from inside the error path.
283
+ */
284
+ async function httpError(res: Response, name: string): Promise<Error> {
285
+ return new Error(await httpErrorMessage(res, name));
286
+ }
287
+
288
+ /** The message text without constructing an Error — for packs that need to wrap
289
+ * it in their own envelope or add an explicit classification prefix. */
290
+ async function httpErrorMessage(res: Response, name: string): Promise<string> {
291
+ // The one place a 5xx from a host WE run gets stamped as ours. `res.url` is
292
+ // the URL the fetch actually resolved to (after redirects), so this is a fact
293
+ // about the call rather than a guess from the `name` the pack passed in —
294
+ // reword that label freely, the class does not move. See
295
+ // internal-host-class.ts; no-op for every third-party upstream, which is why
296
+ // this touches 481 packs' error text and changes none of it.
297
+ return markInternalOrigin(
298
+ `${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
299
+ res.url,
300
+ res.status,
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Just the upstream's own explanation — no name, no status.
306
+ *
307
+ * For a pack that has already said both in its own sentence. epo-ops reads
308
+ * `EPO rejected this search as too large (HTTP 413) — ${httpErrorMessage(…)}`,
309
+ * which rendered as `… (HTTP 413) — EPO: 413.` once the XML detail was being
310
+ * dropped: the upstream named twice, the status twice, and the one thing EPO
311
+ * actually said ("Not enough characters before truncation character") nowhere
312
+ * (fleet #712). Returns '' when the body carries nothing readable, so a caller
313
+ * can fall back to its own wording.
314
+ */
315
+ async function upstreamDetail(res: Response): Promise<string> {
316
+ return readDetail(res);
317
+ }
318
+
319
+ /**
320
+ * Read a SUCCESSFUL response as JSON, failing loudly when it isn't JSON.
321
+ *
322
+ * `httpError` above only ever runs on `!res.ok`, which leaves the nastier half
323
+ * of the problem unhandled: an upstream that answers **HTTP 200 with an HTML
324
+ * page**. A bot wall, a login redirect, a maintenance interstitial and a CDN
325
+ * error page are all 200s, so `res.ok` is true, and `res.json()` then throws
326
+ * `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`.
327
+ *
328
+ * That string is the problem. It names no upstream, carries no status, and
329
+ * reads like a parser bug in Pipeworx — so it lands in the `error` tier, which
330
+ * means "we have a defect", and the caller is told nothing they can act on.
331
+ * data.govt.nz sat dead behind an Imperva challenge this way and every
332
+ * status-code health check we own reported it green (7889a845). A zero-length
333
+ * body has the same shape: `Unexpected end of JSON input`, seen this week on
334
+ * uk-gazette (83% of external calls) and census.
335
+ *
336
+ * UNLIKE `httpError`, this one DOES classify, and the asymmetry is deliberate.
337
+ * A 400 is genuinely ambiguous — often the caller's bad argument, sometimes a
338
+ * query we built wrong — so blanket-classifying it would hide our own bugs.
339
+ * There is no such ambiguity here: **no argument a caller can pass makes a JSON
340
+ * API return an HTML page.** It is always the upstream, so `upstream_down:` is
341
+ * a statement of fact rather than a guess, and it keeps these out of the
342
+ * problem-tools list where they crowd out real defects.
343
+ *
344
+ * const data = await parseJson<Feed>(res, 'UK Gazette');
345
+ *
346
+ * Call it only after the `!res.ok` check — on a failed response you want
347
+ * `httpError`, which mines the body for the upstream's own explanation.
348
+ */
349
+ async function parseJson<T>(res: Response, name: string): Promise<T> {
350
+ let raw: string;
351
+ try {
352
+ raw = await res.text();
353
+ } catch {
354
+ throw new Error(
355
+ `upstream_down: ${name} returned a body that could not be read (HTTP ${res.status}). ` +
356
+ 'The connection most likely dropped mid-response; retrying is reasonable.',
357
+ );
358
+ }
359
+
360
+ const type = res.headers.get('content-type') ?? 'no content-type';
361
+
362
+ if (!raw.trim()) {
363
+ throw new Error(
364
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected (${type}). ` +
365
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
366
+ );
367
+ }
368
+
369
+ // Checked before parsing rather than in the catch, because knowing it is
370
+ // markup is what turns "we failed to parse something" into "they served a
371
+ // web page" — the second is diagnosable, the first is not.
372
+ const head = raw.slice(0, 200).trimStart().toLowerCase();
373
+ if (head.startsWith('<!doctype') || head.startsWith('<html') || head.startsWith('<?xml')) {
374
+ const kind = head.startsWith('<?xml') ? 'an XML document' : 'an HTML page';
375
+ // The summary, not the source. Pasting the first 120 characters of a web
376
+ // page handed the agent `<!DOCTYPE html><html lang="en"…` — the same leak
377
+ // this branch exists to describe (fleet #712).
378
+ throw new Error(
379
+ `upstream_down: ${name} answered HTTP ${res.status} with ${kind} instead of JSON (${type}). ` +
380
+ 'That is typically a bot wall, a login redirect or a maintenance page — it is returned as a SUCCESS, ' +
381
+ `so status-code health checks read it as fine. No argument change will get past it. ` +
382
+ `The page says: ${summarizeErrorBody(raw) || 'nothing readable'}`,
383
+ );
384
+ }
385
+
386
+ try {
387
+ return JSON.parse(raw) as T;
388
+ } catch {
389
+ throw new Error(
390
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON (${type}). ` +
391
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
392
+ );
393
+ }
394
+ }
395
+
396
+ /**
397
+ * `fetch`, but bounded — the fix for a systemic gap found 2026-08-30: a grep
398
+ * audit of every pack's `mcps/*\/src/index.ts` found 1,339 of ~1,500 call
399
+ * `fetch()` with NO timeout guard anywhere in the file. Two of those
400
+ * (epo-ops, statcan) were confirmed live-hanging for 4-8 minutes before this
401
+ * existed — every unguarded call carries the same risk, just unconfirmed.
402
+ *
403
+ * Mirrors the `epoFetch` wrapper `mcps/epo-ops/src/index.ts` shipped first:
404
+ * bound the request with `AbortSignal.timeout`, and on a timeout/abort throw
405
+ * an `upstream_down:` error that names the upstream and the bound rather than
406
+ * letting the raw `TimeoutError`/`AbortError` (which names neither) propagate.
407
+ * `upstream_down:` is deliberate, same reasoning as `parseJson` above — no
408
+ * argument a caller passes can make an upstream hang, so it is always the
409
+ * upstream's fault, and marking it that way keeps a slow API off the
410
+ * problem-tools list where it would crowd out our own defects.
411
+ *
412
+ * Usage — a mechanical swap for a bare `fetch(url, init)`:
413
+ *
414
+ * const res = await fetchWithTimeout(url, init, 'Some API');
415
+ *
416
+ * Pass `timeoutMs` as a fourth argument to override the default for a pack
417
+ * with a known-slower upstream; the label should be the same short name you'd
418
+ * pass to `httpError`/`httpErrorMessage` for that call.
419
+ */
420
+ async function fetchWithTimeout(
421
+ url: string | URL,
422
+ init: RequestInit = {},
423
+ name: string,
424
+ timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
425
+ ): Promise<Response> {
426
+ try {
427
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
428
+ } catch (err) {
429
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
430
+ // States the OBSERVATION (no response in N seconds), not a diagnosis.
431
+ // "appears to be degraded" is an inference about the vendor that we have
432
+ // not checked, and it is wrong in a way that misdirects whoever reads it:
433
+ // a timeout from a Worker can equally mean OUR egress is blocked.
434
+ //
435
+ // Measured today (2026-09-01, fleet #1047): every call to
436
+ // mainnet.base.org failed from the x402 facilitator while the identical
437
+ // request from a laptop returned 200. Base was entirely healthy; the
438
+ // public RPC refuses Cloudflare Worker egress. Had this message fired
439
+ // there it would have blamed Base by name, and the next person would have
440
+ // waited for a vendor outage to clear that did not exist.
441
+ // A timeout has no status to test — there is no response at all — so
442
+ // `markInternalOrigin` is called without one: an origin we run that never
443
+ // answered is an availability failure by definition. This is the half of
444
+ // fleet #1096 with neither a SQLSTATE nor a status code to key on.
445
+ throw new Error(
446
+ markInternalOrigin(
447
+ `upstream_down: ${name} did not respond within ${timeoutMs / 1000}s. ` +
448
+ `That can be ${name} being slow or down, or this environment being unable to reach it ` +
449
+ `(some hosts refuse datacenter/Worker egress) — retry shortly, and check reachability ` +
450
+ `from elsewhere before concluding ${name} is down.`,
451
+ url,
452
+ ),
453
+ );
454
+ }
455
+ throw err;
456
+ }
457
+ }
458
+
459
+ function detailSuffix(detail: string): string {
460
+ return detail ? ` — ${detail}` : '';
461
+ }
462
+
463
+ async function readDetail(res: Response): Promise<string> {
464
+ let raw: string;
465
+ try {
466
+ raw = await res.text();
467
+ } catch {
468
+ // Body already consumed, or the connection died mid-read. The status alone
469
+ // is still worth throwing — never let the error path throw its own error.
470
+ return '';
471
+ }
472
+ return summarizeErrorBody(raw);
473
+ }
474
+
475
+ /**
476
+ * Turn ANY error body — JSON, HTML, XML or plain text — into one short phrase
477
+ * that never contains markup.
478
+ *
479
+ * This used to just drop an HTML or XML body on the floor, on the reasoning
480
+ * that markup crowds out the status. That was half right. Dropping it loses the
481
+ * one sentence a caller could have acted on: an `Access Denied` title, an SDMX
482
+ * `<message:Error>` text, an OPS fault string. A 2026-08-30 support sweep
483
+ * measured 13 of 291 caller-facing error rows carrying a raw page or document
484
+ * verbatim, across 11 packs, and in every one of them the useful content —
485
+ * "Access Denied", "Invalid country code", "SCRAPE_TIMEOUT" — was in there,
486
+ * buried in markup the agent had to parse out of a string (fleet #712).
487
+ *
488
+ * So: extract the meaning, discard the markup. The output is passed through
489
+ * `stripMarkup` unconditionally, which is what lets `check:error-body-leak`
490
+ * assert mechanically that no caller-facing message can contain `<?xml`,
491
+ * `<!DOCTYPE` or `<html`.
492
+ */
493
+ function summarizeErrorBody(raw: string): string {
494
+ if (!raw || !raw.trim()) return '';
495
+
496
+ const head = raw.slice(0, 400).trimStart().toLowerCase();
497
+
498
+ // An HTML error page (Cloudflare interstitial, nginx default, a login
499
+ // redirect) says what it is in its <title>, and almost nowhere else.
500
+ if (head.startsWith('<!doctype') || head.startsWith('<html')) {
501
+ const title = htmlTitle(raw);
502
+ return title
503
+ ? `${title} (upstream returned an HTML error page, not an API response)`
504
+ : 'upstream returned an HTML error page, not an API response';
505
+ }
506
+
507
+ // XML fault documents — EPO OPS, SDMX (`<message:Error>`), SOAP faults. The
508
+ // human sentence sits in a child element whose tag name says what it is.
509
+ if (head.startsWith('<?xml') || head.startsWith('<')) {
510
+ const fault = xmlFaultText(raw);
511
+ return fault
512
+ ? `${stripMarkup(fault).slice(0, MAX_DETAIL)} (from the upstream's XML error document)`
513
+ : 'upstream returned an XML error document with no readable message';
514
+ }
515
+
516
+ // Most JSON error bodies bury one human sentence among ids and echoed request
517
+ // params. Prefer that sentence; fall back to the whole body when the shape is
518
+ // unfamiliar, since an unfamiliar shape is exactly when we can least afford to
519
+ // guess wrong and show nothing.
520
+ const fromJson = messageFromJson(raw);
521
+ return stripMarkup(fromJson ?? raw).slice(0, MAX_DETAIL);
522
+ }
523
+
524
+ /** The `<title>` of an HTML error page, or its first `<h1>` — the two places a
525
+ * bot wall, a 502 and an "Access Denied" all state what happened. */
526
+ function htmlTitle(raw: string): string | null {
527
+ const head = raw.slice(0, 4000);
528
+ for (const re of [/<title[^>]*>([\s\S]*?)<\/title>/i, /<h1[^>]*>([\s\S]*?)<\/h1>/i]) {
529
+ const m = re.exec(head);
530
+ const text = m ? stripMarkup(m[1]) : '';
531
+ if (text) return text.slice(0, 160);
532
+ }
533
+ return null;
534
+ }
535
+
536
+ /** Tag names that carry the explanation in an XML fault document, namespace
537
+ * prefix optional (`<message:Error>`, `<com:Text>`, `<faultstring>`). */
538
+ const XML_FAULT_TAG_RE =
539
+ /<(?:[A-Za-z0-9_.-]+:)?(?:text|message|description|faultstring|reason|detail|title|errormessage|error)\b[^>]*>([^<]{2,400})</i;
540
+
541
+ function xmlFaultText(raw: string): string | null {
542
+ const head = raw.slice(0, 8000);
543
+ const tagged = XML_FAULT_TAG_RE.exec(head);
544
+ if (tagged && tagged[1].trim()) return tagged[1];
545
+
546
+ // Nothing conventionally named — take the longest text node instead. A fault
547
+ // document with one sentence in an oddly named element is still readable;
548
+ // returning nothing at all is not.
549
+ let best = '';
550
+ for (const m of head.matchAll(/>([^<>]{8,400})</g)) {
551
+ const text = m[1].trim();
552
+ if (text.length > best.length) best = text;
553
+ }
554
+ return best || null;
555
+ }
556
+
557
+ /**
558
+ * Remove every tag and stray angle bracket, then collapse whitespace.
559
+ *
560
+ * Applied to everything on the way out, including the JSON and plain-text
561
+ * paths, because an upstream is free to embed markup in a JSON string field —
562
+ * and a leak is a leak regardless of which branch produced it.
563
+ */
564
+ function stripMarkup(s: string): string {
565
+ return collapse(decodeEntities$shared(s.replace(/<[^>]*>/g, ' ')).replace(/[<>]/g, ' '));
566
+ }
567
+
568
+ /** The handful of entities that show up in error-page titles. Decoded AFTER
569
+ * tags are stripped and BEFORE the angle-bracket sweep, so `&lt;script&gt;`
570
+ * in a title cannot decode into markup that survives — EMBL-EBI's ChEMBL 500
571
+ * page renders as `500 Internal Server Error &lt; EMBL-EBI` otherwise. */
572
+ function decodeEntities$shared(s: string): string {
573
+ return s
574
+ .replace(/&(?:amp|#0*38);/gi, '&')
575
+ .replace(/&(?:lt|#0*60);/gi, '<')
576
+ .replace(/&(?:gt|#0*62);/gi, '>')
577
+ .replace(/&(?:quot|#0*34);/gi, '"')
578
+ .replace(/&(?:#0*39|apos|#x0*27);/gi, "'")
579
+ .replace(/&nbsp;/gi, ' ');
580
+ }
581
+
582
+ /** The conventional "what went wrong" field, under any of the names upstreams
583
+ * actually use. Checked in order; first non-empty string wins. */
584
+ const MESSAGE_KEYS = [
585
+ 'message', 'error_message', 'errorMessage', 'detail', 'details',
586
+ 'description', 'error_description', 'reason', 'title', 'fault',
587
+ ];
588
+
589
+ function messageFromJson(raw: string): string | null {
590
+ let parsed: unknown;
591
+ try {
592
+ parsed = JSON.parse(raw);
593
+ } catch {
594
+ return null;
595
+ }
596
+ return pickMessage(parsed, 0);
597
+ }
598
+
599
+ function pickMessage(node: unknown, depth: number): string | null {
600
+ // Two levels covers `{error: {message}}` and `{errors: [{detail}]}`, the two
601
+ // shapes that account for nearly all of them, without walking a large payload.
602
+ if (depth > 2 || node == null) return null;
603
+
604
+ if (typeof node === 'string') return node.trim() || null;
605
+
606
+ if (Array.isArray(node)) {
607
+ for (const item of node) {
608
+ const found = pickMessage(item, depth + 1);
609
+ if (found) return found;
610
+ }
611
+ return null;
612
+ }
613
+
614
+ if (typeof node !== 'object') return null;
615
+ const obj = node as Record<string, unknown>;
616
+
617
+ for (const key of MESSAGE_KEYS) {
618
+ const v = obj[key];
619
+ if (typeof v === 'string' && v.trim()) return v.trim();
620
+ }
621
+ // `{error: …}` where error is itself an object or a string — the single most
622
+ // common wrapper, so it is worth descending into by name rather than scanning
623
+ // every key and risking picking up an echoed request parameter.
624
+ for (const key of ['error', 'errors', 'fault', 'Error', 'data']) {
625
+ if (key in obj) {
626
+ const found = pickMessage(obj[key], depth + 1);
627
+ if (found) return found;
628
+ }
629
+ }
630
+ return null;
631
+ }
632
+
633
+ /** Errors are read in a single line of log output; newlines and runs of
634
+ * whitespace make a multi-line body unreadable there. */
635
+ function collapse(s: string): string {
636
+ return s.replace(/\s+/g, ' ').trim();
637
+ }
638
+
639
+
19
640
  // Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
20
641
  // imports — so publish-pack.sh can inline it into standalone pack builds the same
21
642
  // way it inlines the McpToolExport type.
@@ -67,7 +688,7 @@ function rankMatches<T>(
67
688
  return scored.map(({ item, kind, score }) => ({ item, kind, score }));
68
689
  }
69
690
 
70
- interface SecTickerRow { cik_str: number; ticker: string; title: string }
691
+ interface SecTickerRow$shared { cik_str: number; ticker: string; title: string }
71
692
 
72
693
  interface SecEntity {
73
694
  ticker: string;
@@ -98,7 +719,7 @@ async function resolveSecEntity(
98
719
  const doFetch = opts.fetchImpl ?? fetch;
99
720
  const res = await doFetch(SEC_TICKERS_URL, { headers: opts.headers });
100
721
  if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
101
- const data = (await res.json()) as Record<string, SecTickerRow>;
722
+ const data = (await res.json()) as Record<string, SecTickerRow$shared>;
102
723
  const rows = Object.values(data);
103
724
 
104
725
  // 1) Exact ticker match — the common, unambiguous case.
@@ -120,10 +741,10 @@ async function resolveSecEntity(
120
741
  return best;
121
742
  }
122
743
 
123
- throw new Error(`No SEC company matches "${query}". Pass a ticker like "AAPL" or a company name like "Apple Inc.".`);
744
+ throw new Error(`No SEC company matches "${query}". Pass a US-listed ticker ("AAPL") or the exact listed-company name ("Apple Inc."). If this is a clinical-trial sponsor, an operating subsidiary (e.g. "Merck Sharp & Dohme" → Merck & Co), or a foreign/private entity, call sponsor_to_filer({sponsor}) instead — it resolves subsidiaries to the listed parent and honestly reports when no US-listed filer exists.`);
124
745
  }
125
746
 
126
- function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEntity {
747
+ function toEntity(r: SecTickerRow$shared, matched_by: 'ticker' | 'company_name'): SecEntity {
127
748
  return {
128
749
  ticker: r.ticker,
129
750
  cik: String(r.cik_str),
@@ -133,7 +754,320 @@ function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEn
133
754
  };
134
755
  }
135
756
 
757
+ const GENERIC_CORP_WORDS = new Set([
758
+ 'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED',
759
+ 'LLC', 'LP', 'PLC', 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
760
+ 'GROUP', 'HOLDINGS', 'HOLDING', 'AND', 'OF', 'US', 'USA', 'INTERNATIONAL',
761
+ 'GLOBAL',
762
+ ]);
763
+
764
+ /**
765
+ * Split a corporate/organization name into its SIGNIFICANT tokens — words
766
+ * that aren't generic corporate boilerplate (Inc, Co, Ltd, Group, ...) or
767
+ * punctuation — sorted LONGEST FIRST. Built for cross-registry name joins
768
+ * where the two registries anchor on different words of the same name: SEC
769
+ * lists Eli Lilly as "ELI LILLY & Co", but Drugs@FDA's sponsor_name field
770
+ * uses "LILLY" — the longer, more distinctive token, not the first one
771
+ * ("ELI" alone is short and matches too loosely). Pure (no I/O); callers
772
+ * typically try tokens in order until one call to their OWN registry
773
+ * returns a result.
774
+ */
775
+ function significantNameTokens(name: string): string[] {
776
+ const tokens = name
777
+ .toUpperCase()
778
+ .replace(/[.,&/()-]/g, ' ')
779
+ .split(/\s+/)
780
+ .filter((t) => t.length > 1 && !GENERIC_CORP_WORDS.has(t));
781
+ return [...new Set(tokens)].sort((a, b) => b.length - a.length);
782
+ }
783
+
784
+
785
+ /**
786
+ * Pick "the most recent annual value" out of SEC companyfacts XBRL data —
787
+ * the one operation every company-facts tool does, and the one that shipped
788
+ * wrong twice (sec + edgar packs, identical code) for the largest filers.
789
+ *
790
+ * Two traps in the raw companyfacts feed, both confirmed live 2026-08-28
791
+ * (fleet #594):
792
+ *
793
+ * 1. `fy` is the fiscal year of the FILING, not of the reported period. A
794
+ * 10-K carries two or three prior years as comparatives, and every one of
795
+ * them is stamped with the filing's fy. Amazon's FY2020 net income
796
+ * ($21.3B, period 2020-01-01..2020-12-31) appears with fy=2022 because it
797
+ * was restated in the FY2022 10-K — and Amazon's actual FY2022 figure is a
798
+ * $2.7B LOSS. Sorting by fy therefore mislabels the year AND can pick a
799
+ * comparative over the current period. The period is `start`/`end`; the
800
+ * year has to be derived from `end`.
801
+ *
802
+ * 2. `frame` is assigned to the LAST-FILED fact for a calendar period, not to
803
+ * the 10-K that first reported it. Since proxies (DEF 14A) began carrying
804
+ * XBRL, Amazon's CY2021..CY2025 annual frames all sit on the 2026 proxy,
805
+ * so a `form === '10-K' && frame` filter sees no 10-K annual fact newer
806
+ * than CY2020 and confidently returns that.
807
+ *
808
+ * And one presentation trap: a concept the filer has RETIRED (Microsoft's
809
+ * `Revenues` stops at FY2010 — it moved to
810
+ * RevenueFromContractWithCustomerExcludingAssessedTax under ASC 606) is
811
+ * accurate per-concept but wrong the moment it is presented under
812
+ * "most recent" beside concepts that are genuinely current. The fix is
813
+ * labelling and precedence, not deletion: a caller building a long series
814
+ * legitimately wants the retired concept, so it stays — flagged `stale`,
815
+ * sorted last, with the current concept surfaced in `latest_annual`.
816
+ *
817
+ * Kept dependency-free on purpose: publish-pack.sh inlines this file into the
818
+ * standalone npm build of any pack that imports it.
819
+ */
820
+
821
+ interface XbrlFactEntry {
822
+ start?: string;
823
+ end: string;
824
+ val: number;
825
+ accn?: string;
826
+ fy?: number;
827
+ fp?: string;
828
+ form?: string;
829
+ filed?: string;
830
+ frame?: string;
831
+ }
832
+
833
+ interface XbrlConcept {
834
+ label: string;
835
+ description?: string;
836
+ units: Record<string, XbrlFactEntry[]>;
837
+ }
838
+
839
+ type XbrlConceptMap = Record<string, XbrlConcept>;
840
+
841
+ interface AnnualValue {
842
+ /** Fiscal year the period ENDS in (derived from period_end — see fiscalYearOf). */
843
+ year: number;
844
+ value: number;
845
+ filed: string;
846
+ /** Present for duration concepts (income statement); absent for instants (balance sheet). */
847
+ period_start?: string;
848
+ period_end: string;
849
+ form: string;
850
+ unit: string;
851
+ concept: string;
852
+ }
853
+
854
+ interface KeyFinancial {
855
+ label: string;
856
+ most_recent_annual: Omit<AnnualValue, 'concept'> | null;
857
+ /**
858
+ * true when this concept's latest annual value ends before the filer's latest
859
+ * annual report period — i.e. the filer stopped reporting it. The number is
860
+ * real history, not the company's current figure.
861
+ */
862
+ stale: boolean;
863
+ stale_note?: string;
864
+ }
865
+
866
+ /** Annual-report forms. 10-KT (transition period) is deliberately excluded. */
867
+ const ANNUAL_FORMS$shared = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
868
+ /** Preferred unit per concept, in order. Anything else falls back to the first unit present. */
869
+ const UNIT_PREFERENCE = ['USD', 'USD/shares', 'shares', 'pure'];
870
+ const DAY_MS = 86_400_000;
871
+
872
+ /**
873
+ * Fiscal year a period belongs to, by the near-universal convention of naming
874
+ * the year the period ENDS in (Apple FY2025 ends 2025-09-27, Walmart FY2026
875
+ * ends 2026-01-31). The one exception handled: 52/53-week years that end in
876
+ * the first days of January belong to the prior year (a year ending
877
+ * 2027-01-02 is fiscal 2026).
878
+ */
879
+ function fiscalYearOf(periodEnd: string): number {
880
+ const y = Number(periodEnd.slice(0, 4));
881
+ const m = Number(periodEnd.slice(5, 7));
882
+ const d = Number(periodEnd.slice(8, 10));
883
+ return m === 1 && d <= 7 ? y - 1 : y;
884
+ }
885
+
886
+ function preferredUnit(units: Record<string, XbrlFactEntry[]>): string | null {
887
+ for (const u of UNIT_PREFERENCE) if (units[u]?.length) return u;
888
+ const first = Object.keys(units).find((u) => units[u]?.length);
889
+ return first ?? null;
890
+ }
891
+
892
+ function isAnnualPeriod(e: XbrlFactEntry): boolean {
893
+ if (!e.start) return true; // instant (balance-sheet) fact
894
+ const days = (Date.parse(e.end) - Date.parse(e.start)) / DAY_MS;
895
+ return days >= 340 && days <= 380; // 52/53-week years included, quarters/YTD excluded
896
+ }
897
+
898
+ /**
899
+ * The most recent annual value for one concept: latest period_end among
900
+ * annual-report facts, ties broken by latest filed (a restated figure from a
901
+ * later 10-K wins over the original).
902
+ */
903
+ function latestAnnual(concepts: XbrlConceptMap, concept: string): AnnualValue | null {
904
+ const fact = concepts[concept];
905
+ if (!fact?.units) return null;
906
+ const unit = preferredUnit(fact.units);
907
+ if (!unit) return null;
908
+ let best: XbrlFactEntry | null = null;
909
+ for (const e of fact.units[unit]) {
910
+ if (!e.form || !ANNUAL_FORMS$shared.has(e.form) || !e.end || typeof e.val !== 'number') continue;
911
+ if (!isAnnualPeriod(e)) continue;
912
+ if (
913
+ !best ||
914
+ e.end > best.end ||
915
+ (e.end === best.end && (e.filed ?? '') > (best.filed ?? ''))
916
+ ) best = e;
917
+ }
918
+ if (!best) return null;
919
+ return {
920
+ year: fiscalYearOf(best.end),
921
+ value: best.val,
922
+ filed: best.filed ?? '',
923
+ ...(best.start ? { period_start: best.start } : {}),
924
+ period_end: best.end,
925
+ form: best.form ?? '',
926
+ unit,
927
+ concept,
928
+ };
929
+ }
930
+
931
+ /**
932
+ * Among alternative concepts for the same line item (filers migrate tags —
933
+ * Revenues → RevenueFromContractWithCustomerExcludingAssessedTax under ASC
934
+ * 606), the one with the most recent period wins; ties go to list order.
935
+ */
936
+ function freshestOf(concepts: XbrlConceptMap, candidates: string[]): AnnualValue | null {
937
+ let best: AnnualValue | null = null;
938
+ for (const c of candidates) {
939
+ const hit = latestAnnual(concepts, c);
940
+ if (hit && (!best || hit.period_end > best.period_end)) best = hit;
941
+ }
942
+ return best;
943
+ }
944
+
945
+ /** Canonical line items → the us-gaap concepts filers use for them, most-current-first. */
946
+ const LINE_ITEM_CONCEPTS: Record<string, string[]> = {
947
+ revenue: [
948
+ 'RevenueFromContractWithCustomerExcludingAssessedTax',
949
+ 'Revenues',
950
+ 'SalesRevenueNet',
951
+ 'RevenueFromContractWithCustomerIncludingAssessedTax',
952
+ 'SalesRevenueGoodsNet',
953
+ 'RevenuesNetOfInterestExpense',
954
+ ],
955
+ net_income: ['NetIncomeLoss', 'ProfitLoss'],
956
+ operating_income: ['OperatingIncomeLoss'],
957
+ gross_profit: ['GrossProfit'],
958
+ total_assets: ['Assets'],
959
+ total_liabilities: ['Liabilities'],
960
+ stockholders_equity: [
961
+ 'StockholdersEquity',
962
+ 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest',
963
+ ],
964
+ cash_and_equivalents: [
965
+ 'CashAndCashEquivalentsAtCarryingValue',
966
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
967
+ 'Cash',
968
+ ],
969
+ eps_basic: ['EarningsPerShareBasic'],
970
+ eps_diluted: ['EarningsPerShareDiluted'],
971
+ shares_outstanding: ['CommonStockSharesOutstanding'],
972
+ research_and_development: ['ResearchAndDevelopmentExpense'],
973
+ };
974
+
975
+ interface AnnualFinancialsSummary {
976
+ /** Fiscal year of the filer's most recent annual report, across every concept examined. */
977
+ latest_fiscal_year: number | null;
978
+ latest_period_end: string | null;
979
+ /** One entry per canonical line item, choosing whichever concept the filer currently reports under. */
980
+ latest_annual: Record<string, AnnualValue | null>;
981
+ /** Per-concept detail. Current concepts first, retired (stale) concepts last. */
982
+ key_financials: Record<string, KeyFinancial>;
983
+ /** Concepts the filer has stopped reporting — present in key_financials, flagged, never in latest_annual. */
984
+ stale_concepts: string[];
985
+ year_note: string;
986
+ }
987
+
988
+ /**
989
+ * Build the whole "key financials" view: canonical line items resolved to the
990
+ * concept the filer currently uses, plus per-concept detail with staleness
991
+ * flagged at the point of use and stale concepts sorted last.
992
+ */
993
+ function summarizeAnnualFinancials(concepts: XbrlConceptMap): AnnualFinancialsSummary {
994
+ // Every concept any line item could draw from, in a stable order.
995
+ const perConcept: { concept: string; label: string; value: AnnualValue | null }[] = [];
996
+ const seen = new Set<string>();
997
+ for (const candidates of Object.values(LINE_ITEM_CONCEPTS)) {
998
+ for (const c of candidates) {
999
+ if (seen.has(c) || !concepts[c]) continue;
1000
+ seen.add(c);
1001
+ perConcept.push({ concept: c, label: concepts[c].label, value: latestAnnual(concepts, c) });
1002
+ }
1003
+ }
1004
+
1005
+ let latestEnd: string | null = null;
1006
+ for (const row of perConcept) {
1007
+ if (row.value && (!latestEnd || row.value.period_end > latestEnd)) latestEnd = row.value.period_end;
1008
+ }
1009
+ const latestFy = latestEnd ? fiscalYearOf(latestEnd) : null;
136
1010
 
1011
+ // A concept whose latest annual period ends more than ~100 days before the
1012
+ // filer's latest annual period has been retired by the filer. (Instants and
1013
+ // durations in the same 10-K share an end date, so anything beyond a few
1014
+ // days is a genuinely older report.)
1015
+ const isStale = (v: AnnualValue | null) =>
1016
+ !!v && !!latestEnd && (Date.parse(latestEnd) - Date.parse(v.period_end)) / DAY_MS > 100;
1017
+
1018
+ // Canonical line items: the freshest NON-STALE concept per item. A line the
1019
+ // filer only ever reported under a since-retired concept (Amazon's
1020
+ // GrossProfit stops at FY2009) is null here — the stale value stays in
1021
+ // key_financials, flagged, where it cannot be read as current.
1022
+ const latest_annual: Record<string, AnnualValue | null> = {};
1023
+ for (const [item, candidates] of Object.entries(LINE_ITEM_CONCEPTS)) {
1024
+ const v = freshestOf(concepts, candidates);
1025
+ latest_annual[item] = v && !isStale(v) ? v : null;
1026
+ }
1027
+
1028
+ const itemOf = (concept: string) =>
1029
+ Object.entries(LINE_ITEM_CONCEPTS).find(([, cs]) => cs.includes(concept))?.[0];
1030
+
1031
+ const current = perConcept.filter((r) => !isStale(r.value));
1032
+ const stale = perConcept.filter((r) => isStale(r.value));
1033
+ const key_financials: Record<string, KeyFinancial> = {};
1034
+ for (const r of [...current, ...stale]) {
1035
+ const entry: KeyFinancial = {
1036
+ label: r.label,
1037
+ most_recent_annual: r.value ? stripConcept(r.value) : null,
1038
+ stale: isStale(r.value),
1039
+ };
1040
+ if (entry.stale && r.value) {
1041
+ const item = itemOf(r.concept);
1042
+ const replacement = item ? latest_annual[item] : null;
1043
+ const successor =
1044
+ replacement && replacement.concept !== r.concept
1045
+ ? ` The filer now reports this line under ${replacement.concept} (FY${replacement.year}) — see latest_annual.${item}.`
1046
+ : ' No current concept for this line item was found in the filer\'s us-gaap facts.';
1047
+ entry.stale_note =
1048
+ `STALE: this concept's latest annual value is for FY${r.value.year} (period ending ${r.value.period_end}), ` +
1049
+ `but the filer's most recent annual report covers FY${latestFy}. The number is real history, not the company's current figure.` +
1050
+ successor;
1051
+ }
1052
+ key_financials[r.concept] = entry;
1053
+ }
1054
+
1055
+ return {
1056
+ latest_fiscal_year: latestFy,
1057
+ latest_period_end: latestEnd,
1058
+ latest_annual,
1059
+ key_financials,
1060
+ stale_concepts: stale.map((r) => r.concept),
1061
+ year_note:
1062
+ '`year` is the fiscal year the reporting period ENDS in, derived from period_end. It is NOT the SEC `fy` field, ' +
1063
+ 'which stamps prior-year comparatives with the year of the filing that restated them.',
1064
+ };
1065
+ }
1066
+
1067
+ function stripConcept(v: AnnualValue): Omit<AnnualValue, 'concept'> {
1068
+ const { concept: _concept, ...rest } = v;
1069
+ return rest;
1070
+ }
137
1071
  /**
138
1072
  * EDGAR MCP — SEC EDGAR public APIs (free, no auth)
139
1073
  *
@@ -143,12 +1077,24 @@ function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEn
143
1077
  * - edgar_company_facts: get structured XBRL financial data for a company
144
1078
  * - edgar_company_concept: get a specific financial metric over time
145
1079
  * - edgar_ticker_to_cik: look up CIK from ticker symbol
1080
+ * - edgar_company_snapshot: CIK + recent filings + headline XBRL figures in ONE call
1081
+ * (the resolve -> filings -> concept chain, collapsed; fleet #2182)
146
1082
  * - edgar_filing_documents: list the documents inside one filing by accession number
1083
+ * - edgar_filing_text: full/paged plaintext of a filing's primary document (by section optional)
147
1084
  *
148
1085
  * Note: SEC requires a descriptive User-Agent header per their guidelines.
149
1086
  */
150
1087
 
151
1088
 
1089
+ // Bound every fetch() in this pack to a fixed timeout — an upstream that
1090
+ // degrades without erroring would otherwise hold the Worker in `await fetch()`
1091
+ // until its own execution budget kills the request (minutes, not seconds).
1092
+ // Mirrors the epoFetch / usaspending retryFetch pattern (fleet #685).
1093
+ async function pwFetch(url: string | URL, init?: RequestInit): Promise<Response> {
1094
+ return fetchWithTimeout(url, init ?? {}, 'Edgar');
1095
+ }
1096
+
1097
+
152
1098
  const EFTS_BASE = 'https://efts.sec.gov/LATEST';
153
1099
  const DATA_BASE = 'https://data.sec.gov';
154
1100
  const SEC_HEADERS: Record<string, string> = {
@@ -160,7 +1106,8 @@ const tools: McpToolExport['tools'] = [
160
1106
  {
161
1107
  name: 'edgar_search_filings',
162
1108
  description:
163
- 'PREFER OVER WEB SEARCH for "what did $COMPANY say about X in their SEC filings" or "find filings that mention Y". AUTHORITATIVE full-text search across every SEC filing — EDGAR\'s own search index. Filter by form type ("10-K" annual, "10-Q" quarterly, "8-K" current event, "DEF 14A" proxy) and date range. Returns filing metadata + accession numbers + document links. Use when you need to find filings matching a topic across the whole market, not for a specific company (for that use edgar_company_filings).',
1109
+ 'PREFER OVER WEB SEARCH for "what did $COMPANY say about X in their SEC filings" or "find filings that mention Y". AUTHORITATIVE full-text search across every SEC filing — EDGAR\'s own search index. Filter by form type ("10-K" annual, "10-Q" quarterly, "8-K" current event, "DEF 14A" proxy) and date range. Returns entity name, CIK, form type, filing/period dates, location, accession number (feed straight into edgar_filing_text / edgar_filing_documents — no second lookup), and — for 8-K results — the `items` array of item codes (e.g. "3.01" listing deficiency vs "1.01" material agreement vs "3.02" unregistered sale), which carry the actual signal. Use when you need to find filings matching a topic across the whole market, not for a specific company (for that use edgar_company_filings).',
1110
+ summary: 'Full-text search across every SEC filing, using EDGAR\'s own search index.',
164
1111
  inputSchema: {
165
1112
  type: 'object' as const,
166
1113
  properties: {
@@ -179,126 +1126,330 @@ const tools: McpToolExport['tools'] = [
179
1126
  {
180
1127
  name: 'edgar_company_filings',
181
1128
  description:
182
- 'AUTHORITATIVE list of recent SEC filings for a specific US public company. Pass a ticker ("AAPL") or CIK ("320193"). Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts.',
1129
+ 'AUTHORITATIVE list of recent SEC filings for a specific US public company. Send the company as `ticker_or_cik` — that argument takes a ticker ("AAPL") or a CIK ("320193"), and `cik` / `ticker` are accepted as aliases for it. Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts. If you also need the headline financials alongside the filings, edgar_company_snapshot returns both in one call.',
1130
+ summary: 'Every SEC filing a US public company has made, newest first.',
183
1131
  inputSchema: {
184
1132
  type: 'object' as const,
185
1133
  properties: {
186
1134
  ticker_or_cik: {
187
1135
  type: 'string',
188
- description: 'Ticker symbol (e.g., "AAPL") or CIK number (e.g., "320193")',
1136
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker symbol (e.g., "AAPL") or CIK number (e.g., "320193")',
189
1137
  },
190
1138
  form_type: {
191
1139
  type: 'string',
192
1140
  description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K"). Omit for all types.',
193
1141
  },
194
1142
  limit: { type: 'number', description: 'Max filings to return (1-40, default 20)' },
1143
+ cik: {
1144
+ type: 'string',
1145
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1146
+ },
1147
+ ticker: {
1148
+ type: 'string',
1149
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1150
+ },
195
1151
  },
196
- required: ['ticker_or_cik'],
1152
+ // Deliberately empty, with the check done in the handler instead. A flat
1153
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1154
+ // declaring the aliases above and keeping the canonical name required
1155
+ // would reject every call that used one — the gateway's pre-flight
1156
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1157
+ required: [],
1158
+
197
1159
  },
198
1160
  },
199
1161
  {
200
1162
  name: 'edgar_company_facts',
201
1163
  description:
202
- 'AUTHORITATIVE full XBRL fundamentals dump for a US public company by CIK. Returns every reported financial metric (hundreds of concepts: revenue, net income, assets, liabilities, EPS, cash flow lines, segment breakdowns) with annual and historical values pulled straight from the company\'s SEC filings — the official numbers, not estimates. Use when you need the complete fundamental picture vs. one metric (for one metric use edgar_company_concept). Large payload; agents typically use this once to discover available concepts then narrow to edgar_company_concept for follow-up queries.',
1164
+ 'AUTHORITATIVE full XBRL fundamentals dump for a US public company. Send the company as `cik` — that argument takes a TICKER ("NVDA") or a CIK ("320193"), and `ticker` / `ticker_or_cik` are accepted as aliases for it. Returns every reported financial metric (hundreds of concepts: revenue, net income, assets, liabilities, EPS, cash flow lines, segment breakdowns) with annual and historical values pulled straight from the company\'s SEC filings — the official numbers, not estimates. Use when you need the complete fundamental picture vs. one metric (for one metric use edgar_company_concept). Leads with latest_annual — revenue, net income, assets, cash, EPS for the most recent fiscal year, resolved to whichever XBRL concept the filer currently reports under — and flags retired concepts (e.g. a pre-ASC-606 Revenues tag) as stale so a 2010 figure is never mistaken for current. Large payload; agents typically use this once to discover available concepts then narrow to edgar_company_concept for follow-up queries. For just the headline figures plus the recent filings list, edgar_company_snapshot is the smaller one-call answer.',
1165
+ summary: 'Every figure a US public company has reported to the SEC in XBRL, annual and quarterly.',
203
1166
  inputSchema: {
204
1167
  type: 'object' as const,
205
1168
  properties: {
206
1169
  cik: {
207
1170
  type: 'string',
208
- description: 'Ticker ("NVDA") or CIK number ("320193"). Tickers are auto-resolved to CIKs internally.',
1171
+ description: 'REQUIRED (or one of its aliases `ticker` / `ticker_or_cik`). Ticker ("NVDA") or CIK number ("320193"). Tickers are auto-resolved to CIKs internally.',
1172
+ },
1173
+ ticker: {
1174
+ type: 'string',
1175
+ description: 'Alias for `cik` — same thing, a ticker or a CIK. The spelling edgar_fund_holdings and edgar_ticker_to_cik use.',
1176
+ },
1177
+ ticker_or_cik: {
1178
+ type: 'string',
1179
+ description: 'Alias for `cik` — the spelling edgar_company_filings, edgar_insider_transactions and edgar_product_revenue use.',
209
1180
  },
210
1181
  },
211
- required: ['cik'],
1182
+ // Deliberately empty, with the check done in the handler instead. A flat
1183
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1184
+ // declaring the aliases above and keeping the canonical name required
1185
+ // would reject every call that used one — the gateway's pre-flight
1186
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1187
+ required: [],
1188
+
212
1189
  },
213
1190
  },
214
1191
  {
215
1192
  name: 'edgar_company_concept',
216
1193
  description:
217
- 'AUTHORITATIVE historical financials for any US public company. Source: SEC XBRL filings (the official numbers companies file, not third-party scrapes). Pass a ticker or CIK plus a friendly metric name — Revenue, NetIncomeLoss, Cash, LongTermDebt, EarningsPerShareDiluted — and the tool resolves the right XBRL tag for that filer (post-ASC-606 companies use RevenueFromContractWithCustomerExcludingAssessedTax instead of "Revenues", etc.). Returns annual values with fiscal years, period ends, filing types. Use for "what was AAPL\'s revenue in 2024", "show me NVDA\'s long-term debt trend", anything where you need the SEC-filed number rather than an estimate.',
1194
+ 'AUTHORITATIVE historical financials for any US public company. Source: SEC XBRL filings (the official numbers companies file, not third-party scrapes). Send the company as `cik` — that argument takes a TICKER ("AAPL") or a CIK ("320193"), and `ticker` / `ticker_or_cik` are accepted as aliases for it — plus the metric as `concept` (alias `metric`), which takes a friendly name: Revenue, NetIncomeLoss, Cash, LongTermDebt, EarningsPerShareDiluted. The tool resolves the right XBRL tag for that filer (post-ASC-606 companies use RevenueFromContractWithCustomerExcludingAssessedTax instead of "Revenues", etc.). Returns both ANNUAL (10-K) and QUARTERLY (10-Q) values by default, each labeled with fiscal_period (FY/Q1/Q2/Q3/Q4) and form, newest first, PLUS a `latest` field holding the single freshest data point. Q4 rows are DERIVED (FY minus Q1-Q3, marked derived:true) because SEC filers never report a standalone Q4 fact — so "revenue Q4 2024" questions are answerable directly from `values`. For one specific period, pass `fiscal_year` and/or `fiscal_period` as ARGUMENTS and the values array comes back filtered to it (they are also the names of the fields on each returned row, which is what to match on if you ask for every period instead); do not default to `latest` for a period question. Use `latest` for point-in-time metrics like cash, runway, and debt — it is the newest 10-Q when one is more recent than the last 10-K, so a stale annual figure never masks a newer quarter. Use for "what was AAPL\'s revenue in 2024", "NVDA\'s latest cash position", "show me long-term debt trend", anything where you need the SEC-filed number rather than an estimate.',
1195
+ summary: 'One financial metric for a US public company across every period it has reported, from SEC XBRL.',
218
1196
  inputSchema: {
219
1197
  type: 'object' as const,
220
1198
  properties: {
221
1199
  cik: {
222
1200
  type: 'string',
223
- description: 'Ticker (e.g., "AAPL") or CIK number (e.g., "320193"). Tickers are auto-resolved.',
1201
+ description: 'REQUIRED (or one of its aliases `ticker` / `ticker_or_cik`). Ticker (e.g., "AAPL") or CIK number (e.g., "320193"). Tickers are auto-resolved.',
224
1202
  },
225
1203
  concept: {
226
1204
  type: 'string',
227
1205
  description:
228
- 'Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
1206
+ 'REQUIRED (alias `metric`). Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
1207
+ },
1208
+ period: {
1209
+ type: 'string',
1210
+ description: 'Which reporting periods to return: "all" (default — annual 10-K + quarterly 10-Q), "annual" (10-K/20-F/40-F only), or "quarterly" (10-Q only). Point-in-time metrics (cash/runway/debt) usually want the default so the freshest quarter is included; use "annual" for clean year-over-year trends.',
1211
+ enum: ['all', 'annual', 'quarterly'],
1212
+ },
1213
+ fiscal_year: {
1214
+ type: 'string',
1215
+ description: 'Optional filter: return only rows for this fiscal year, e.g. "2024". This is the filer\'s OWN fiscal year label (NVDA\'s FY2024 ended Jan 2024), not a calendar year. Unmatched years are reported with the years that ARE available rather than as an empty result.',
1216
+ },
1217
+ fiscal_period: {
1218
+ type: 'string',
1219
+ description: 'Optional filter: return only rows for this period within the fiscal year — "FY" (annual), "Q1", "Q2", "Q3", or "Q4" (derived: FY minus Q1-Q3). Combine with fiscal_year for a single figure.',
1220
+ enum: ['FY', 'Q1', 'Q2', 'Q3', 'Q4'],
1221
+ },
1222
+ ticker: {
1223
+ type: 'string',
1224
+ description: 'Alias for `cik` — same thing, a ticker or a CIK. Declared because sibling SEC tools name this argument differently (edgar_fund_holdings uses `ticker`, edgar_company_filings uses `ticker_or_cik`) and a caller filling arguments from prose reaches for whichever it read; all three spellings work here.',
1225
+ },
1226
+ ticker_or_cik: {
1227
+ type: 'string',
1228
+ description: 'Alias for `cik` — the spelling used by edgar_company_filings, edgar_insider_transactions and edgar_product_revenue.',
1229
+ },
1230
+ metric: {
1231
+ type: 'string',
1232
+ description: 'Alias for `concept` — the word this tool\'s own description uses for the thing you are asking about.',
229
1233
  },
230
1234
  },
231
- required: ['cik', 'concept'],
1235
+ // Deliberately empty, with the check done in companyConcept() instead —
1236
+ // the same pattern edgar_filing_text/edgar_filing_documents already use.
1237
+ // A flat `required` list cannot express "cik OR ticker OR ticker_or_cik",
1238
+ // so declaring the aliases above and keeping `cik` required would reject
1239
+ // every call that used one, which is exactly the production failure this
1240
+ // replaces: 12 of 12 `partial` outcomes on this tool over 3 days had the
1241
+ // adjudicator fill `ticker` (4 of them `metric` too), and the gateway's
1242
+ // pre-flight rejected them with "cik is required" before the pack ran
1243
+ // (fleet #2058). The pack-side check below names all accepted spellings.
1244
+ required: [],
232
1245
  },
233
1246
  },
234
1247
  {
235
1248
  name: 'edgar_insider_transactions',
236
1249
  description:
237
- 'AUTHORITATIVE insider trading activity (SEC Form 3/4/5) for a US public company — who bought or sold, how many shares, at what price, and what they hold now. Pass a ticker ("TSLA") or CIK. Returns each recent Form 4 filing parsed into structured transactions: reporting owner + role (director/officer/10% holder), transaction code (P=open-market purchase, S=sale, A=grant/award, M=option exercise, G=gift, F=tax-withholding), shares, price per share, acquired/disposed, and shares owned after. Use for "insider buying at $TICKER", "did executives sell recently", "latest Form 4 activity". Open-market purchases (code P) are the strongest conviction signal; awards (code A) are routine comp. For the raw filing list use edgar_company_filings with form_type:"4".',
1250
+ 'AUTHORITATIVE insider trading activity (SEC Form 3/4/5) for a US public company — who bought or sold, how many shares, at what price, and what they hold now. Send the company as `ticker_or_cik` — a ticker ("TSLA") or a CIK — and `cik` / `ticker` are accepted as aliases for it. Returns each recent Form 4 filing parsed into structured transactions: reporting owner + role (director/officer/10% holder), transaction code (P=open-market purchase, S=sale, A=grant/award, M=option exercise, G=gift, F=tax-withholding), shares, price per share, acquired/disposed, and shares owned after. Use for "insider buying at $TICKER", "did executives sell recently", "latest Form 4 activity". Open-market purchases (code P) are the strongest conviction signal; awards (code A) are routine comp. For the raw filing list use edgar_company_filings with form_type:"4".',
1251
+ summary: 'Who bought or sold a company\'s stock as an insider, from SEC Forms 3, 4 and 5.',
238
1252
  inputSchema: {
239
1253
  type: 'object' as const,
240
1254
  properties: {
241
1255
  ticker_or_cik: {
242
1256
  type: 'string',
243
- description: 'Ticker symbol (e.g., "TSLA") or CIK number (e.g., "1318605")',
1257
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker symbol (e.g., "TSLA") or CIK number (e.g., "1318605")',
244
1258
  },
245
1259
  limit: { type: 'number', description: 'Max Form 4/3/5 filings to parse (1-25, default 10)' },
246
1260
  include_derivatives: {
247
1261
  type: 'boolean',
248
1262
  description: 'Also include derivative (options/RSU) transactions. Default false (non-derivative common-stock only).',
249
1263
  },
1264
+ cik: {
1265
+ type: 'string',
1266
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1267
+ },
1268
+ ticker: {
1269
+ type: 'string',
1270
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1271
+ },
250
1272
  },
251
- required: ['ticker_or_cik'],
1273
+ // Deliberately empty, with the check done in the handler instead. A flat
1274
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1275
+ // declaring the aliases above and keeping the canonical name required
1276
+ // would reject every call that used one — the gateway's pre-flight
1277
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1278
+ required: [],
1279
+
252
1280
  },
253
1281
  },
254
1282
  {
255
1283
  name: 'edgar_institutional_holdings',
256
1284
  description:
257
- "AUTHORITATIVE stock portfolio of a large institutional investor (SEC Form 13F-HR) — what a fund/manager owns, share counts, and position values. Pass the MANAGER's ticker or CIK (e.g. \"BRK-B\" or CIK \"1067983\" for Berkshire Hathaway; \"1350694\" for Bridgewater). Returns the latest quarterly 13F: top holdings aggregated by issuer with value (USD), shares, and % of portfolio, plus the report period. Use for \"what does Berkshire own\", \"Bridgewater's biggest positions\", \"which funds hold $TICKER\" (run per manager). Note: 13F covers US-listed long equity + options held by managers with >$100M AUM, filed ~45 days after quarter-end; it excludes shorts, cash, and non-US holdings. Values are whole USD for filings since 2023; older ones are in thousands.",
1285
+ "AUTHORITATIVE stock portfolio of a large institutional investor (SEC Form 13F-HR) — what a fund/manager owns, share counts, and position values. Pass the MANAGER's ticker or CIK (e.g. \"BRK-B\" or CIK \"1067983\" for Berkshire Hathaway; \"1350694\" for Bridgewater). Returns the latest quarterly 13F: top holdings aggregated by issuer with value (USD), shares, and % of portfolio, plus the report period. Use for \"what does Berkshire own\", \"Bridgewater's biggest positions\", \"which funds hold $TICKER\" (run per manager). Note: 13F covers US-listed long equity + options held by managers with >$100M AUM, filed ~45 days after quarter-end; it excludes shorts, cash, and non-US holdings. Values are whole USD for filings since 2023; older ones are in thousands. IMPORTANT: rows carry a `put_call` field and a plain-English `direction`. A `put` row is a BEARISH bet AGAINST that issuer — never report it as a holding the manager owns — and for option rows the value is the underlying's notional, not premium or capital at risk. Rank real holdings by `pct_of_long_equity`, and read `position_summary` + `interpretation_note` before summarising.",
1286
+ summary: 'What a large institutional investor holds, from its SEC Form 13F filing.',
258
1287
  inputSchema: {
259
1288
  type: 'object' as const,
260
1289
  properties: {
261
1290
  ticker_or_cik: {
262
1291
  type: 'string',
263
- description: 'The institutional manager\'s ticker (e.g. "BRK-B") or CIK (e.g. "1067983"). NOT the held stock — the fund/manager doing the filing.',
1292
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). The institutional manager\'s ticker (e.g. "BRK-B") or CIK (e.g. "1067983"). NOT the held stock — the fund/manager doing the filing.',
264
1293
  },
265
1294
  limit: { type: 'number', description: 'Top N holdings by value to return (1-100, default 25)' },
1295
+ cik: {
1296
+ type: 'string',
1297
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1298
+ },
1299
+ ticker: {
1300
+ type: 'string',
1301
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1302
+ },
1303
+ },
1304
+ // Deliberately empty, with the check done in the handler instead. A flat
1305
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1306
+ // declaring the aliases above and keeping the canonical name required
1307
+ // would reject every call that used one — the gateway's pre-flight
1308
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1309
+ required: [],
1310
+
1311
+ },
1312
+ },
1313
+ {
1314
+ name: 'edgar_fund_holdings',
1315
+ description:
1316
+ "AUTHORITATIVE portfolio holdings of a US ETF or mutual fund (SEC Form N-PORT) — what the fund actually owns. Pass the FUND's ticker (e.g. \"ARKK\", \"QQQ\", \"VTI\", \"VOO\", \"IVV\"). Returns the latest monthly portfolio: net assets, holdings count, and top positions by weight — each with name, CUSIP, value (USD), and % of fund. Use for \"what does ARKK hold\", \"top holdings of QQQ\", \"is $STOCK in VTI\". Distinct from edgar_institutional_holdings (13F = what an investment MANAGER like Berkshire owns); this is a registered fund's own N-PORT. Covers US-registered open-end funds + ETFs; data is ~30-60 days delayed. Note: a few legacy ETFs structured as unit investment trusts (e.g. SPY, DIA) don't file N-PORT and won't resolve — use IVV or VOO for S&P 500 exposure.",
1317
+ summary: 'What a US ETF or mutual fund owns, from its monthly SEC Form N-PORT filing.',
1318
+ inputSchema: {
1319
+ type: 'object' as const,
1320
+ properties: {
1321
+ ticker: { type: 'string', description: 'REQUIRED (or its alias `ticker_or_cik`). ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
1322
+ limit: { type: 'number', description: 'Top N holdings by weight to return (1-100, default 25)' },
1323
+ ticker_or_cik: {
1324
+ type: 'string',
1325
+ description: 'Alias for `ticker` — the spelling sibling SEC tools (edgar_company_filings, edgar_insider_transactions) use. Must still be a FUND ticker: N-PORT funds are keyed by ticker, so a bare CIK will not resolve here.',
1326
+ },
1327
+ },
1328
+ // Deliberately empty, with the check done in fundHoldings() instead —
1329
+ // a flat `required` list cannot express "ticker OR ticker_or_cik", so
1330
+ // keeping `ticker` required would reject every aliased call at the
1331
+ // gateway's pre-flight before the pack ran (fleet #2069).
1332
+ required: [],
1333
+ },
1334
+ },
1335
+ {
1336
+ name: 'edgar_company_snapshot',
1337
+ description:
1338
+ 'ONE CALL for "give me the SEC picture on $TICKER" / "what has $COMPANY filed recently and what are its numbers" / "pull the filings and financials for X". Resolves a ticker, company name or CIK and returns, from SEC EDGAR, the three things callers otherwise chain by hand across edgar_ticker_to_cik -> edgar_company_filings -> edgar_company_concept: the identity (cik, company_name, tickers, SIC code, fiscal year end), the recent filings list (accession numbers, form types, filing dates, document links — by default the substantive forms 10-K/10-Q/8-K/20-F/40-F/6-K/DEF 14A, so insider Form 4 noise is excluded; pass `form_type` for one form or "all"), and the headline XBRL figures from the latest annual report (revenue, net income, operating income, gross profit, assets, liabilities, equity, cash, EPS, shares, R&D — each resolved to the concept the filer CURRENTLY reports under, with retired concepts listed separately as stale). Send the company as `ticker_or_cik`; `cik` / `ticker` are accepted aliases. A filer with no XBRL facts (a fund, a trust, a foreign private issuer on paper forms) still returns its filings, with `financials_status: "unavailable"` and a reason, not an error. Drill down from here: edgar_filing_text for a filing\'s text, edgar_company_concept for one metric\'s multi-year history, edgar_company_facts for every concept. For a cross-source view (patents, contracts, hiring, news) use entity_profile instead.',
1339
+ summary: 'A US public company\'s SEC identity, recent filings and headline financials in one call.',
1340
+ inputSchema: {
1341
+ type: 'object' as const,
1342
+ properties: {
1343
+ ticker_or_cik: {
1344
+ type: 'string',
1345
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker ("AAPL"), company name ("Apple Inc") or CIK ("320193"). Tickers and names are resolved to a CIK internally.',
1346
+ },
1347
+ cik: {
1348
+ type: 'string',
1349
+ description: 'Alias for `ticker_or_cik` — same thing. The spelling edgar_company_facts and edgar_company_concept use.',
1350
+ },
1351
+ ticker: {
1352
+ type: 'string',
1353
+ description: 'Alias for `ticker_or_cik` — same thing. The spelling edgar_ticker_to_cik uses.',
1354
+ },
1355
+ form_type: {
1356
+ type: 'string',
1357
+ description: 'Which filings to list. Omit for the substantive default set (10-K, 10-K/A, 10-Q, 10-Q/A, 8-K, 20-F, 40-F, 6-K, DEF 14A). Pass one form ("10-K") to list only that form, or "all" for every form including Form 4 insider filings.',
1358
+ },
1359
+ filings_limit: {
1360
+ type: 'number',
1361
+ description: 'How many filings to return after the form filter (1-40, default 10).',
1362
+ },
1363
+ },
1364
+ // Deliberately empty — a flat `required` list cannot express
1365
+ // "ticker_or_cik OR cik OR ticker"; the handler refuses with a message
1366
+ // that names all three (fleet #2058 / #2069).
1367
+ required: [],
1368
+ },
1369
+ },
1370
+ {
1371
+ name: 'edgar_ticker_to_cik',
1372
+ description:
1373
+ 'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives` for disambiguation. Cheap, no rate limit concerns. Most other tools also accept tickers/names directly and call this internally — only use it explicitly when you want the CIK as data. The response carries a `next` hint: the usual NEXT step after resolving is edgar_company_snapshot({ticker_or_cik}), which returns the recent filings list AND the headline XBRL financials in one call — do not chain edgar_company_filings then edgar_company_concept by hand to get that.',
1374
+ summary: 'The SEC CIK number for a stock ticker or company name, from EDGAR\'s company list.',
1375
+ inputSchema: {
1376
+ type: 'object' as const,
1377
+ properties: {
1378
+ ticker: {
1379
+ type: 'string',
1380
+ description: 'REQUIRED (or one of its aliases `ticker_or_cik` / `company`). Stock ticker symbol (e.g., "AAPL", "MSFT", "TSLA") or company name (e.g., "Apple", "Microsoft")',
1381
+ },
1382
+ ticker_or_cik: {
1383
+ type: 'string',
1384
+ description: 'Alias for `ticker` — the spelling edgar_company_filings and edgar_insider_transactions use. A ticker or company name; this tool RESOLVES to a CIK, so passing a bare CIK has nothing to look up.',
1385
+ },
1386
+ company: {
1387
+ type: 'string',
1388
+ description: 'Alias for `ticker` — use it when what you have is a company NAME ("Apple Inc.") rather than a symbol. Same argument, same behaviour.',
1389
+ },
266
1390
  },
267
- required: ['ticker_or_cik'],
1391
+ // Deliberately empty, with the check done in tickerToCik() instead —
1392
+ // a flat `required` list cannot express "ticker OR ticker_or_cik OR
1393
+ // company" (fleet #2069).
1394
+ required: [],
268
1395
  },
269
1396
  },
270
1397
  {
271
- name: 'edgar_fund_holdings',
1398
+ name: 'sponsor_to_filer',
272
1399
  description:
273
- "AUTHORITATIVE portfolio holdings of a US ETF or mutual fund (SEC Form N-PORT) — what the fund actually owns. Pass the FUND's ticker (e.g. \"ARKK\", \"QQQ\", \"VTI\", \"VOO\", \"IVV\"). Returns the latest monthly portfolio: net assets, holdings count, and top positions by weight — each with name, CUSIP, value (USD), and % of fund. Use for \"what does ARKK hold\", \"top holdings of QQQ\", \"is $STOCK in VTI\". Distinct from edgar_institutional_holdings (13F = what an investment MANAGER like Berkshire owns); this is a registered fund's own N-PORT. Covers US-registered open-end funds + ETFs; data is ~30-60 days delayed. Note: a few legacy ETFs structured as unit investment trusts (e.g. SPY, DIA) don't file N-PORT and won't resolve — use IVV or VOO for S&P 500 exposure.",
1400
+ 'Resolve an organization NAME — especially a clinical-trial sponsor, drug developer, or operating subsidiary — to the US-listed public FILER that reports it (ticker + SEC CIK). Built for the join that plain ticker/name lookup fails: trial registries (ClinicalTrials.gov) name operating subsidiaries ("Merck Sharp and Dohme"), while SEC names the listed parent ("Merck & Co", MRK). This tool bridges that gap and, crucially, tells you WHY a name does not resolve instead of collapsing every miss to "not found". Returns a `status`: "resolved" (name is itself a US-listed filer), "resolved_via_parent" (name is a subsidiary; resolved to its listed parent, with evidence + confidence), "us_registrant_unlisted" (has an SEC CIK but no public listing and no listed parent — typically a private company that filed a Form D or draft registration), or "no_us_registrant" (no US SEC presence at all — typically a non-US-listed or foreign private company). Use before joining trial sponsors to public financials, ownership, or filings.',
1401
+ summary: 'The SEC filer entities linked to a plan sponsor, from EDGAR Form 5500 filings.',
274
1402
  inputSchema: {
275
1403
  type: 'object' as const,
276
1404
  properties: {
277
- ticker: { type: 'string', description: 'ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
278
- limit: { type: 'number', description: 'Top N holdings by weight to return (1-100, default 25)' },
1405
+ sponsor: {
1406
+ type: 'string',
1407
+ description: 'Organization name to resolve — a trial sponsor, drug developer, or company name, e.g. "Merck Sharp and Dohme", "Lexeo Therapeutics", "Dizal Pharmaceuticals".',
1408
+ },
279
1409
  },
280
- required: ['ticker'],
1410
+ required: ['sponsor'],
281
1411
  },
282
1412
  },
283
1413
  {
284
- name: 'edgar_ticker_to_cik',
1414
+ name: 'filer_to_sponsors',
285
1415
  description:
286
- 'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives` for disambiguation. Cheap, no rate limit concerns. Most other tools also accept tickers/names directly and call this internally — only use it explicitly when you want the CIK as data.',
1416
+ 'The REVERSE of sponsor_to_filer: given a US-listed public FILER (parent company), list its operating subsidiaries as disclosed in Exhibit 21 of its most recent 10-K (Item 601(b)(21) — "significant subsidiaries"). Built for the same trial-sponsor/entity-resolution join, run the other direction: instead of ~10 calls guessing candidate subsidiary names and confirming each via sponsor_to_filer, get the parent\'s full disclosed subsidiary list (with jurisdiction of incorporation) in one call, straight from SEC — e.g. Merck (MRK/CIK 310158) -> "Merck Sharp & Dohme LLC" among hundreds of others. Pass `name_filter` (case-insensitive substring) to check whether a specific candidate name is among the subsidiaries without reading the whole list. Every result carries provenance (accession number, filing date, exhibit URL) so the join is auditable. Smaller filers or ones with no significant subsidiaries can genuinely have no Exhibit 21 — status distinguishes that from a lookup failure. Foreign private issuers (20-F filers) are not yet covered.',
1417
+ summary: 'The plan sponsors linked to an SEC filer entity, from EDGAR Form 5500 filings.',
287
1418
  inputSchema: {
288
1419
  type: 'object' as const,
289
1420
  properties: {
1421
+ ticker_or_cik: {
1422
+ type: 'string',
1423
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). The PARENT company\'s ticker (e.g. "MRK") or CIK (e.g. "310158"). Tickers are auto-resolved to CIKs.',
1424
+ },
1425
+ name_filter: {
1426
+ type: 'string',
1427
+ description: 'Optional case-insensitive substring to filter subsidiary names by, e.g. "Sharp & Dohme" to check whether that entity is among the parent\'s disclosed subsidiaries. Subsidiary names in Exhibit 21 use "&", not "and".',
1428
+ },
1429
+ limit: { type: 'number', description: 'Max subsidiaries to return (1-500, default 200). Large parents can disclose 500+.' },
1430
+ cik: {
1431
+ type: 'string',
1432
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1433
+ },
290
1434
  ticker: {
291
1435
  type: 'string',
292
- description: 'Stock ticker symbol (e.g., "AAPL", "MSFT", "TSLA") or company name (e.g., "Apple", "Microsoft")',
1436
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
293
1437
  },
294
1438
  },
295
- required: ['ticker'],
1439
+ // Deliberately empty, with the check done in the handler instead. A flat
1440
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1441
+ // declaring the aliases above and keeping the canonical name required
1442
+ // would reject every call that used one — the gateway's pre-flight
1443
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1444
+ required: [],
1445
+
296
1446
  },
297
1447
  },
298
1448
  {
299
1449
  name: 'edgar_xbrl_frames',
300
1450
  description:
301
1451
  'Compare ONE financial metric across ALL public companies for a single period (SEC XBRL "frames"). PREFER OVER WEB SEARCH for "which companies had the most revenue/net income/assets in <year>", "rank companies by <metric>", cross-company financial comparison. concept is a US-GAAP tag (e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense", "CashAndCashEquivalentsAtCarryingValue"). period is a calendar frame: "CY2023" (annual), "CY2023Q1" (quarter), or "CY2023Q1I" (instant/balance-sheet, period-end). Returns companies + values, sorted descending by default. Differs from edgar_company_concept (one company over time) — this is one period across every filer.',
1452
+ summary: 'One XBRL financial concept\'s reported value across every US public company for one period, from EDGAR.',
302
1453
  inputSchema: {
303
1454
  type: 'object' as const,
304
1455
  properties: {
@@ -312,10 +1463,60 @@ const tools: McpToolExport['tools'] = [
312
1463
  required: ['concept', 'period'],
313
1464
  },
314
1465
  },
1466
+ {
1467
+ name: 'edgar_companies_by_sic',
1468
+ description:
1469
+ 'AUTHORITATIVE peer / competitor lookup: find every SEC filer classified under one SIC (Standard Industrial Classification) industry code. PREFER OVER WEB SEARCH for "who are $COMPANY\'s public competitors/peers", "list companies in <industry>", "which filers are in SIC <code>". Pass EITHER `sic` directly (a 2-4 digit code, e.g. "3571" = Electronic Computers) OR `ticker_or_cik` for a company whose own SIC should be looked up first and then used to find its peers (self excluded by default). Returns each peer\'s CIK, and ticker + company_name when the filer has a listed ticker — results are sorted so currently-listed peers come first, since an SIC bucket covers every filer that EVER filed the form (many delisted/defunct); unlisted registrants still appear after them with ticker:null rather than being dropped. Source: SEC EDGAR company-search (browse-edgar), filtered to filers who have filed the given form_type (default "10-K", i.e. active public reporters — omitting this filter is unreliable upstream). Note: SIC is a broad, sometimes dated bucket assigned once at registration — treat this as a peer-set STARTING POINT, not a precise competitor list.',
1470
+ summary: 'Every SEC filer classified under one industry code — a company\'s public peers.',
1471
+ inputSchema: {
1472
+ type: 'object' as const,
1473
+ properties: {
1474
+ sic: { type: 'string', description: 'SIC code to search directly, e.g. "3571" (Electronic Computers), "2836" (Biological Products), "6021" (National Commercial Banks). Provide this OR ticker_or_cik.' },
1475
+ ticker_or_cik: { type: 'string', description: 'Ticker (e.g. "AAPL") or CIK of a company whose SIC should be resolved first, then used to find its peers. Provide this OR sic. Aliases: `cik`, `ticker`.' },
1476
+ cik: { type: 'string', description: 'Alias for `ticker_or_cik` — takes a ticker or a CIK. Declared because sibling SEC tools spell this argument differently.' },
1477
+ ticker: { type: 'string', description: 'Alias for `ticker_or_cik` — takes a ticker or a CIK. Declared because sibling SEC tools spell this argument differently.' },
1478
+ form_type: { type: 'string', description: 'Only include filers who have filed this form type (default "10-K" — active public reporters). SEC\'s upstream search is unreliable with this left blank.' },
1479
+ exclude_self: { type: 'boolean', description: 'When resolving via ticker_or_cik, exclude that company itself from the peer list. Default true.' },
1480
+ limit: { type: 'number', description: 'Max peers to return (1-100, default 25).' },
1481
+ },
1482
+ },
1483
+ },
1484
+ {
1485
+ name: 'edgar_product_revenue',
1486
+ description:
1487
+ 'PRODUCT-LEVEL or segment-level revenue as STRUCTURED data — e.g. "how much revenue did Keytruda generate", "AAPL revenue by product line". Regular XBRL tools (edgar_company_concept, edgar_company_facts) only expose UNDIMENSIONED totals; a filer\'s product/segment breakdown is tagged with an XBRL dimension (e.g. a "Keytruda [Member]"), which those APIs cannot see no matter which concept is requested. This tool reads SEC\'s own standardized "Financial Report" rendering of that dimensional data straight out of the annual or quarterly segment-reporting / revenue-disaggregation note — 10-K and 10-Q for US filers, 20-F for foreign private issuers (Novartis, AstraZeneca, GSK, Sanofi, Novo Nordisk, Takeda) and 40-F for Canadian MJDS filers, resolved automatically and reported back as `resolved_form` — the same note human analysts read, but pre-parsed into rows. Pass `product_filter` (case-insensitive substring, matched against the dimension breadcrumb, e.g. "Keytruda") to get just one product/segment instead of the whole table. Every result carries a citation (accession, filing date, exact report + URL it came from). Not every filer discloses product-level revenue in XBRL, and a small fraction use a non-standard table layout this parser can\'t read — both are reported as an explicit status rather than a silent empty array, with a fallback to edgar_filing_text for the prose note.',
1488
+ summary: 'A US public company\'s revenue broken out by product or segment, from its EDGAR XBRL filings.',
1489
+ inputSchema: {
1490
+ type: 'object' as const,
1491
+ properties: {
1492
+ ticker_or_cik: { type: 'string', description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker (e.g. "MRK") or CIK (e.g. "310158"). Tickers are auto-resolved.' },
1493
+ product_filter: { type: 'string', description: 'Optional case-insensitive substring to match against the product/segment dimension, e.g. "Keytruda". Omit to get every disaggregated row in the table.' },
1494
+ form_type: { type: 'string', description: 'Filing type to read the note from. Omit it to try 10-K, then 20-F, then 40-F automatically — foreign private issuers (NVS, AZN, GSK, SNY, NVO, TAK) file 20-F and Canadian MJDS filers file 40-F. "10-Q" also works for filers that disaggregate revenue quarterly. The form actually used comes back as `resolved_form`.' },
1495
+ accession: { type: 'string', description: 'Optional exact accession number (from edgar_company_filings) to read a specific past filing instead of the latest matching form_type.' },
1496
+ limit: { type: 'number', description: 'Max rows to return (1-200, default 100).' },
1497
+ cik: {
1498
+ type: 'string',
1499
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1500
+ },
1501
+ ticker: {
1502
+ type: 'string',
1503
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1504
+ },
1505
+ },
1506
+ // Deliberately empty, with the check done in the handler instead. A flat
1507
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1508
+ // declaring the aliases above and keeping the canonical name required
1509
+ // would reject every call that used one — the gateway's pre-flight
1510
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1511
+ required: [],
1512
+
1513
+ },
1514
+ },
315
1515
  {
316
1516
  name: 'edgar_filing_documents',
317
1517
  description:
318
1518
  'AUTHORITATIVE list of the SEC filing documents inside ONE specific filing, by accession number. Retrieve a filing / its contents / attachments: pass the accession (e.g. "0000320193-25-000079", with or without dashes) plus the filer\'s ticker ("AAPL") or CIK ("320193"). Returns every document in the filing folder — the primary document (10-K / 10-Q / 8-K body), all exhibits, and XBRL files — each with name, type, size, and a direct https URL, plus the filing\'s form type, filing date, and human -index.html page. Set include_primary_text:true to also pull the primary document\'s text (HTML stripped to plaintext, ~40k chars). Use to list a 10-K / 10-Q / 8-K\'s exhibits, retrieve filing contents/attachments, or fetch the text of a filing. You can pass an exact accession, OR just a ticker + form_type to auto-resolve the latest matching filing (no accession lookup needed). Examples: edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the documents in NVIDIA\'s latest annual report; edgar_filing_documents({accession: "0000320193-25-000079", ticker: "AAPL", include_primary_text: true}) for a specific filing\'s text.',
1519
+ summary: 'The documents and attachments inside one SEC filing, by accession number.',
319
1520
  inputSchema: {
320
1521
  type: 'object' as const,
321
1522
  properties: {
@@ -337,7 +1538,56 @@ const tools: McpToolExport['tools'] = [
337
1538
  },
338
1539
  include_primary_text: {
339
1540
  type: 'boolean',
340
- description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false.',
1541
+ description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false. For the FULL, pageable document text — or just one section like going-concern/liquidity — use edgar_filing_text instead.',
1542
+ },
1543
+ ticker_or_cik: {
1544
+ type: 'string',
1545
+ description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_company_filings and edgar_insider_transactions use. Takes either a ticker or a CIK.',
1546
+ },
1547
+ },
1548
+ required: [],
1549
+ },
1550
+ },
1551
+ {
1552
+ name: 'edgar_filing_text',
1553
+ description:
1554
+ 'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, and the liquidity footnote. Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events). Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page and can make a late `section` (e.g. subsequent_events) come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
1555
+ summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1556
+ inputSchema: {
1557
+ type: 'object' as const,
1558
+ properties: {
1559
+ accession: {
1560
+ type: 'string',
1561
+ description: 'SEC accession number, dashed or not (e.g. "0001683168-26-003909"). Omit to auto-resolve the latest filing of form_type for the given ticker/cik.',
1562
+ },
1563
+ ticker: {
1564
+ type: 'string',
1565
+ description: 'Filer ticker (e.g. "ACTU"). Provide this OR cik. ONLY pass a ticker you are CERTAIN of — a wrong remembered ticker silently retrieves a DIFFERENT company\'s filing as a clean success (a "SpaceX" question filled with SPCE returns Virgin Galactic\'s S-1). For a recent IPO or any uncertain ticker, resolve first: edgar_company_filings accepts the company NAME and returns the cik — pass that cik here.',
1566
+ },
1567
+ cik: {
1568
+ type: 'string',
1569
+ description: 'Filer CIK number (e.g. "1652935"). Provide this OR ticker.',
1570
+ },
1571
+ form_type: {
1572
+ type: 'string',
1573
+ description: 'When accession is omitted, the form type of the latest filing to fetch — "10-K", "10-Q", "8-K", "DEF 14A", etc.',
1574
+ },
1575
+ section: {
1576
+ type: 'string',
1577
+ enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events'],
1578
+ description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false).',
1579
+ },
1580
+ max_chars: {
1581
+ type: 'number',
1582
+ description: 'Max characters to return in this page (1000–100000, default 50000). Doc text past this is available via next_offset.',
1583
+ },
1584
+ offset: {
1585
+ type: 'number',
1586
+ description: 'Character offset to start from (default 0). Pass the prior result\'s next_offset to page forward.',
1587
+ },
1588
+ ticker_or_cik: {
1589
+ type: 'string',
1590
+ description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_company_filings and edgar_insider_transactions use. Takes either a ticker or a CIK.',
341
1591
  },
342
1592
  },
343
1593
  required: [],
@@ -374,21 +1624,25 @@ async function searchFilings(
374
1624
  if (endDate) params.set('enddt', endDate);
375
1625
  }
376
1626
 
377
- const res = await fetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
378
- if (!res.ok) throw new Error(`SEC EDGAR search error: ${res.status}`);
1627
+ const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
1628
+ if (!res.ok) throw await httpError(res, 'SEC EDGAR search error');
379
1629
 
380
1630
  const data = (await res.json()) as {
381
1631
  hits: {
382
1632
  hits: {
383
1633
  _source: {
384
- entity_name: string;
385
- entity_id: string;
386
- file_num: string;
387
- form_type: string;
388
- file_date: string;
389
- period_of_report: string;
390
- biz_location: string;
391
- category: string;
1634
+ ciks?: string[];
1635
+ display_names?: string[];
1636
+ form?: string;
1637
+ file_type?: string;
1638
+ root_forms?: string[];
1639
+ file_date?: string;
1640
+ period_ending?: string;
1641
+ biz_locations?: string[];
1642
+ biz_states?: string[];
1643
+ adsh?: string;
1644
+ items?: string[];
1645
+ file_description?: string;
392
1646
  };
393
1647
  _id: string;
394
1648
  }[];
@@ -396,15 +1650,30 @@ async function searchFilings(
396
1650
  };
397
1651
  };
398
1652
 
1653
+ // EFTS `_source` field names (verified against efts.sec.gov): display_names
1654
+ // ["Name (TICKER) (CIK 000...)"], ciks[], form, period_ending, biz_locations[],
1655
+ // adsh (accession), and — for 8-Ks — items[] (e.g. "3.01"). The prior mapper
1656
+ // read entity_name/form_type/biz_location, which do NOT exist in this payload,
1657
+ // so every field but the date/id serialized as undefined and dropped.
399
1658
  const results = (data.hits?.hits ?? []).map((hit) => {
400
1659
  const s = hit._source;
1660
+ const display = s.display_names?.[0] ?? '';
1661
+ // display_names is "NAME (TICKER) (CIK 000...)" (double-space before each
1662
+ // paren group; TICKER paren is absent for non-tickered filers). Take the name
1663
+ // before the first double-space-paren so both shapes strip cleanly.
1664
+ const entityName = display.split(/\s{2,}\(/)[0].trim() || display;
1665
+ const cik = s.ciks?.[0] ? String(parseInt(s.ciks[0], 10)) : undefined;
401
1666
  return {
402
- entity_name: s.entity_name,
403
- cik: s.entity_id,
404
- form_type: s.form_type,
1667
+ entity_name: entityName || undefined,
1668
+ cik,
1669
+ form_type: s.form ?? s.file_type ?? s.root_forms?.[0],
1670
+ // 8-K item codes — the signal (3.01 delisting vs 1.01 agreement). Empty for non-8-Ks.
1671
+ items: s.items && s.items.length ? s.items : undefined,
405
1672
  filing_date: s.file_date,
406
- period_of_report: s.period_of_report,
407
- location: s.biz_location,
1673
+ period_of_report: s.period_ending,
1674
+ location: s.biz_locations?.[0] ?? s.biz_states?.[0],
1675
+ // Accession — feed straight into edgar_filing_documents/edgar_filing_text, no second lookup.
1676
+ accession: s.adsh,
408
1677
  filing_id: hit._id,
409
1678
  };
410
1679
  });
@@ -422,16 +1691,319 @@ async function searchFilings(
422
1691
  // identity. Delegates to the shared resolver so the ticker/name-resolution logic
423
1692
  // stays consistent across every SEC-keyed pack (edgar, sec).
424
1693
  async function tickerToCik(ticker: string) {
425
- return resolveSecEntity(ticker, { headers: SEC_HEADERS });
1694
+ if (typeof ticker !== 'string' || !ticker.trim()) {
1695
+ throw new Error(
1696
+ 'A company is required: pass `ticker`, or its aliases `ticker_or_cik` / `company` — ' +
1697
+ 'all three take a stock ticker ("AAPL") or a company name ("Apple Inc."). ' +
1698
+ 'Example: edgar_ticker_to_cik({ticker: "AAPL"}).'
1699
+ );
1700
+ }
1701
+ let entity: Awaited<ReturnType<typeof resolveSecEntity>> | FilerNameMatch;
1702
+ try {
1703
+ entity = await resolveSecEntity(ticker, { headers: SEC_HEADERS });
1704
+ } catch (err) {
1705
+ if (!/No SEC company matches/.test(String((err as Error)?.message))) throw err;
1706
+ entity = await resolveAnyFilerByName(ticker, err as Error);
1707
+ }
1708
+ // Measured (docs/edgar-cohort-retention-2026-09-17.md, fleet #2182): 66% of
1709
+ // one-shot EDGAR callers call this tool and nothing else — they resolved a
1710
+ // ticker and had no signal about what to do next — while 90%+ of depth
1711
+ // callers go on to chain edgar_company_filings + edgar_company_concept by
1712
+ // hand. Name the one-call path in the response, where a direct-tool caller
1713
+ // actually sees it (a meta-tool's description is invisible to them).
1714
+ return {
1715
+ ...entity,
1716
+ next: {
1717
+ tool: 'edgar_company_snapshot',
1718
+ args: { ticker_or_cik: entity.cik },
1719
+ why: 'Returns the recent filings list AND the headline XBRL financials for this CIK in one call — the usual next step, instead of chaining edgar_company_filings then edgar_company_concept.',
1720
+ },
1721
+ };
1722
+ }
1723
+
1724
+ // --- delisted / non-listed filers by name (fleet #2305) ---------------------
1725
+ // company_tickers.json lists only CURRENTLY-listed companies, so a bankrupt or
1726
+ // delisted filer — exactly the ones people research — resolved to "No SEC
1727
+ // company matches" by name even though EDGAR holds all its filings under a
1728
+ // CIK (Amyris, CIK 1365916, tickers: []). EDGAR's own entity typeahead
1729
+ // (efts search-index, the box on efts full-text search) covers every filer.
1730
+ // Accept a hit only when its distinctive name tokens EQUAL the query's: a
1731
+ // looser match would hand back a different entity ("WNA Private Equity Fund
1732
+ // B-Amyris LLC" for "Amyris") as a clean success.
1733
+
1734
+ interface FilerNameMatch {
1735
+ ticker: null;
1736
+ cik: string;
1737
+ cik_padded: string;
1738
+ company_name: string;
1739
+ matched_by: 'edgar_entity_search';
1740
+ note: string;
1741
+ alternatives?: { company_name: string; cik: string }[];
1742
+ }
1743
+
1744
+ async function resolveAnyFilerByName(name: string, original: Error): Promise<FilerNameMatch> {
1745
+ const q = name.trim();
1746
+ const url = `https://efts.sec.gov/LATEST/search-index?keysTyped=${encodeURIComponent(q)}`;
1747
+ const res = await pwFetch(url, { headers: SEC_HEADERS }).catch(() => null);
1748
+ if (!res || !res.ok) throw original;
1749
+ const data = (await res.json().catch(() => null)) as
1750
+ | { hits?: { hits?: { _id: string; _source?: { entity?: string; rank?: number } }[] } }
1751
+ | null;
1752
+ const hits = (data?.hits?.hits ?? [])
1753
+ .filter((h) => /^\d+$/.test(h._id) && h._source?.entity)
1754
+ .map((h) => ({ cik: String(Number(h._id)), company_name: h._source!.entity!, rank: h._source!.rank ?? 0 }));
1755
+ const key = (s: string) => [...new Set(significantTokens(s))].sort().join(' ');
1756
+ const want = key(q);
1757
+ const exact = want ? hits.filter((h) => key(h.company_name) === want).sort((a, b) => b.rank - a.rank) : [];
1758
+ if (!exact.length) {
1759
+ const seen = hits.slice(0, 5).map((h) => `${h.company_name} (CIK ${h.cik})`).join('; ');
1760
+ throw new Error(
1761
+ `${original.message}${seen ? ` EDGAR's all-filer name index (which includes delisted and unlisted filers) has no exact match either; nearest: ${seen}. Pass one of those CIKs as ticker_or_cik.` : ''} ` +
1762
+ 'A delisted company\'s OLD ticker cannot be resolved (SEC keeps no ticker history) — pass the company name or CIK instead.'
1763
+ );
1764
+ }
1765
+ const best = exact[0];
1766
+ const alts = exact.slice(1, 4).map((h) => ({ company_name: h.company_name, cik: h.cik }));
1767
+ return {
1768
+ ticker: null,
1769
+ cik: best.cik,
1770
+ cik_padded: best.cik.padStart(10, '0'),
1771
+ company_name: best.company_name,
1772
+ matched_by: 'edgar_entity_search',
1773
+ note: 'Not a currently-listed company (no ticker on SEC\'s current list) — likely delisted, acquired, bankrupt or never listed. Resolved by name from EDGAR\'s all-filer index; its full filing history is still available under this CIK.',
1774
+ ...(alts.length ? { alternatives: alts } : {}),
1775
+ };
1776
+ }
1777
+
1778
+ // --- sponsor_to_filer -------------------------------------------------------
1779
+ // Resolve a sponsor/subsidiary NAME to the US-listed filer that reports it.
1780
+ // The hard case: trial registries name operating subsidiaries ("Merck Sharp
1781
+ // and Dohme"); SEC names the listed parent ("Merck & Co", MRK). We also refuse
1782
+ // to collapse every miss to "not found" — the status distinguishes listed /
1783
+ // subsidiary-of-listed / SEC-registered-but-unlisted / no-US-registrant.
1784
+
1785
+ interface SecTickerRow { cik_str: number; ticker: string; title: string }
1786
+
1787
+ // Generic corporate/industry words that don't distinguish one biotech from
1788
+ // another — excluded when picking a sponsor's "distinctive" token.
1789
+ const GENERIC_NAME_TOKENS = new Set([
1790
+ 'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED',
1791
+ 'LLC', 'LP', 'PLC', 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
1792
+ 'PHARMACEUTICALS', 'PHARMACEUTICAL', 'PHARMA', 'THERAPEUTICS', 'THERAPEUTIC',
1793
+ 'BIOSCIENCES', 'BIOSCIENCE', 'BIO', 'BIOPHARMA', 'BIOPHARMACEUTICALS',
1794
+ 'BIOTECH', 'BIOTECHNOLOGY', 'MEDICAL', 'MEDICINE', 'MEDICINES', 'SCIENCES',
1795
+ 'SCIENCE', 'HEALTH', 'HEALTHCARE', 'GROUP', 'HOLDINGS', 'HOLDING',
1796
+ 'LABORATORIES', 'LABS', 'AND', 'OF', 'US', 'USA', 'INTERNATIONAL', 'GLOBAL',
1797
+ ]);
1798
+
1799
+ const normName = (s: string): string =>
1800
+ s.toUpperCase().replace(/[.,&/()-]/g, ' ').replace(/\s+/g, ' ').trim();
1801
+
1802
+ function significantTokens(name: string): string[] {
1803
+ return normName(name).split(' ').filter((t) => t.length > 1 && !GENERIC_NAME_TOKENS.has(t));
1804
+ }
1805
+
1806
+ function distinctiveToken(name: string): string {
1807
+ return significantTokens(name)[0] ?? normName(name).split(' ')[0] ?? '';
1808
+ }
1809
+
1810
+ // SEC company names use "&", not "and". Produce query variants: &-normalized
1811
+ // full name first, then progressively drop trailing generic words (browse-edgar
1812
+ // matches on a name prefix and a too-specific full name can return nothing).
1813
+ function secQueryVariants(name: string): string[] {
1814
+ const amp = name.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
1815
+ const out = [amp];
1816
+ const words = amp.split(' ');
1817
+ for (let end = words.length; end > 1; end--) {
1818
+ const last = words[end - 1].toUpperCase().replace(/[.,&]/g, '');
1819
+ if (GENERIC_NAME_TOKENS.has(last)) out.push(words.slice(0, end - 1).join(' '));
1820
+ else break;
1821
+ }
1822
+ return [...new Set(out.filter(Boolean))];
1823
+ }
1824
+
1825
+ async function fetchCompanyTickers(): Promise<SecTickerRow[]> {
1826
+ const res = await pwFetch('https://www.sec.gov/files/company_tickers.json', { headers: SEC_HEADERS });
1827
+ if (!res.ok) throw await httpError(res, 'SEC ticker lookup error');
1828
+ const data = (await res.json()) as Record<string, SecTickerRow>;
1829
+ return Object.values(data);
1830
+ }
1831
+
1832
+ /** Does the sponsor name have its OWN SEC filer CIK (registered entity — a
1833
+ * subsidiary, or a private co that filed a Form D / draft S-1)? browse-edgar
1834
+ * orders by relevance so the leading <cik> tags are the closest matches; each
1835
+ * candidate is validated against its real name from the submissions API
1836
+ * (which doubles as the entity profile). Returns {cik, profile} or null. */
1837
+ async function edgarOwnCik(sponsor: string): Promise<{ cik: string; profile: EntityProfile } | null> {
1838
+ const sig = significantTokens(sponsor);
1839
+ const need = Math.min(2, sig.length || 1);
1840
+ for (const q of secQueryVariants(sponsor)) {
1841
+ const url = `https://www.sec.gov/cgi-bin/browse-edgar?company=${encodeURIComponent(q)}&action=getcompany&type=&dateb=&owner=include&count=10&output=atom`;
1842
+ const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } }).catch(() => null);
1843
+ if (!res || !res.ok) continue;
1844
+ const xml = await res.text();
1845
+ const ciks = [...xml.matchAll(/<cik>(\d+)<\/cik>/gi)].map((m) => String(Number(m[1])));
1846
+ for (const cik of [...new Set(ciks)].slice(0, 3)) {
1847
+ const profile = await edgarEntityProfile(cik);
1848
+ if (!profile?.name) continue;
1849
+ const nameToks = new Set(significantTokens(profile.name));
1850
+ const overlap = sig.filter((t) => nameToks.has(t)).length;
1851
+ if (overlap >= need) return { cik, profile };
1852
+ }
1853
+ if (ciks.length) break; // first variant that returned candidates is authoritative
1854
+ }
1855
+ return null;
1856
+ }
1857
+
1858
+ interface ListedFiler { name: string; ticker: string; cik: string; hits: number }
1859
+
1860
+ /** EDGAR full-text: LISTED filers (display_name carries a ticker) whose 10-Ks
1861
+ * mention the sponsor, ranked by mention count. */
1862
+ async function fullTextListedFilers(sponsor: string): Promise<{ candidates: ListedFiler[]; total: number }> {
1863
+ // SEC filings write "&", not "and" — normalize so "Merck Sharp and Dohme"
1864
+ // matches the same volume as "Merck Sharp & Dohme".
1865
+ const phrase = sponsor.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
1866
+ const params = new URLSearchParams({ q: `"${phrase}"`, forms: '10-K', from: '0', size: '100' });
1867
+ const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
1868
+ if (!res.ok) return { candidates: [], total: 0 };
1869
+ const data = (await res.json()) as {
1870
+ hits?: { total?: { value?: number }; hits?: { _source?: { display_names?: string[] } }[] };
1871
+ };
1872
+ const byCik = new Map<string, ListedFiler>();
1873
+ for (const h of data.hits?.hits ?? []) {
1874
+ for (const dn of h._source?.display_names ?? []) {
1875
+ // "Merck & Co. Inc. (MRK) (CIK 0000310158)" — parse name, ticker?, cik.
1876
+ const parens = [...dn.matchAll(/\(([^)]+)\)/g)].map((m) => m[1].trim());
1877
+ const cikPart = parens.find((p) => /^CIK\s*\d+/i.test(p));
1878
+ const cik = cikPart ? String(Number(cikPart.replace(/CIK\s*/i, ''))) : '';
1879
+ const ticker = parens.find((p) => /^[A-Z][A-Z.]{0,5}$/.test(p) && !/^CIK/i.test(p));
1880
+ if (!cik || !ticker) continue; // listed filers only
1881
+ const name = dn.slice(0, dn.indexOf('(')).trim();
1882
+ const cur = byCik.get(cik);
1883
+ if (cur) cur.hits += 1;
1884
+ else byCik.set(cik, { name, ticker, cik, hits: 1 });
1885
+ }
1886
+ }
1887
+ const candidates = [...byCik.values()].sort((a, b) => b.hits - a.hits);
1888
+ return { candidates, total: data.hits?.total?.value ?? 0 };
1889
+ }
1890
+
1891
+ interface EntityProfile { name?: string; state?: string; recent_forms?: string[] }
1892
+
1893
+ /** Best-effort entity profile for a bare CIK — forms filed + state, to explain
1894
+ * an "unlisted" verdict (e.g. "only Form D" ⇒ private placement). */
1895
+ async function edgarEntityProfile(cik: string): Promise<EntityProfile | null> {
1896
+ try {
1897
+ const padded = cik.padStart(10, '0');
1898
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${padded}.json`, { headers: SEC_HEADERS });
1899
+ if (!res.ok) return null;
1900
+ const d = (await res.json()) as { name?: string; stateOfIncorporation?: string; filings?: { recent?: { form?: string[] } } };
1901
+ const forms = [...new Set(d.filings?.recent?.form ?? [])].slice(0, 8);
1902
+ return { name: d.name, state: d.stateOfIncorporation, recent_forms: forms };
1903
+ } catch {
1904
+ return null;
1905
+ }
1906
+ }
1907
+
1908
+ function filerOut(r: SecTickerRow) {
1909
+ return { company_name: r.title, ticker: r.ticker, cik: String(r.cik_str), cik_padded: String(r.cik_str).padStart(10, '0') };
1910
+ }
1911
+
1912
+ async function sponsorToFiler(sponsor: string): Promise<unknown> {
1913
+ if (typeof sponsor !== 'string' || !sponsor.trim()) {
1914
+ return {
1915
+ error: 'Required argument "sponsor" is missing or empty.',
1916
+ retry_hint: 'Pass an organization name, e.g. sponsor_to_filer({ sponsor: "Merck Sharp and Dohme" }).',
1917
+ };
1918
+ }
1919
+ const name = sponsor.trim();
1920
+ const disc = distinctiveToken(name);
1921
+ const tickers = await fetchCompanyTickers();
1922
+
1923
+ // 1) Direct listed match (exact / prefix / whole-word — not loose substring).
1924
+ const ranked = rankMatches(name, tickers, (r) => r.title);
1925
+ if (ranked.length && ranked[0].score >= 2) {
1926
+ const m = ranked[0].item;
1927
+ return {
1928
+ status: 'resolved',
1929
+ method: 'direct',
1930
+ confidence: ranked[0].score >= 3 ? 'high' : 'medium',
1931
+ sponsor: name,
1932
+ filer: filerOut(m),
1933
+ alternatives: ranked.slice(1, 4).map((x) => filerOut(x.item)),
1934
+ };
1935
+ }
1936
+
1937
+ // Own SEC CIK? (registered filer entity — subsidiary or private that filed)
1938
+ const own = await edgarOwnCik(name).catch(() => null);
1939
+ if (own) {
1940
+ const listedSelf = tickers.find((t) => String(t.cik_str) === own.cik);
1941
+ if (listedSelf) {
1942
+ return { status: 'resolved', method: 'direct_cik', confidence: 'high', sponsor: name, filer: filerOut(listedSelf) };
1943
+ }
1944
+ }
1945
+
1946
+ // 3) Full-text: listed filers referencing the sponsor; confirm a parent by
1947
+ // requiring the sponsor's distinctive token in the filer's name (kills
1948
+ // "merely mentioned by an unrelated filer" false positives).
1949
+ const ft = await fullTextListedFilers(name).catch(() => ({ candidates: [] as ListedFiler[], total: 0 }));
1950
+ const confirmed = ft.candidates.find((c) => normName(c.name).split(' ').includes(disc));
1951
+ if (confirmed) {
1952
+ return {
1953
+ status: 'resolved_via_parent',
1954
+ method: 'subsidiary_fulltext',
1955
+ confidence: confirmed.hits >= 10 ? 'high' : 'medium',
1956
+ sponsor: name,
1957
+ filer: { company_name: confirmed.name, ticker: confirmed.ticker, cik: confirmed.cik, cik_padded: confirmed.cik.padStart(10, '0') },
1958
+ evidence: {
1959
+ parent_mention_filings: confirmed.hits,
1960
+ distinctive_token: disc,
1961
+ own_subsidiary_cik: own?.cik ?? null,
1962
+ own_subsidiary_name: own?.profile?.name ?? null,
1963
+ note: 'Sponsor appears to be an operating subsidiary. Resolved to the listed parent whose SEC filings reference it and whose name shares the sponsor\'s distinctive token.',
1964
+ },
1965
+ alternatives: ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits })),
1966
+ };
1967
+ }
1968
+
1969
+ // 4) No confirmed parent — classify the miss honestly.
1970
+ const mentions = ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits }));
1971
+ if (own) {
1972
+ return {
1973
+ status: 'us_registrant_unlisted',
1974
+ confidence: 'high',
1975
+ sponsor: name,
1976
+ own_cik: own.cik,
1977
+ likely: 'private',
1978
+ message: `"${name}" is an SEC-registered entity (CIK ${own.cik}) but is not publicly listed (no ticker) and has no dominant listed parent in EDGAR filings. Most likely a private company (e.g. a Form D private placement or draft/withdrawn registration) or a subsidiary of a private or non-US parent.`,
1979
+ entity: own.profile,
1980
+ full_text_mentions: mentions,
1981
+ };
1982
+ }
1983
+ return {
1984
+ status: 'no_us_registrant',
1985
+ confidence: 'high',
1986
+ sponsor: name,
1987
+ likely: 'non_us_listed_or_private',
1988
+ message: `No US SEC registrant found for "${name}". The sponsor is not US-listed and has no US SEC CIK — most likely a non-US-listed company (foreign exchange) or a private company. Any full-text mentions below are other filers referencing the name, not the sponsor itself.`,
1989
+ full_text_mentions: mentions,
1990
+ };
426
1991
  }
427
1992
 
428
1993
  async function resolveCik(tickerOrCik: string): Promise<string> {
429
1994
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
1995
+ // Names EVERY accepted spelling, because `required` is empty on these
1996
+ // schemas (a flat list cannot say "one of these three") so this is the
1997
+ // only place a caller is told what to send. The words "is required" are
1998
+ // load-bearing: error-class.ts books the refusal as user_error rather
1999
+ // than the `error` bucket that feeds Problem Tools (fleet #2069).
2000
+ // No tool name in the example: resolveCik() is shared by four tools, and a
2001
+ // refusal that says `edgar_company_filings(...)` to a caller of
2002
+ // edgar_company_facts hands it the wrong word to copy — which is the exact
2003
+ // failure this whole change exists to stop.
430
2004
  throw new Error(
431
- 'Required argument "ticker_or_cik" is missing or empty. ' +
432
- 'Try one of: edgar_company_filings({ticker_or_cik: "AAPL"}) by ticker, ' +
433
- 'or edgar_company_filings({ticker_or_cik: "320193"}) by CIK number (Apple\'s CIK). ' +
434
- 'Tickers are auto-resolved to CIKs internally.'
2005
+ 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — ' +
2006
+ 'all three take a ticker ("AAPL") or a CIK ("320193"), and tickers are auto-resolved.'
435
2007
  );
436
2008
  }
437
2009
  if (isNumericCik(tickerOrCik)) return tickerOrCik.trim();
@@ -439,62 +2011,110 @@ async function resolveCik(tickerOrCik: string): Promise<string> {
439
2011
  return result.cik;
440
2012
  }
441
2013
 
442
- async function companyFilings(tickerOrCik: string, formType?: string, limit?: number) {
443
- const cik = await resolveCik(tickerOrCik);
444
- const paddedCik = padCik(cik);
445
- const count = Math.min(40, Math.max(1, limit ?? 20));
446
-
447
- const res = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
448
- if (!res.ok) throw new Error(`SEC EDGAR submissions error: ${res.status}`);
2014
+ /**
2015
+ * A 404 from data.sec.gov/submissions/ means the CIK simply isn't a registrant —
2016
+ * that's a caller mistake, not an EDGAR outage, so say which CIK failed and how to
2017
+ * get a real one instead of surfacing a bare status code.
2018
+ */
2019
+ function submissionsError(status: number, tickerOrCik: string, paddedCik: string): Error {
2020
+ if (status === 404) {
2021
+ return new Error(
2022
+ `No SEC registrant with CIK ${paddedCik} (resolved from "${tickerOrCik}"). ` +
2023
+ 'That CIK does not exist in EDGAR — the company may be private, foreign-listed, or the ' +
2024
+ 'identifier may be wrong. Resolve a real one with edgar_ticker_to_cik({ticker: "…"}), ' +
2025
+ 'which also accepts company names and returns `alternatives` for disambiguation.'
2026
+ );
2027
+ }
2028
+ return new Error(`SEC EDGAR submissions error: ${status}`);
2029
+ }
449
2030
 
450
- const data = (await res.json()) as {
451
- cik: string;
452
- name: string;
453
- sic: string;
454
- sicDescription: string;
455
- stateOfIncorporation: string;
456
- fiscalYearEnd: string;
457
- tickers: string[];
458
- filings: {
459
- recent: {
460
- accessionNumber: string[];
461
- filingDate: string[];
462
- form: string[];
463
- primaryDocument: string[];
464
- items: string[];
465
- size: number[];
466
- };
2031
+ interface SubmissionsPayload {
2032
+ cik: string;
2033
+ name: string;
2034
+ sic: string;
2035
+ sicDescription: string;
2036
+ stateOfIncorporation: string;
2037
+ fiscalYearEnd: string;
2038
+ tickers: string[];
2039
+ filings: {
2040
+ recent: {
2041
+ accessionNumber: string[];
2042
+ filingDate: string[];
2043
+ form: string[];
2044
+ primaryDocument: string[];
2045
+ items: string[];
2046
+ size: number[];
467
2047
  };
468
2048
  };
2049
+ }
469
2050
 
470
- const recent = data.filings.recent;
471
- const filings: {
472
- accession_number: string;
473
- filing_date: string;
474
- form: string;
475
- primary_document: string;
476
- document_url: string;
477
- }[] = [];
2051
+ interface FilingRow {
2052
+ accession_number: string;
2053
+ filing_date: string;
2054
+ form: string;
2055
+ items?: string[];
2056
+ primary_document: string;
2057
+ document_url: string;
2058
+ }
2059
+
2060
+ async function fetchSubmissions(tickerOrCik: string, paddedCik: string): Promise<SubmissionsPayload> {
2061
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
2062
+ if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
2063
+ return (await res.json()) as SubmissionsPayload;
2064
+ }
478
2065
 
2066
+ /**
2067
+ * Map the submissions payload's column-oriented `recent` block to filing rows.
2068
+ * `keep` decides per form; `count` caps the output after filtering.
2069
+ */
2070
+ function recentFilingRows(data: SubmissionsPayload, keep: (form: string) => boolean, count: number): FilingRow[] {
2071
+ const recent = data.filings.recent;
2072
+ const filings: FilingRow[] = [];
479
2073
  for (let i = 0; i < recent.accessionNumber.length && filings.length < count; i++) {
480
2074
  const form = recent.form[i];
481
- if (formType && form !== formType) continue;
2075
+ if (!keep(form)) continue;
482
2076
 
483
2077
  const accession = recent.accessionNumber[i];
484
2078
  const accessionPath = accession.replace(/-/g, '');
2079
+ // submissions payload carries `items` as a comma-joined string per filing,
2080
+ // populated for 8-Ks (e.g. "3.01,9.01"). Split to an array; the item code
2081
+ // (3.01 delisting vs 1.01 agreement) is the signal, so no second fetch needed.
2082
+ const rawItems = recent.items?.[i] ?? '';
2083
+ const items = rawItems
2084
+ ? rawItems.split(',').map((s) => s.trim()).filter(Boolean)
2085
+ : undefined;
485
2086
  filings.push({
486
2087
  accession_number: accession,
487
2088
  filing_date: recent.filingDate[i],
488
2089
  form,
2090
+ ...(items && items.length ? { items } : {}),
489
2091
  primary_document: recent.primaryDocument[i],
490
2092
  document_url: `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accessionPath}/${recent.primaryDocument[i]}`,
491
2093
  });
492
2094
  }
2095
+ return filings;
2096
+ }
2097
+
2098
+ async function companyFilings(tickerOrCik: string, formType?: string, limit?: number) {
2099
+ const cik = await resolveCik(tickerOrCik);
2100
+ const paddedCik = padCik(cik);
2101
+ const count = Math.min(40, Math.max(1, limit ?? 20));
2102
+
2103
+ const data = await fetchSubmissions(tickerOrCik, paddedCik);
2104
+ const filings = recentFilingRows(data, (form) => !formType || form === formType, count);
493
2105
 
494
2106
  return {
495
2107
  cik: data.cik,
496
2108
  company_name: data.name,
497
2109
  tickers: data.tickers ?? [],
2110
+ // The NUMERIC code as well as the prose (fleet #2124). `sic_description`
2111
+ // was already returned but is free text ("Biological Products, (No
2112
+ // Diagnostic Substances)"), so every consumer that wants to branch on
2113
+ // industry has to pattern-match prose that SEC is free to reword. The code
2114
+ // is the fact. entity_profile uses it to decide whether asking the FDA
2115
+ // Purple Book about this filer can possibly return anything; it is already
2116
+ // in the submissions payload, so this costs no extra request.
2117
+ sic: data.sic,
498
2118
  sic_description: data.sicDescription,
499
2119
  state_of_incorporation: data.stateOfIncorporation,
500
2120
  fiscal_year_end: data.fiscalYearEnd,
@@ -503,6 +2123,128 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
503
2123
  };
504
2124
  }
505
2125
 
2126
+ // --- edgar_company_snapshot --------------------------------------------------
2127
+ // The resolve -> list filings -> pull financials chain, collapsed into one call.
2128
+ // Measured in docs/edgar-cohort-retention-2026-09-17.md (fleet #2114 / #2182):
2129
+ // 90%+ of depth EDGAR callers assemble edgar_company_filings +
2130
+ // edgar_company_concept by hand after edgar_ticker_to_cik, three calls every
2131
+ // time; 66% of one-shot callers stop after ticker resolution. Neither
2132
+ // edgar_company_facts (concepts, no filings) nor entity_profile (a cross-source
2133
+ // meta-tool outside this pack, five unfiltered filings, three concepts) gave
2134
+ // a direct-tool caller that result from inside the pack.
2135
+
2136
+ /** Forms listed by default: the substantive periodic/current/proxy reports. Form 4 insider filings dominate a raw feed for any large cap and are what callers filter out first. */
2137
+ const SNAPSHOT_DEFAULT_FORMS = new Set(['10-K', '10-K/A', '10-Q', '10-Q/A', '8-K', '20-F', '40-F', '6-K', 'DEF 14A']);
2138
+
2139
+ async function companySnapshot(tickerOrCik: string, formType?: string, filingsLimit?: number) {
2140
+ const cik = await resolveCik(tickerOrCik);
2141
+ const paddedCik = padCik(cik);
2142
+ const count = Math.min(40, Math.max(1, filingsLimit ?? 10));
2143
+ const filter = (formType ?? '').trim();
2144
+ const keep = !filter
2145
+ ? (form: string) => SNAPSHOT_DEFAULT_FORMS.has(form)
2146
+ : filter.toLowerCase() === 'all'
2147
+ ? () => true
2148
+ : (form: string) => form === filter;
2149
+
2150
+ // Both legs are keyed on the same CIK and independent of each other, so run
2151
+ // them together — this call exists to save round-trips, not to serialize them.
2152
+ const [data, factsRes] = await Promise.all([
2153
+ fetchSubmissions(tickerOrCik, paddedCik),
2154
+ pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS }),
2155
+ ]);
2156
+
2157
+ let filings = recentFilingRows(data, keep, count);
2158
+ let filingsFilter = filter ? filter : 'default (10-K, 10-K/A, 10-Q, 10-Q/A, 8-K, 20-F, 40-F, 6-K, DEF 14A)';
2159
+ // The default set is tuned for operating companies. A fund, trust or ABS
2160
+ // issuer files N-CSR / 485BPOS / 10-D and none of the above, so the default
2161
+ // would hand back an empty list for a registrant with hundreds of filings —
2162
+ // a zero that reads as "nothing filed" (silent-zero policy). Fall back to
2163
+ // every form and SAY so in `filings_filter`, rather than return the empty set.
2164
+ if (!filter && filings.length === 0 && data.filings.recent.accessionNumber.length > 0) {
2165
+ filings = recentFilingRows(data, () => true, count);
2166
+ const forms = [...new Set(filings.map((f) => f.form))].join(', ');
2167
+ filingsFilter = `all — this filer's recent forms (${forms}) include none of the default set, so every form is listed`;
2168
+ }
2169
+
2170
+ // The financials leg degrades on its own: a registrant with no XBRL facts
2171
+ // (funds, trusts, paper-form foreign filers) still has filings worth
2172
+ // returning. The gap is STATED — `financials_status` plus a reason — never
2173
+ // an empty object that reads as "reported nothing" (silent-zero policy).
2174
+ let financials: {
2175
+ financials_status: 'ok' | 'unavailable';
2176
+ financials_note?: string;
2177
+ latest_fiscal_year: number | null;
2178
+ latest_period_end: string | null;
2179
+ key_concepts: Record<string, unknown>;
2180
+ stale_concepts: string[];
2181
+ year_note?: string;
2182
+ };
2183
+ if (factsRes.ok) {
2184
+ const facts = (await factsRes.json()) as {
2185
+ facts: { 'us-gaap'?: Parameters<typeof summarizeAnnualFinancials>[0] };
2186
+ };
2187
+ const usGaap = facts.facts?.['us-gaap'] ?? {};
2188
+ const summary = summarizeAnnualFinancials(usGaap);
2189
+ const reported = Object.values(summary.latest_annual).some((v) => v !== null);
2190
+ financials = reported
2191
+ ? {
2192
+ financials_status: 'ok',
2193
+ latest_fiscal_year: summary.latest_fiscal_year,
2194
+ latest_period_end: summary.latest_period_end,
2195
+ key_concepts: summary.latest_annual,
2196
+ stale_concepts: summary.stale_concepts,
2197
+ year_note: summary.year_note,
2198
+ }
2199
+ : {
2200
+ financials_status: 'unavailable',
2201
+ financials_note: `EDGAR has XBRL facts for CIK ${paddedCik} but none under the us-gaap headline concepts (revenue, net income, assets, ...). Common for filers reporting under IFRS or dei-only tags. edgar_company_facts lists whatever concepts it does report.`,
2202
+ latest_fiscal_year: null,
2203
+ latest_period_end: null,
2204
+ key_concepts: {},
2205
+ stale_concepts: [],
2206
+ };
2207
+ } else {
2208
+ // Drain the body so the connection is released; the status is the fact.
2209
+ await factsRes.text().catch(() => undefined);
2210
+ financials = {
2211
+ financials_status: 'unavailable',
2212
+ financials_note:
2213
+ factsRes.status === 404
2214
+ ? `EDGAR has no XBRL company-facts record for CIK ${paddedCik}. That is normal for funds, trusts, ABS issuers and filers that have never submitted an XBRL-tagged 10-K/10-Q/20-F — the filings above are still complete.`
2215
+ : `SEC EDGAR company-facts returned HTTP ${factsRes.status}; the filings list above is complete, the financials are not. Retry, or call edgar_company_facts directly.`,
2216
+ latest_fiscal_year: null,
2217
+ latest_period_end: null,
2218
+ key_concepts: {},
2219
+ stale_concepts: [],
2220
+ };
2221
+ }
2222
+
2223
+ return {
2224
+ cik: data.cik,
2225
+ cik_padded: paddedCik,
2226
+ company_name: data.name,
2227
+ tickers: data.tickers ?? [],
2228
+ sic: data.sic,
2229
+ sic_description: data.sicDescription,
2230
+ state_of_incorporation: data.stateOfIncorporation,
2231
+ fiscal_year_end: data.fiscalYearEnd,
2232
+ filings_filter: filingsFilter,
2233
+ filings_count: filings.length,
2234
+ filings,
2235
+ ...financials,
2236
+ resources: {
2237
+ edgar_filings: `pipeworx://edgar/company/${paddedCik}/filings`,
2238
+ edgar_facts: `pipeworx://edgar/company/${paddedCik}/facts`,
2239
+ },
2240
+ next: {
2241
+ filing_text: 'edgar_filing_text({accession, section}) — the text of one filing above (section: "risk_factors", "mdna", ...).',
2242
+ concept_history: 'edgar_company_concept({cik, concept: "Revenues"}) — one metric across every reported period.',
2243
+ all_concepts: 'edgar_company_facts({cik}) — every XBRL concept the filer reports.',
2244
+ },
2245
+ };
2246
+ }
2247
+
506
2248
  // ── Filing documents (list docs inside one filing by accession) ─────
507
2249
 
508
2250
  // Normalize an accession number (dashed or undashed) to both forms.
@@ -514,6 +2256,53 @@ function normalizeAccession(raw: string): { dashed: string; nodash: string } | n
514
2256
  return { dashed, nodash: digits };
515
2257
  }
516
2258
 
2259
+ // Read at most maxBytes of a filing document, then stop reading and cancel
2260
+ // the rest of the response body.
2261
+ //
2262
+ // Not a nicety: a SpaceX S-1 primary document is 11.8 MB of raw HTML (fleet
2263
+ // #440 — confirmed by fetching it directly), and SEC's archive server does
2264
+ // not honor Range requests (measured: a `Range: bytes=0-999` GET still comes
2265
+ // back 200 with the full 11.8 MB, not 206). materialising that and then
2266
+ // running htmlToText's regex passes over all of it inside one Worker
2267
+ // invocation is what hits the CPU/isolate ceiling and gets the whole request
2268
+ // killed with a bare Cloudflare 1102 — no JS exception, so a try/catch
2269
+ // around the fetch (see filingDocuments' includePrimaryText path) does NOT
2270
+ // protect against this failure mode. The cap has to be enforced on OUR side
2271
+ // of the socket. Mirrors mcps/legislation-uk/src/index.ts's getTextBounded,
2272
+ // which fixed the identical shape of bug for a 5.9 MB Act.
2273
+ async function fetchTextBounded(url: string, headers: Record<string, string>, maxBytes: number): Promise<{ text: string; truncated: boolean }> {
2274
+ const res = await pwFetch(url, { headers });
2275
+ if (!res.ok) throw new Error(`Failed to fetch primary document (HTTP ${res.status}): ${url}`);
2276
+ if (!res.body) {
2277
+ const whole = await res.text();
2278
+ return { text: whole.length > maxBytes ? whole.slice(0, maxBytes) : whole, truncated: whole.length > maxBytes };
2279
+ }
2280
+ const reader = res.body.getReader();
2281
+ const chunks: Uint8Array[] = [];
2282
+ let total = 0;
2283
+ let truncated = false;
2284
+ for (;;) {
2285
+ const { done, value } = await reader.read();
2286
+ if (done) break;
2287
+ if (value) {
2288
+ total += value.byteLength;
2289
+ chunks.push(value);
2290
+ if (total >= maxBytes) { truncated = true; await reader.cancel(); break; }
2291
+ }
2292
+ }
2293
+ const buf = new Uint8Array(total);
2294
+ let off = 0;
2295
+ for (const c of chunks) { buf.set(c, off); off += c.byteLength; }
2296
+ return { text: new TextDecoder().decode(buf), truncated };
2297
+ }
2298
+ // Cap on RAW HTML bytes read, not plaintext chars — HTML markup overhead in
2299
+ // SEC filings (heavy inline styling/XBRL tagging) means this yields roughly
2300
+ // 1.5-3x fewer plaintext chars, comfortably above FILING_TEXT_CAP for the
2301
+ // front-loaded sections (Business, Risk Factors, MD&A) most questions ask
2302
+ // about. A doc capped here is flagged `raw_truncated: true` — an honest
2303
+ // stated limit, not a silent gap — rather than never answering at all.
2304
+ const FILING_DOC_MAX_BYTES = 4_000_000;
2305
+
517
2306
  // Strip an HTML document to readable plaintext. Workers have no DOMParser;
518
2307
  // drop script/style blocks, remove tags, decode entities, collapse whitespace.
519
2308
  function htmlToText(html: string): string {
@@ -536,7 +2325,7 @@ async function filingDocuments(
536
2325
  ) {
537
2326
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
538
2327
  throw new Error(
539
- 'A ticker or CIK is required. Pass ticker ("AAPL") or cik ("320193"), plus either an accession or a form_type (e.g. edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the latest 10-K).',
2328
+ 'A company is required: pass `ticker` ("AAPL"), `cik` ("320193") or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the latest 10-K).',
540
2329
  );
541
2330
  }
542
2331
  const cik = await resolveCik(tickerOrCik);
@@ -552,7 +2341,7 @@ async function filingDocuments(
552
2341
  }
553
2342
  if (!acc) {
554
2343
  const wantForm = wantFormTypeArg?.trim().toUpperCase();
555
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
2344
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
556
2345
  if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
557
2346
  const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
558
2347
  const r = sub.filings?.recent;
@@ -577,7 +2366,7 @@ async function filingDocuments(
577
2366
  let primaryDocument: string | null = null;
578
2367
  let primaryDocDescription: string | null = null;
579
2368
  try {
580
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
2369
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
581
2370
  if (subRes.ok) {
582
2371
  const sub = (await subRes.json()) as {
583
2372
  name?: string;
@@ -608,13 +2397,13 @@ async function filingDocuments(
608
2397
  }
609
2398
 
610
2399
  const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
611
- const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
2400
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
612
2401
  if (idxRes.status === 404) {
613
2402
  throw new Error(
614
2403
  `No filing found for accession ${acc.dashed} — that accession number does not exist in SEC EDGAR (double-check the digits; it may belong to a different filer). Use edgar_company_filings({ticker_or_cik: "${tickerOrCik}"}) to list this company's real accession numbers.`,
615
2404
  );
616
2405
  }
617
- if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
2406
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
618
2407
  const idx = (await idxRes.json()) as {
619
2408
  directory?: { item?: { name: string; type?: string; size?: string | number; 'last-modified'?: string }[] };
620
2409
  };
@@ -640,26 +2429,194 @@ async function filingDocuments(
640
2429
  documents,
641
2430
  };
642
2431
 
643
- if (includePrimaryText && primaryDocument) {
644
- try {
645
- const docUrl = `${folder}/${primaryDocument}`;
646
- const docRes = await fetch(docUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
647
- if (docRes.ok) {
648
- const body = await docRes.text();
649
- const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
650
- const text = isHtml ? htmlToText(body) : body;
651
- result.primary_text = text.length > 40000 ? text.slice(0, 40000) + '\n…[truncated]' : text;
2432
+ if (includePrimaryText && primaryDocument) {
2433
+ try {
2434
+ const docUrl = `${folder}/${primaryDocument}`;
2435
+ const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
2436
+ const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
2437
+ const text = isHtml ? htmlToText(body) : body;
2438
+ result.primary_text = text.length > 40000 ? text.slice(0, 40000) + '\n…[truncated]' : text;
2439
+ if (rawTruncated) result.primary_text_raw_truncated = true;
2440
+ } catch (e) {
2441
+ result.primary_text = null;
2442
+ result.primary_text_error = String(e);
2443
+ }
2444
+ }
2445
+
2446
+ return result;
2447
+ }
2448
+
2449
+ // Heading patterns for the `section` slice of edgar_filing_text. Each filing's
2450
+ // MD&A / notes carry these standard headings; we locate the LAST occurrence
2451
+ // (the body, not the table-of-contents entry) and return a window from there.
2452
+ // Each section: an anchor regex plus whether to take the FIRST match (the actual
2453
+ // disclosure body — used where the phrase is rare and meaningful) or the LAST
2454
+ // (used where the heading also appears in the table of contents, so the body is
2455
+ // the later occurrence). going_concern anchors on "substantial doubt" — the
2456
+ // ASC 205-40 trigger phrase — because "going concern" alone recurs in boilerplate.
2457
+ const FILING_SECTION_PATTERNS: Record<string, { anchor: RegExp; pick: 'first' | 'last' }> = {
2458
+ going_concern: { anchor: /substantial doubt|going concern/gi, pick: 'first' },
2459
+ liquidity: { anchor: /liquidity and capital resources|liquidity/gi, pick: 'last' },
2460
+ capital_resources: { anchor: /capital resources/gi, pick: 'last' },
2461
+ subsequent_events: { anchor: /subsequent events/gi, pick: 'last' },
2462
+ };
2463
+
2464
+ const FILING_TEXT_DEFAULT_MAX = 50000;
2465
+ const FILING_TEXT_CAP = 100000;
2466
+
2467
+ // Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
2468
+ // out (rather than piggybacking edgar_filing_documents' include_primary_text,
2469
+ // which caps at 40k and can't page) lets the register pull deep disclosures —
2470
+ // going-concern language, ATM facilities, share caps — that live past 40k.
2471
+ async function filingText(
2472
+ accession: string | undefined,
2473
+ tickerOrCik: string,
2474
+ section?: string,
2475
+ maxChars?: number,
2476
+ offset?: number,
2477
+ wantFormTypeArg?: string,
2478
+ ) {
2479
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2480
+ throw new Error('A company is required: pass `ticker` ("ACTU"), `cik` or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_text({ticker:"ACTU", form_type:"10-Q"})).');
2481
+ }
2482
+ const cik = await resolveCik(tickerOrCik);
2483
+ const paddedCik = padCik(cik);
2484
+ const cikNoZeros = String(Number(cik.replace(/\D/g, '')));
2485
+
2486
+ let acc = accession ? normalizeAccession(accession) : null;
2487
+ if (accession && !acc) {
2488
+ throw new Error(`Invalid accession "${accession}". A SEC accession is 18 digits shaped 10-2-6 (e.g. "0001683168-26-003909"). Get one from edgar_company_filings, or omit accession and pass form_type to auto-resolve the latest.`);
2489
+ }
2490
+
2491
+ // One submissions fetch serves both auto-resolution and metadata enrichment.
2492
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
2493
+ if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) for ${tickerOrCik}.`);
2494
+ const sub = (await subRes.json()) as {
2495
+ name?: string;
2496
+ filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[]; primaryDocument?: string[] } };
2497
+ };
2498
+ const r = sub.filings?.recent;
2499
+ const accs = r?.accessionNumber ?? [];
2500
+ const forms = r?.form ?? [];
2501
+
2502
+ if (!acc) {
2503
+ const wantForm = wantFormTypeArg?.trim().toUpperCase();
2504
+ const idx = wantForm ? forms.findIndex((f) => f.toUpperCase() === wantForm) : 0;
2505
+ if (idx < 0 || !accs[idx]) {
2506
+ throw new Error(`No ${wantForm ?? 'recent'} filing found for ${tickerOrCik}. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list filings, then pass an accession.`);
2507
+ }
2508
+ acc = normalizeAccession(accs[idx]);
2509
+ if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
2510
+ }
2511
+
2512
+ const i = accs.indexOf(acc.dashed);
2513
+ const formType = i >= 0 ? forms[i] ?? null : null;
2514
+ const filingDate = i >= 0 ? r?.filingDate?.[i] ?? null : null;
2515
+ let primaryDocument = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
2516
+
2517
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
2518
+ // If the accession wasn't in the recent index (older filing), fall back to the
2519
+ // folder index to find the primary document.
2520
+ if (!primaryDocument) {
2521
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
2522
+ if (idxRes.status === 404) throw new Error(`No filing found for accession ${acc.dashed} — it does not exist in SEC EDGAR for this filer. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"}) to list real accessions.`);
2523
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
2524
+ const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
2525
+ const docs = idxJson.directory?.item ?? [];
2526
+ // Prefer the main htm document that isn't an exhibit/XBRL artifact.
2527
+ primaryDocument =
2528
+ docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
2529
+ if (!primaryDocument) throw new Error(`Could not identify the primary document for accession ${acc.dashed}. Use edgar_filing_documents to list the filing's files and fetch one by URL.`);
2530
+ }
2531
+
2532
+ const docUrl = `${folder}/${primaryDocument}`;
2533
+ const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
2534
+ const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
2535
+ let fullText = isHtml ? htmlToText(body) : body;
2536
+
2537
+ // Optional section slice: locate the section heading (last occurrence = the
2538
+ // real body, past any table-of-contents mention) and reframe the text to start
2539
+ // there. `section` is best-effort — an unmatched section returns the whole doc.
2540
+ let sectionApplied: string | null = null;
2541
+ let sectionFound: string | boolean | null = null;
2542
+ const sec = (section ?? '').trim().toLowerCase();
2543
+ if (sec) {
2544
+ const conf = FILING_SECTION_PATTERNS[sec];
2545
+ if (!conf) {
2546
+ sectionFound = `unknown_section:${sec}`;
2547
+ } else {
2548
+ const idxs: number[] = [];
2549
+ for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
2550
+ const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
2551
+ if (chosen >= 0) {
2552
+ // Back up a little so the section heading itself is included, not clipped mid-phrase.
2553
+ fullText = fullText.slice(Math.max(0, chosen - 120));
2554
+ sectionApplied = sec;
2555
+ sectionFound = true;
652
2556
  } else {
653
- result.primary_text = null;
654
- result.primary_text_error = `fetch ${docRes.status}`;
2557
+ sectionFound = false;
655
2558
  }
656
- } catch (e) {
657
- result.primary_text = null;
658
- result.primary_text_error = String(e);
659
2559
  }
660
2560
  }
661
2561
 
662
- return result;
2562
+ const total = fullText.length;
2563
+ const off = Math.max(0, Math.floor(Number(offset) || 0));
2564
+ const cap = Math.min(FILING_TEXT_CAP, Math.max(1000, Math.floor(Number(maxChars) || FILING_TEXT_DEFAULT_MAX)));
2565
+ const slice = fullText.slice(off, off + cap);
2566
+ const end = off + slice.length;
2567
+ const truncated = end < total;
2568
+
2569
+ return {
2570
+ accession: acc.dashed,
2571
+ cik: cikNoZeros,
2572
+ company: sub.name ?? null,
2573
+ form: formType,
2574
+ filed: filingDate,
2575
+ document: primaryDocument,
2576
+ document_url: docUrl,
2577
+ section: sectionApplied,
2578
+ ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2579
+ total_chars: total,
2580
+ offset: off,
2581
+ returned_chars: slice.length,
2582
+ truncated,
2583
+ // When truncated, pass this back as `offset` to page forward; do NOT spill the
2584
+ // whole doc to disk — request the next window instead.
2585
+ next_offset: truncated ? end : null,
2586
+ ...(rawTruncated
2587
+ ? {
2588
+ raw_truncated: true,
2589
+ raw_truncated_note: `This document's raw HTML exceeds the ${(FILING_DOC_MAX_BYTES / 1_000_000).toFixed(0)}MB this tool reads in one call, so total_chars/text above reflect only the portion read — NOT the whole filing. A section search (going_concern/liquidity/capital_resources/subsequent_events) that picks the LAST occurrence may miss content past this cutoff, since large filings' later sections (financial statement notes, subsequent events) can fall beyond it. Treat section_found:false as inconclusive, not "absent", for a raw_truncated document.`,
2590
+ }
2591
+ : {}),
2592
+ text: slice,
2593
+ };
2594
+ }
2595
+
2596
+ // data.sec.gov answers a bare 404 both when no filer has the CIK at all and when
2597
+ // the filer exists but has never submitted XBRL financial data (investment funds,
2598
+ // trusts, individuals, most pre-2011 foreign private issuers). "SEC EDGAR company
2599
+ // facts error: 404" told the caller neither, and booked as `error` (the tier
2600
+ // meaning a Pipeworx defect) instead of the caller's CIK being the problem. The
2601
+ // sibling `sec` pack fixed this same shape (fleet #586); ported here — the two
2602
+ // packs cover the same upstream and had silently drifted. `not_found:` is the
2603
+ // classifier token: the gateway books it as user_error and strips it before the
2604
+ // caller sees it.
2605
+ async function companyFactsNotFound(input: string, paddedCik: string): Promise<Error> {
2606
+ const lookup = 'Resolve a real one with edgar_ticker_to_cik({ticker: "…"}) — it accepts a ticker or a company name.';
2607
+ const sub = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS }).catch(
2608
+ () => null,
2609
+ );
2610
+ if (sub?.ok) {
2611
+ const filer = (await sub.json().catch(() => null)) as { name?: string } | null;
2612
+ const name = filer?.name ? `"${filer.name}"` : 'a filer';
2613
+ return new Error(
2614
+ `not_found: SEC EDGAR knows CIK ${paddedCik} as ${name}, but it has no XBRL financial facts — this filer does not submit XBRL financial statements (typical for investment funds, trusts and individuals). If you meant an operating company, look up its CIK first: ${lookup}`,
2615
+ );
2616
+ }
2617
+ return new Error(
2618
+ `not_found: SEC EDGAR has no company at CIK ${paddedCik} (resolved from "${input}"). EDGAR covers companies registered with the US SEC only. ${lookup}`,
2619
+ );
663
2620
  }
664
2621
 
665
2622
  async function companyFacts(cikOrTicker: string) {
@@ -669,10 +2626,11 @@ async function companyFacts(cikOrTicker: string) {
669
2626
  // resolveCik like every sibling: validates the arg and resolves tickers.
670
2627
  const cik = await resolveCik(cikOrTicker);
671
2628
  const paddedCik = padCik(cik);
672
- const res = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
2629
+ const res = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
673
2630
  headers: SEC_HEADERS,
674
2631
  });
675
- if (!res.ok) throw new Error(`SEC EDGAR company facts error: ${res.status}`);
2632
+ if (res.status === 404) throw await companyFactsNotFound(cikOrTicker, paddedCik);
2633
+ if (!res.ok) throw await httpError(res, 'SEC EDGAR company facts error');
676
2634
 
677
2635
  const data = (await res.json()) as {
678
2636
  cik: number;
@@ -692,52 +2650,22 @@ async function companyFacts(cikOrTicker: string) {
692
2650
  };
693
2651
  };
694
2652
 
2653
+ // Most-recent annual value per concept, with the filer's CURRENT concept
2654
+ // leading and retired concepts flagged stale — see shared/src/xbrl.ts for the
2655
+ // three traps this replaces (fy = filing year, frame on the last filer, and
2656
+ // Microsoft's FY2010 `Revenues` presented as "most recent"). Fleet #594.
695
2657
  const usGaap = data.facts?.['us-gaap'] ?? {};
696
- const KEY_METRICS = [
697
- 'Revenues',
698
- 'RevenueFromContractWithCustomerExcludingAssessedTax',
699
- 'NetIncomeLoss',
700
- 'Assets',
701
- 'Liabilities',
702
- 'StockholdersEquity',
703
- 'CashAndCashEquivalentsAtCarryingValue',
704
- 'EarningsPerShareBasic',
705
- 'EarningsPerShareDiluted',
706
- 'CommonStockSharesOutstanding',
707
- 'OperatingIncomeLoss',
708
- 'GrossProfit',
709
- 'ResearchAndDevelopmentExpense',
710
- ];
711
-
712
- const metrics: Record<
713
- string,
714
- { label: string; most_recent_annual: { year: number; value: number; filed: string } | null }
715
- > = {};
716
-
717
- for (const key of KEY_METRICS) {
718
- const fact = usGaap[key];
719
- if (!fact) continue;
720
-
721
- const usdEntries = fact.units['USD'] ?? fact.units['shares'] ?? [];
722
- // Same fix as companyConcept: don't gate on `frame !== undefined`;
723
- // off-calendar filers (NVDA, AAPL) get their modern annuals dropped.
724
- // Sort by period end (desc) to surface the actual most-recent 10-K.
725
- const annual = usdEntries
726
- .filter((e) => e.form === '10-K' || e.form === '10-K/A')
727
- .sort((a, b) => (b.end ?? '').localeCompare(a.end ?? ''));
728
-
729
- metrics[key] = {
730
- label: fact.label,
731
- most_recent_annual: annual[0]
732
- ? { year: annual[0].fy, value: annual[0].val, filed: annual[0].filed }
733
- : null,
734
- };
735
- }
2658
+ const summary = summarizeAnnualFinancials(usGaap);
736
2659
 
737
2660
  return {
738
2661
  cik: String(data.cik),
739
2662
  company_name: data.entityName,
740
- key_financials: metrics,
2663
+ latest_fiscal_year: summary.latest_fiscal_year,
2664
+ latest_period_end: summary.latest_period_end,
2665
+ latest_annual: summary.latest_annual,
2666
+ key_financials: summary.key_financials,
2667
+ stale_concepts: summary.stale_concepts,
2668
+ year_note: summary.year_note,
741
2669
  available_concepts: Object.keys(usGaap).length,
742
2670
  };
743
2671
  }
@@ -770,12 +2698,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
770
2698
  revenues: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
771
2699
  netincome: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
772
2700
  netincomeloss: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
2701
+ // Without these, "shares outstanding" fell through to the fuzzy name scan,
2702
+ // which matched PreferredStockSharesOutstanding first — and most filers
2703
+ // report 0 preferred. Asking a plain question about Snowflake returned
2704
+ // "0 shares outstanding" for a company with ~330M. A confident zero about a
2705
+ // public company is the worst shape of wrong answer we produce, so the
2706
+ // common-stock tags are named explicitly and ordered.
2707
+ // EntityCommonStockSharesOutstanding is the dei cover-page tag, which is
2708
+ // often fresher than the us-gaap balance-sheet one.
2709
+ sharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
2710
+ commonsharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
2711
+ commonstocksharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding'],
773
2712
  cash: [
774
2713
  'CashAndCashEquivalentsAtCarryingValue',
775
2714
  'Cash',
776
2715
  'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
777
2716
  ],
778
- longtermdebt: ['LongTermDebt', 'LongTermDebtNoncurrent'],
2717
+ longtermdebt: [
2718
+ 'LongTermDebt', 'LongTermDebtNoncurrent',
2719
+ 'LongTermDebtAndCapitalLeaseObligations', 'DebtAndCapitalLeaseObligations',
2720
+ // Post-ASC-842 (2019+) filers and combined-debt filers use different tags.
2721
+ // Also adds debtlongterm* to the company-facts scan token set so the name
2722
+ // scan catches tags where "Debt" precedes "Longterm" (e.g. Ford CIK 37996).
2723
+ 'LongTermDebtAndFinanceLeaseLiability',
2724
+ 'DebtLongtermAndShorttermCombinedAmount',
2725
+ ],
779
2726
  // Common concepts an LLM asks for that were MISSING → 404 (net income was
780
2727
  // covered but these weren't). Keys are bare-alphanumeric (the lookup strips
781
2728
  // spaces/punctuation), so "total assets"/"earnings per share" etc. all match.
@@ -793,7 +2740,9 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
793
2740
  operatingincome: ['OperatingIncomeLoss'],
794
2741
  operatingincomeloss: ['OperatingIncomeLoss'],
795
2742
  // ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
796
- longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
2743
+ longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt', 'LongTermDebtAndFinanceLeaseLiability', 'DebtLongtermAndShorttermCombinedAmount'],
2744
+ longtermdebtandfinanceleaseliability: ['LongTermDebtAndFinanceLeaseLiability', 'LongTermDebt', 'LongTermDebtAndCapitalLeaseObligations'],
2745
+ debtlongtermandshorttermcombinedamount: ['DebtLongtermAndShorttermCombinedAmount', 'LongTermDebt', 'LongTermDebtNoncurrent'],
797
2746
  salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
798
2747
  revenuefromcontractwithcustomerexcludingassessedtax: [
799
2748
  'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
@@ -825,6 +2774,19 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
825
2774
  // only, which silently dropped every foreign filer's data even when the tag
826
2775
  // existed → 404s / empty results.
827
2776
  const ANNUAL_FORMS = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
2777
+ const QUARTERLY_FORMS = new Set(['10-Q', '10-Q/A']);
2778
+ const ALL_PERIODIC_FORMS = new Set([...ANNUAL_FORMS, ...QUARTERLY_FORMS]);
2779
+
2780
+ /** Forms to include for a requested reporting period. Default "all" surfaces
2781
+ * quarterly (10-Q) alongside annual — a metric like Cash moves every quarter,
2782
+ * and returning only the annual 10-K value hid the fresher 10-Q figure
2783
+ * (published an 8-month runway off a 10-K when the 10-Q implied ~5). */
2784
+ function formsForPeriod(period: string | undefined): Set<string> {
2785
+ const p = (period ?? 'all').trim().toLowerCase();
2786
+ if (p === 'annual' || p === 'yearly' || p === 'fy') return ANNUAL_FORMS;
2787
+ if (p === 'quarterly' || p === 'quarter' || p === 'q') return QUARTERLY_FORMS;
2788
+ return ALL_PERIODIC_FORMS;
2789
+ }
828
2790
 
829
2791
  // ifrs-full taxonomy fallback for foreign private issuers (they report under
830
2792
  // ifrs-full, not us-gaap). Keyed by the same normalized metric name; tried only
@@ -845,12 +2807,36 @@ const IFRS_FALLBACK: Record<string, string[]> = {
845
2807
  equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
846
2808
  };
847
2809
 
848
- async function companyConcept(cikOrTicker: string, concept: string) {
2810
+ async function companyConcept(
2811
+ cikOrTicker: string,
2812
+ concept: string,
2813
+ period?: string,
2814
+ fiscalYearArg?: string | number,
2815
+ fiscalPeriodArg?: string,
2816
+ ) {
2817
+ // Checked here rather than by the gateway's declared-`required` pre-flight,
2818
+ // because `cik` accepts three spellings and a flat required list cannot say
2819
+ // "one of these" (see the inputSchema note). Both messages say "is required"
2820
+ // so they classify as a caller error, not as our defect, and both name every
2821
+ // spelling that works — the old gateway refusal named only `cik`, which is
2822
+ // the word the caller had already decided not to use.
2823
+ if (typeof cikOrTicker !== 'string' || !cikOrTicker.trim()) {
2824
+ throw new Error(
2825
+ 'A company is required: pass `cik` (it takes a ticker like "AAPL" or a CIK like "320193"), or its aliases `ticker` / `ticker_or_cik`. Example: edgar_company_concept({cik:"DOV", concept:"NetIncomeLoss"}).',
2826
+ );
2827
+ }
2828
+ if (typeof concept !== 'string' || !concept.trim()) {
2829
+ throw new Error(
2830
+ 'A metric is required: pass `concept` (alias `metric`) — a friendly name like "Revenue", "NetIncomeLoss", "Cash", "LongTermDebt" or "EarningsPerShareDiluted". Example: edgar_company_concept({cik:"DOV", concept:"NetIncomeLoss"}).',
2831
+ );
2832
+ }
2833
+ const forms = formsForPeriod(period);
849
2834
  // Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
850
2835
  // Production analytics: 11% errors on edgar largely from LLMs passing
851
2836
  // tickers as cik (then padCik strips letters → 0000000000 → 404).
852
2837
  const cik = await resolveCik(cikOrTicker);
853
2838
  const paddedCik = padCik(cik);
2839
+ let resolvedEntityName = '';
854
2840
 
855
2841
  // Normalize the lookup key to bare alphanumerics so natural-language input
856
2842
  // ("net income", "Long-Term Debt", "earnings per share") matches the no-space
@@ -870,6 +2856,24 @@ async function companyConcept(cikOrTicker: string, concept: string) {
870
2856
  { end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string }[]
871
2857
  >;
872
2858
  };
2859
+ type ConceptFact = ConceptDoc['units'][string][number];
2860
+
2861
+ // SEC's XBRL JSON is normally { units: { "USD": [ ...facts ] } }, but a small
2862
+ // fraction of documents carry a `units` bucket that is not an array (and a
2863
+ // handful omit `units` entirely). `for (const e of values)` on one of those
2864
+ // throws "values is not iterable" — an unguarded internal TypeError, so it
2865
+ // books as OUR defect rather than an upstream data problem, and the caller
2866
+ // gets a message that names a variable in our source and tells them nothing
2867
+ // they can act on. It was firing on roughly 1 call in 318 of
2868
+ // edgar_company_concept, the highest-volume tool on the platform, which is
2869
+ // exactly the kind of rate that never trips an alarm and never gets fixed.
2870
+ //
2871
+ // Skip the malformed bucket rather than failing the whole lookup: a company's
2872
+ // other units almost always still carry the answer, so a partial answer beats
2873
+ // a crash. If every bucket is malformed the caller falls through to the normal
2874
+ // no-data path, which already says something useful.
2875
+ const unitEntries = (units: ConceptDoc['units'] | undefined): [string, ConceptFact[]][] =>
2876
+ Object.entries(units ?? {}).filter((e): e is [string, ConceptFact[]] => Array.isArray(e[1]));
873
2877
 
874
2878
  // Fetch EVERY candidate that exists, then pick the one with the most recent
875
2879
  // 10-K data — not the first non-empty one. Filers switch revenue tags over
@@ -881,16 +2885,16 @@ async function companyConcept(cikOrTicker: string, concept: string) {
881
2885
  const found: { doc: ConceptDoc; latestEnd: string }[] = [];
882
2886
  const tryNamespace = async (ns: string, tags: string[]) => {
883
2887
  for (const candidate of tags) {
884
- const r = await fetch(
2888
+ const r = await pwFetch(
885
2889
  `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${ns}/${encodeURIComponent(candidate)}.json`,
886
2890
  { headers: SEC_HEADERS },
887
2891
  );
888
2892
  if (!r.ok) { lastStatus = r.status; continue; }
889
2893
  const doc = (await r.json()) as ConceptDoc;
890
2894
  let latestEnd = '';
891
- for (const values of Object.values(doc.units)) {
2895
+ for (const [, values] of unitEntries(doc.units)) {
892
2896
  for (const e of values) {
893
- if (!ANNUAL_FORMS.has(e.form)) continue;
2897
+ if (!ALL_PERIODIC_FORMS.has(e.form)) continue;
894
2898
  if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
895
2899
  }
896
2900
  }
@@ -910,19 +2914,26 @@ async function companyConcept(cikOrTicker: string, concept: string) {
910
2914
  // LongTermDebtNoncurrent, many filers use LongTermDebtAndCapitalLease...). Pull
911
2915
  // the full facts once and find the freshest us-gaap tag whose NAME matches the
912
2916
  // concept keywords, preferring balance-sheet stock tags over cash-flow tags.
913
- if (found.length === 0) {
2917
+ // Also trigger when ALL found data is >3 years stale — filer changed tag.
2918
+ const THREE_YEARS_MS = 3 * 365 * 24 * 3600 * 1000;
2919
+ const bestFoundMs = found.length > 0 ? Math.max(...found.map(f => f.latestEnd ? new Date(f.latestEnd).getTime() : 0)) : 0;
2920
+ const foundIsStale = found.length > 0 && bestFoundMs > 0 && (Date.now() - bestFoundMs) > THREE_YEARS_MS;
2921
+ if (found.length === 0 || foundIsStale) {
914
2922
  const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
915
2923
  const tokens = candidates.map(norm).filter((t) => t.length >= 3);
916
- const factsRes = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
917
- if (factsRes.ok && tokens.length) {
2924
+ const factsRes = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
2925
+ if (!factsRes.ok) {
2926
+ lastStatus = factsRes.status;
2927
+ } else if (tokens.length) {
918
2928
  const facts = (await factsRes.json()) as { entityName?: string; facts?: { 'us-gaap'?: Record<string, { label?: string; description?: string; units: Record<string, ConceptDoc['units'][string]> }> } };
2929
+ resolvedEntityName = facts.entityName ?? '';
919
2930
  const gaap = facts.facts?.['us-gaap'] ?? {};
920
2931
  const FLOW = /^(ProceedsFrom|RepaymentsOf|PaymentsOf|IncreaseDecrease|AmortizationOf|Gain|Loss)/;
921
2932
  const matches: { isFlow: boolean; latestEnd: string; doc: ConceptDoc }[] = [];
922
2933
  for (const [tag, entry] of Object.entries(gaap)) {
923
2934
  if (!tokens.some((t) => norm(tag).includes(t))) continue;
924
2935
  let latestEnd = '';
925
- for (const values of Object.values(entry.units)) {
2936
+ for (const [, values] of unitEntries(entry.units)) {
926
2937
  for (const e of values) {
927
2938
  if (e.form !== '10-K' && e.form !== '10-K/A') continue;
928
2939
  if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
@@ -938,10 +2949,82 @@ async function companyConcept(cikOrTicker: string, concept: string) {
938
2949
  if (matches.length) found.push({ doc: matches[0].doc, latestEnd: matches[0].latestEnd });
939
2950
  }
940
2951
  }
2952
+ // Still stale after the name scan — every tag we can see is >3y old and the
2953
+ // scan found nothing fresher. Don't hand back the old number. It is the worst
2954
+ // failure shape we ship: a well-formed, plausible, five-year-old answer with
2955
+ // no marker on it, so neither the caller nor any automatic check can tell it
2956
+ // apart from a current one.
2957
+ //
2958
+ // Ford is the reference case, measured 2026-08-01. companyconcept for
2959
+ // LongTermDebtNoncurrent serves 4 facts ending at end=2020-12-31 /
2960
+ // filed=2021-02-05, and LongTermDebt, LongTermDebtAndCapitalLeaseObligations
2961
+ // and DebtLongtermAndShorttermCombinedAmount all 404 — yet Ford filed a 10-Q
2962
+ // on 2026-07-29. The debt is right there in that filing, tagged
2963
+ // LongTermDebtAndCapitalLeaseObligations, but reported ONLY under
2964
+ // StatementBusinessSegmentsAxis (Company-excluding-Ford-Credit vs Ford
2965
+ // Credit) with no undimensioned consolidated total. companyfacts and
2966
+ // companyconcept expose dimensionless facts only, so the live value is
2967
+ // invisible to this API no matter which tag we ask for. Any filer that splits
2968
+ // a balance-sheet line across segments and never totals it has this hole; the
2969
+ // only real fix is a second ingestion path (inline XBRL / SEC's Financial
2970
+ // Statement Data Sets, whose num.txt carries the segments column).
2971
+ //
2972
+ // We still name the stale tag we found, so a caller who genuinely wants the
2973
+ // historical figure knows exactly where it is rather than being told nothing.
2974
+ if (found.length > 0) {
2975
+ const freshest = found.reduce((a, b) => ((b.latestEnd ?? '') > (a.latestEnd ?? '') ? b : a));
2976
+ const freshestMs = freshest.latestEnd ? new Date(freshest.latestEnd).getTime() : 0;
2977
+ if (freshestMs > 0 && Date.now() - freshestMs > THREE_YEARS_MS) {
2978
+ const years = Math.floor((Date.now() - freshestMs) / (365 * 24 * 3600 * 1000));
2979
+ return {
2980
+ cik: String(cik),
2981
+ company_name: resolvedEntityName || freshest.doc.entityName || null,
2982
+ concept,
2983
+ candidates_tried: candidates,
2984
+ period: (period ?? 'all').trim().toLowerCase(),
2985
+ reported: false,
2986
+ latest: null,
2987
+ values: [],
2988
+ stale_tag: { tag: freshest.doc.tag, latest_period_end: freshest.latestEnd, years_old: years },
2989
+ note: `No CURRENT value for "${concept}" is available from SEC's XBRL API for this filer. The freshest tag we can see, ${freshest.doc.tag}, stops at ${freshest.latestEnd} (~${years}y old) and every alternative name 404s — but the company may well still be filing. The usual cause is that the filer reports this line only under a dimension (most often segment breakdowns, e.g. Ford splits long-term debt into Company-vs-Ford-Credit and publishes no consolidated total); companyfacts and companyconcept expose UNDIMENSIONED facts only, so a dimension-only line is invisible here regardless of which tag is requested. latest is null, not zero, and deliberately not the ${freshest.latestEnd} figure — that number is real history but is NOT this company's current ${concept}. To get the current value, read the segment table in the latest filing directly: edgar_filings({cik:"${cikOrTicker}", form:"10-Q"}). To see every tag this filer does publish undimensioned, call edgar_company_facts({cik:"${cikOrTicker}"}).`,
2990
+ };
2991
+ }
2992
+ }
941
2993
  if (found.length === 0) {
942
- throw new Error(
943
- `SEC EDGAR company concept error: ${lastStatus} — none of the candidate concepts ${JSON.stringify(candidates)} exist for CIK ${paddedCik}, and no us-gaap tag matched. Use edgar_company_facts({cik: "${cikOrTicker}"}) to see every concept this filer reports.`,
944
- );
2994
+ // Distinguish a genuine "this filer doesn't report the concept" (every
2995
+ // lookup 404'd) from an upstream failure (SEC throttle / 5xx). Only the
2996
+ // latter should surface as an error the caller retries. A 404 after we've
2997
+ // exhausted us-gaap candidates + IFRS + a full company-facts name scan
2998
+ // means the concept is genuinely absent — for a balance-sheet item like
2999
+ // long-term debt on an equity-funded filer, that's the FACT "reports none",
3000
+ // not a failure. Return it as a clean not-reported result (usable answer,
3001
+ // and it stops mis-classifying as an error). Was the single largest edgar
3002
+ // error class (LongTermDebt on clinical-stage biotechs, 2026-07).
3003
+ if (lastStatus === 403 || lastStatus === 429 || lastStatus >= 500) {
3004
+ throw new Error(
3005
+ `SEC EDGAR company concept error: ${lastStatus} — SEC upstream ${lastStatus === 403 || lastStatus === 429 ? 'rate-limited' : 'unavailable'} while resolving "${concept}" for CIK ${paddedCik}. Retry shortly.`,
3006
+ );
3007
+ }
3008
+ // Nothing resolved the entity's NAME either, and companyfacts 404'd — so
3009
+ // this CIK is not a registrant, and the not-reported answer below would be
3010
+ // a statement about a company that does not exist ("this filer reports no
3011
+ // tag" implies a filer). Requires BOTH conditions: a real filer whose
3012
+ // companyfacts merely 404s still has resolvedEntityName from elsewhere and
3013
+ // keeps the deliberate not-reported behaviour.
3014
+ if (!resolvedEntityName && lastStatus === 404) {
3015
+ throw submissionsError(404, cikOrTicker, paddedCik);
3016
+ }
3017
+ return {
3018
+ cik: String(cik),
3019
+ company_name: resolvedEntityName || null,
3020
+ concept,
3021
+ candidates_tried: candidates,
3022
+ period: (period ?? 'all').trim().toLowerCase(),
3023
+ reported: false,
3024
+ latest: null,
3025
+ values: [],
3026
+ note: `This filer reports no XBRL tag matching "${concept}" under any recognized name — checked us-gaap candidates ${JSON.stringify(candidates)}, IFRS, and a full company-facts name scan. This means EITHER the company genuinely has none (common for e.g. long-term debt at equity-funded filers) OR it reports the item under a tag we didn't match (e.g. convertible/other debt lines). value is null, not zero — call edgar_company_facts({cik:"${cikOrTicker}"}) to see the exact tags this filer uses before treating it as absent.`,
3027
+ };
945
3028
  }
946
3029
  found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
947
3030
  const data = found[0].doc;
@@ -959,14 +3042,29 @@ async function companyConcept(cikOrTicker: string, concept: string) {
959
3042
  // caught this.
960
3043
  // - DO dedupe: a 10-K and its 10-K/A amendments both report the same
961
3044
  // (fy, fp, end) tuple. Keep the most-recently-filed version.
962
- type Raw = { end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
3045
+ type Raw = { start?: string; end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
3046
+ const spanDays = (a?: string, b?: string) => (a && b) ? Math.round((Date.parse(b) - Date.parse(a)) / 86400000) : NaN;
3047
+ // Does this fact's DURATION match its period label? A 10-Q files BOTH the
3048
+ // discrete 3-month quarter AND the year-to-date figure under the same
3049
+ // (fy, fp, end) tuple, filed the same day — so "most recently filed" was a
3050
+ // coin toss between them, and the YTD often won: "AAPL revenue Q3" answered
3051
+ // with NINE MONTHS of revenue labeled Q3 (found via fleet #250). Prefer the
3052
+ // fact whose span matches the label; fall back to filing recency only
3053
+ // between facts of the same shape.
3054
+ const labelFits = (e: Raw): boolean => {
3055
+ const span = spanDays(e.start, e.end);
3056
+ if (Number.isNaN(span)) return false; // instant concepts have no start; nothing to prefer
3057
+ return /^Q[123]$/.test(e.fp ?? '') ? (span >= 80 && span <= 100) : (span >= 330 && span <= 400);
3058
+ };
963
3059
  const dedup = new Map<string, { entry: Raw; unit: string }>();
964
- for (const [unit, values] of Object.entries(data.units)) {
3060
+ for (const [unit, values] of unitEntries(data.units)) {
965
3061
  for (const e of values) {
966
- if (!ANNUAL_FORMS.has(e.form)) continue; // 10-K (+/A) domestic, 20-F/40-F (+/A) foreign
3062
+ if (!forms.has(e.form)) continue; // period-scoped: annual (10-K/20-F/40-F), quarterly (10-Q), or all
967
3063
  const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
968
3064
  const prior = dedup.get(key);
969
- if (!prior || (e.filed ?? '') > (prior.entry.filed ?? '')) {
3065
+ if (!prior
3066
+ || (labelFits(e) && !labelFits(prior.entry))
3067
+ || (labelFits(e) === labelFits(prior.entry) && (e.filed ?? '') > (prior.entry.filed ?? ''))) {
970
3068
  dedup.set(key, { entry: e, unit });
971
3069
  }
972
3070
  }
@@ -974,20 +3072,235 @@ async function companyConcept(cikOrTicker: string, concept: string) {
974
3072
  const entries = [...dedup.values()]
975
3073
  .map(({ entry, unit }) => ({
976
3074
  fiscal_year: entry.fy,
3075
+ fiscal_period: entry.fp ?? 'FY', // FY (annual) | Q1 | Q2 | Q3
3076
+ period_start: entry.start,
977
3077
  period_end: entry.end,
978
3078
  value: entry.val,
3079
+ form: entry.form, // 10-K / 10-Q / 20-F / 40-F (+ /A amendments)
979
3080
  filed: entry.filed,
980
3081
  unit,
3082
+ derived: false as boolean | undefined,
3083
+ note: undefined as string | undefined,
981
3084
  }))
982
3085
  .sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
983
3086
 
3087
+ // Derive standalone Q4 rows. SEC filers report Q1-Q3 in 10-Qs and the full
3088
+ // year in the 10-K — a standalone Q4 fact does not exist in XBRL, so a
3089
+ // question like "Apple revenue Q4 2024" was structurally unanswerable from
3090
+ // this payload, and callers reached for `latest` instead (a confidently
3091
+ // wrong period — fleet #250). Q4 = FY − Q1 − Q2 − Q3, computed only when
3092
+ // the arithmetic is actually valid:
3093
+ // - duration concepts only (rows carry period_start). Instant concepts
3094
+ // (cash, assets) are point-in-time — the FY value IS the year-end
3095
+ // reading and subtraction is meaningless.
3096
+ // - not per-share/ratio units ("USD/shares"): EPS does not sum across
3097
+ // quarters when the share count moves.
3098
+ // - the FY row must span 330-400 days. 10-K comparative periods are
3099
+ // reported under the FILING's fiscal year (a fy-2019 row can carry
3100
+ // fiscal 2017 dates — the known contamination trap), so trusting `fy`
3101
+ // alone subtracts quarters from the wrong year; the date window is
3102
+ // what makes the row the year it claims to be.
3103
+ // - exactly 3 distinct ~quarter-length (80-100d) windows inside the FY
3104
+ // window. Fewer means a quarter is missing; more means duplicate or
3105
+ // YTD rows slipped in, and either way the subtraction is garbage.
3106
+ const derivedQ4: typeof entries = [];
3107
+ for (const fyRow of entries) {
3108
+ if (fyRow.fiscal_period !== 'FY' || fyRow.unit.includes('/')) continue;
3109
+ const span = spanDays(fyRow.period_start, fyRow.period_end);
3110
+ if (!(span >= 330 && span <= 400)) continue;
3111
+ const quarters = new Map<string, typeof entries[number]>();
3112
+ for (const q of entries) {
3113
+ if (!/^Q[123]$/.test(q.fiscal_period) || q.unit !== fyRow.unit) continue;
3114
+ const qSpan = spanDays(q.period_start, q.period_end);
3115
+ if (!(qSpan >= 80 && qSpan <= 100)) continue;
3116
+ if (!q.period_start || !fyRow.period_start || q.period_start < fyRow.period_start || q.period_end > fyRow.period_end) continue;
3117
+ quarters.set(`${q.period_start}|${q.period_end}`, q);
3118
+ }
3119
+ if (quarters.size !== 3) continue;
3120
+ const qSum = [...quarters.values()].reduce((s, q) => s + q.value, 0);
3121
+ derivedQ4.push({
3122
+ fiscal_year: fyRow.fiscal_year,
3123
+ fiscal_period: 'Q4',
3124
+ period_start: undefined,
3125
+ period_end: fyRow.period_end,
3126
+ value: fyRow.value - qSum,
3127
+ form: 'derived',
3128
+ filed: fyRow.filed,
3129
+ unit: fyRow.unit,
3130
+ derived: true,
3131
+ note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
3132
+ });
3133
+ }
3134
+ if (derivedQ4.length > 0) {
3135
+ // A fiscal year re-reported as a 10-K comparative period spans the same
3136
+ // dates as the original FY row and would mint a duplicate Q4 — keep one
3137
+ // derivation per (period_end, unit). Prefer the LOWEST fiscal_year label:
3138
+ // comparatives carry the FILING's fy over earlier dates (a fy-2026 row
3139
+ // spanning fiscal 2024), so the lowest label per window is the original
3140
+ // 10-K's, the one whose year actually names the window.
3141
+ const byWindow = new Map<string, typeof entries[number]>();
3142
+ for (const d of derivedQ4) {
3143
+ const k = `${d.period_end}|${d.unit}`;
3144
+ const prior = byWindow.get(k);
3145
+ if (!prior || (d.fiscal_year ?? Infinity) < (prior.fiscal_year ?? Infinity)) byWindow.set(k, d);
3146
+ }
3147
+ entries.push(...byWindow.values());
3148
+ entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
3149
+ }
3150
+
3151
+ // PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
3152
+ // names of two fields on every row below, and callers asking a one-period
3153
+ // question ("net income fiscal 2024") send them as ARGUMENTS — measured on
3154
+ // every partial-outcome call this tool logged over three days. They used to
3155
+ // be undeclared, so the gateway dropped them and the caller got all 89-230
3156
+ // periods back with no indication its filter had been ignored: the
3157
+ // dropped-filter-returns-too-much shape, where whichever period the
3158
+ // synthesizer happens to read becomes the answer. Honor them instead.
3159
+ //
3160
+ // An unmatched filter returns NO rows and says which periods exist, rather
3161
+ // than falling back to every period — a filter that silently widens is how a
3162
+ // confidently-wrong year gets reported, and `latest` is deliberately null so
3163
+ // there is no plausible number sitting there to be mistaken for the one
3164
+ // asked for.
3165
+ const fyWanted = fiscalYearArg === undefined || fiscalYearArg === null || String(fiscalYearArg).trim() === ''
3166
+ ? null
3167
+ : String(fiscalYearArg).trim();
3168
+ const fpWanted = typeof fiscalPeriodArg === 'string' && fiscalPeriodArg.trim()
3169
+ ? fiscalPeriodArg.trim().toUpperCase()
3170
+ : null;
3171
+ let periodFilter: Record<string, unknown> | undefined;
3172
+ let rows = entries;
3173
+ if (fyWanted || fpWanted) {
3174
+ const matched = entries.filter((e) =>
3175
+ (!fyWanted || String(e.fiscal_year) === fyWanted)
3176
+ && (!fpWanted || String(e.fiscal_period).toUpperCase() === fpWanted));
3177
+ const availableYears = [...new Set(entries.map((e) => e.fiscal_year).filter((y) => y != null))].sort((a, b) => Number(b) - Number(a));
3178
+ const availablePeriods = [...new Set(entries.map((e) => String(e.fiscal_period)))].sort();
3179
+ rows = matched;
3180
+ periodFilter = {
3181
+ fiscal_year: fyWanted,
3182
+ fiscal_period: fpWanted,
3183
+ matched: matched.length,
3184
+ of_periods_reported: entries.length,
3185
+ ...(matched.length === 0
3186
+ ? {
3187
+ available_fiscal_years: availableYears,
3188
+ available_fiscal_periods: availablePeriods,
3189
+ note:
3190
+ `No row matches ${[fyWanted ? `fiscal_year ${fyWanted}` : null, fpWanted ? `fiscal_period ${fpWanted}` : null].filter(Boolean).join(' + ')} for "${data.tag}". `
3191
+ + `values is EMPTY and latest is null on purpose — this filer's reported periods are ${availableYears.length ? `fiscal years ${availableYears.join(', ')}` : '(none)'}`
3192
+ + `${availablePeriods.length ? ` and periods ${availablePeriods.join(', ')}` : ''}, so no number here is the one you asked for. `
3193
+ + `fiscal_year is the FILER'S OWN label, not a calendar year (NVDA's FY2024 ended January 2024), and on a 10-K's comparative rows it carries the FILING's fiscal year rather than the year the dates fall in — if you need an exact window, drop the filter and match on period_end instead.`,
3194
+ }
3195
+ : {}),
3196
+ // A matched filter is not automatically an unambiguous one. A 10-K tags
3197
+ // its COMPARATIVE periods under the filing's own fiscal year, so
3198
+ // fiscal_year 2023 on ITW returns three rows all labeled 2023 with
3199
+ // period_end 2023-12-31, 2022-12-31 and 2021-12-31 (verified live).
3200
+ // Every one is a real filed fact; only the first is the year the label
3201
+ // names. `values` is sorted period_end DESC and `latest` is already that
3202
+ // first row, so the right answer is what a caller gets by default — but
3203
+ // say so here rather than leaving three same-labeled numbers to be
3204
+ // picked between, which is the confidently-wrong-period failure this
3205
+ // filter exists to prevent.
3206
+ ...((() => {
3207
+ if (matched.length < 2) return {};
3208
+ const ends = [...new Set(matched.map((e) => e.period_end))];
3209
+ if (ends.length < 2) return {};
3210
+ return {
3211
+ ambiguity_note:
3212
+ `${matched.length} rows carry the label fiscal_year ${matched[0].fiscal_year}, covering ${ends.length} different periods (${ends.join(', ')}). `
3213
+ + `That is a 10-K reporting its comparative years under the FILING's fiscal year, not ${matched[0].fiscal_year} being reported ${matched.length} times. `
3214
+ + `values[0] and latest are the row whose dates actually fall in the labeled year (period_end ${ends[0]}) — use those, and read period_end rather than fiscal_year if you need to be certain which window a figure covers.`,
3215
+ };
3216
+ })()),
3217
+ };
3218
+ }
3219
+
3220
+ // SUBSTITUTION DISCLOSURE (fleet #219).
3221
+ //
3222
+ // The name-scan fallback above matches any us-gaap tag CONTAINING a candidate
3223
+ // token, which is what makes it useful — filers rename things constantly, and
3224
+ // finding LongTermDebtAndCapitalLeaseObligations when you asked for
3225
+ // LongTermDebt is the whole point. But containment cuts both ways: asking for
3226
+ // InterestExpense matches FinanceLeaseInterestExpense, and Delta then returns
3227
+ // $37M of finance-lease interest as though it were total interest expense —
3228
+ // off by an order of magnitude, well-formed, and with nothing on it to say so.
3229
+ // A credit analyst reads that number and misjudges the debt burden.
3230
+ //
3231
+ // `concept` already carries the tag actually served, so a careful caller
3232
+ // COULD notice. Nobody does: the synthesizing model reads a plausible number
3233
+ // under a plausible label and reports it. This file already refuses to hand
3234
+ // back a stale figure without a marker, for exactly the reason stated in the
3235
+ // comment above — "a well-formed, plausible answer with no marker on it, so
3236
+ // neither the caller nor any automatic check can tell it apart". A
3237
+ // substituted tag is the same shape, and gets the same treatment.
3238
+ const requested = concept.trim();
3239
+ const substituted = requested.length > 0
3240
+ && data.tag.toLowerCase().replace(/[^a-z0-9]/g, '') !== requested.toLowerCase().replace(/[^a-z0-9]/g, '');
3241
+
3242
+ // Coverage-bound disclosure (fleet #693). A returned value can be genuinely
3243
+ // CURRENT for this exact XBRL tag while a newer periodic filing exists that
3244
+ // simply doesn't reuse the tag — verified live on Brown-Forman (CIK 14693):
3245
+ // its FY2026 10-K (filed 2026-06-12) tags dozens of concepts but never
3246
+ // us-gaap:CommonStockSharesOutstanding, and dei:EntityCommonStockSharesOutstanding
3247
+ // is 404 across BF's ENTIRE XBRL history (confirmed via companyfacts — not a
3248
+ // one-year gap, this filer has never used that dei tag). SEC's own
3249
+ // companyconcept API is correct as far as it goes; the gap is upstream, in
3250
+ // what the filer chose to tag. That's invisible to the 3-year stale-tag
3251
+ // guard above (BF is ~15mo, not ~3y) and reads exactly like a current
3252
+ // answer with nothing on it to say otherwise — the same failure shape that
3253
+ // guard exists to prevent, just under its threshold. Flag it whenever the
3254
+ // freshest fact is older than one normal filing cycle for its own form
3255
+ // type. No extra fetch: compare the fact's own `filed` date to today.
3256
+ // Filtered rows, not every row: with fiscal_year/fiscal_period set, `latest`
3257
+ // must be the freshest fact WITHIN the period asked for, or the one field a
3258
+ // synthesizer reaches for first would answer a different year than the
3259
+ // question (fleet #2058).
3260
+ const latestFact = rows.find((e) => !e.derived) ?? null;
3261
+ let coverageBound: string | undefined;
3262
+ if (latestFact?.filed) {
3263
+ const daysSinceFiled = (Date.now() - Date.parse(latestFact.filed)) / 86400000;
3264
+ const isAnnual = ANNUAL_FORMS.has(latestFact.form);
3265
+ const cycleDays = isAnnual ? 400 : 120; // ~13mo (annual cadence + filing-lag grace) / ~4mo (quarterly + grace)
3266
+ if (Number.isFinite(daysSinceFiled) && daysSinceFiled > cycleDays) {
3267
+ coverageBound =
3268
+ `This is the freshest value SEC's XBRL API has for "${data.tag}" — from a ${latestFact.form} filed ` +
3269
+ `${latestFact.filed}, ${Math.floor(daysSinceFiled)} days ago, past the normal ${isAnnual ? 'annual' : 'quarterly'} filing cycle. ` +
3270
+ `If ${data.entityName} has filed a newer ${isAnnual ? '10-K' : '10-Q'} since then, it may simply not tag this specific concept in it ` +
3271
+ `(confirmed on Brown-Forman's FY2026 10-K: it tags hundreds of other concepts, not this one, and never tags the dei cover-page ` +
3272
+ `share count either) — this is not necessarily a gap in our data. Check edgar_company_filings({ticker_or_cik:"${cikOrTicker}"}) ` +
3273
+ `for filings newer than ${latestFact.filed}, and edgar_company_facts({cik:"${String(data.cik)}"}) for every tag the filer currently uses.`;
3274
+ }
3275
+ }
3276
+
984
3277
  return {
985
3278
  cik: String(data.cik),
986
3279
  company_name: data.entityName,
987
3280
  concept: data.tag,
3281
+ ...(substituted
3282
+ ? {
3283
+ requested_concept: requested,
3284
+ concept_substituted: true,
3285
+ substitution_note:
3286
+ `You asked for "${requested}"; this filer does not report that exact tag, so the closest matching tag ` +
3287
+ `"${data.tag}" (${data.label}) is returned instead. These are NOT interchangeable — a narrower tag can be ` +
3288
+ `a small fraction of the concept you asked for. Do NOT present this as "${requested}" without saying which ` +
3289
+ `tag it came from. Call edgar_company_facts({cik:"${String(data.cik)}"}) to see every tag this filer uses.`,
3290
+ }
3291
+ : {}),
988
3292
  label: data.label,
989
3293
  description: data.description,
990
- annual_values: entries,
3294
+ period: (period ?? 'all').trim().toLowerCase(),
3295
+ // Freshest data point across the requested period — use THIS for
3296
+ // point-in-time metrics (cash, runway, debt); it is the latest 10-Q when
3297
+ // one is more recent than the last 10-K, so an annual figure never masks a
3298
+ // newer quarter. Always a REPORTED fact, never a derived Q4.
3299
+ latest: latestFact,
3300
+ ...(coverageBound ? { coverage_bound: coverageBound } : {}),
3301
+ ...(periodFilter ? { period_filter: periodFilter } : {}),
3302
+ values: rows,
3303
+ annual_values: rows, // deprecated alias; prefer `values` (now period-scoped)
991
3304
  };
992
3305
  }
993
3306
 
@@ -1019,9 +3332,13 @@ function xmlVal(block: string, tag: string): string | null {
1019
3332
  return plain ? plain[1].trim() : null;
1020
3333
  }
1021
3334
 
3335
+ // Tolerates an attribute-bearing opening tag (`<tr class="ro">`, `<Report
3336
+ // instance="...">`) as well as a bare one — a strict superset of matching
3337
+ // `<tag>` only, so every existing plain-XML caller (Form 4/N-PORT, no
3338
+ // attributes) still matches exactly the same blocks it always did.
1022
3339
  function xmlBlocks(xml: string, tag: string): string[] {
1023
3340
  const out: string[] = [];
1024
- const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'gi');
3341
+ const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, 'gi');
1025
3342
  let m: RegExpExecArray | null;
1026
3343
  while ((m = re.exec(xml)) !== null) out.push(m[1]);
1027
3344
  return out;
@@ -1089,8 +3406,8 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
1089
3406
  const paddedCik = padCik(cik);
1090
3407
  const count = Math.min(25, Math.max(1, limit ?? 10));
1091
3408
 
1092
- const res = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
1093
- if (!res.ok) throw new Error(`SEC EDGAR submissions error: ${res.status}`);
3409
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3410
+ if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
1094
3411
  const data = (await res.json()) as {
1095
3412
  cik: string;
1096
3413
  name: string;
@@ -1120,7 +3437,7 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
1120
3437
  const rawDoc = t.doc.replace(/^xsl[^/]*\//i, '');
1121
3438
  const url = `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accPath}/${rawDoc}`;
1122
3439
  try {
1123
- const r = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3440
+ const r = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
1124
3441
  if (!r.ok) return { accession_number: t.accession, filing_date: t.date, form: t.form, error: `fetch ${r.status}`, filing_url: url };
1125
3442
  const xml = await r.text();
1126
3443
  const parsed = parseForm4Transactions(xml, includeDerivatives ?? false);
@@ -1162,8 +3479,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1162
3479
  const paddedCik = padCik(cik);
1163
3480
  const topN = Math.min(100, Math.max(1, limit ?? 25));
1164
3481
 
1165
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
1166
- if (!subRes.ok) throw new Error(`SEC EDGAR submissions error: ${subRes.status}`);
3482
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3483
+ if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
1167
3484
  const sub = (await subRes.json()) as {
1168
3485
  cik: string;
1169
3486
  name: string;
@@ -1188,8 +3505,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1188
3505
  // is neither primary_doc.xml (cover page) nor an xsl-rendered viewer copy.
1189
3506
  const accPath = target.accession.replace(/-/g, '');
1190
3507
  const folder = `https://www.sec.gov/Archives/edgar/data/${sub.cik}/${accPath}`;
1191
- const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
1192
- if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
3508
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
3509
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
1193
3510
  const idx = (await idxRes.json()) as { directory: { item: { name: string }[] } };
1194
3511
  const infoFile = idx.directory.item.find(
1195
3512
  (it) => it.name.toLowerCase().endsWith('.xml') && it.name !== 'primary_doc.xml' && !/^xsl/i.test(it.name),
@@ -1198,8 +3515,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1198
3515
  throw new Error(`13F information table not found in filing ${target.accession}.`);
1199
3516
  }
1200
3517
 
1201
- const tableRes = await fetch(`${folder}/${infoFile.name}`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
1202
- if (!tableRes.ok) throw new Error(`SEC EDGAR 13F table error: ${tableRes.status}`);
3518
+ const tableRes = await pwFetch(`${folder}/${infoFile.name}`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3519
+ if (!tableRes.ok) throw await httpError(tableRes, 'SEC EDGAR 13F table error');
1203
3520
  const xml = stripNs(await tableRes.text());
1204
3521
 
1205
3522
  // Aggregate rows by issuer+cusip+putCall (managers file multiple sub-portfolio
@@ -1224,15 +3541,44 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1224
3541
 
1225
3542
  const all = [...byKey.values()].sort((a, b) => b.value_usd - a.value_usd);
1226
3543
  const totalValue = all.reduce((s, h) => s + h.value_usd, 0);
3544
+
3545
+ // A 13F row for an OPTION is not ownership of the underlying, and a PUT is a
3546
+ // BEARISH position. Both facts are carried only by the `putCall` tag, which a
3547
+ // reader can easily skip — so a value-ranked list leads with "PALANTIR ...
3548
+ // 66.04%" for a manager who is in fact positioned AGAINST Palantir via puts.
3549
+ // Misreporting Scion's puts as top holdings is a well-known failure mode. We
3550
+ // already parse putCall; the job here is to make it impossible to miss —
3551
+ // label direction per row and total the legs separately so nothing sums
3552
+ // bullish and bearish notional into one "portfolio".
3553
+ const isPut = (pc: string | null) => (pc ?? '').toLowerCase() === 'put';
3554
+ const isCall = (pc: string | null) => (pc ?? '').toLowerCase() === 'call';
3555
+ const directionOf = (pc: string | null) =>
3556
+ isPut(pc) ? 'bearish (put option)' : isCall(pc) ? 'bullish (call option)' : 'long equity';
3557
+
3558
+ const sumWhere = (pred: (h: Agg) => boolean) =>
3559
+ all.filter(pred).reduce((s, h) => s + h.value_usd, 0);
3560
+ const longEquityValue = sumWhere((h) => !isPut(h.put_call) && !isCall(h.put_call));
3561
+ const putValue = sumWhere((h) => isPut(h.put_call));
3562
+ const callValue = sumWhere((h) => isCall(h.put_call));
3563
+
1227
3564
  const holdings = all.slice(0, topN).map((h) => ({
1228
3565
  issuer: h.issuer,
1229
3566
  cusip: h.cusip,
1230
3567
  put_call: h.put_call,
3568
+ direction: directionOf(h.put_call),
1231
3569
  value_usd: h.value_usd,
1232
3570
  shares: h.shares,
3571
+ // Retained for compatibility, but it divides by a total that mixes option
3572
+ // notional with equity; pct_of_long_equity is the one to rank holdings by.
1233
3573
  pct_of_portfolio: totalValue > 0 ? Math.round((h.value_usd / totalValue) * 10000) / 100 : null,
3574
+ pct_of_long_equity:
3575
+ !isPut(h.put_call) && !isCall(h.put_call) && longEquityValue > 0
3576
+ ? Math.round((h.value_usd / longEquityValue) * 10000) / 100
3577
+ : null,
1234
3578
  }));
1235
3579
 
3580
+ const hasOptions = putValue > 0 || callValue > 0;
3581
+
1236
3582
  return {
1237
3583
  cik: sub.cik,
1238
3584
  manager_name: sub.name,
@@ -1240,6 +3586,20 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1240
3586
  report_period: target.period,
1241
3587
  filed_date: target.filed,
1242
3588
  total_portfolio_value_usd: totalValue,
3589
+ // Split the single "portfolio value" into legs that mean different things.
3590
+ // For options these are the NOTIONAL value of the underlying shares, not
3591
+ // premium paid — a $912M put line is not $912M of capital at risk.
3592
+ position_summary: {
3593
+ long_equity_value_usd: longEquityValue,
3594
+ put_option_notional_usd: putValue,
3595
+ call_option_notional_usd: callValue,
3596
+ long_equity_positions: all.filter((h) => !isPut(h.put_call) && !isCall(h.put_call)).length,
3597
+ put_positions: all.filter((h) => isPut(h.put_call)).length,
3598
+ call_positions: all.filter((h) => isCall(h.put_call)).length,
3599
+ },
3600
+ interpretation_note: hasOptions
3601
+ ? 'This filing contains OPTION positions. A `put` row is a BEARISH bet against the issuer, not a holding of it — do not describe it as something the manager owns. For option rows, value_usd is the notional value of the underlying shares, not premium paid or capital at risk, so it is not comparable to an equity position of the same dollar size. Rank actual holdings by pct_of_long_equity; total_portfolio_value_usd sums all legs and is not a meaningful portfolio size.'
3602
+ : 'All positions are long equity (no options in this filing).',
1243
3603
  total_positions: all.length,
1244
3604
  holdings_returned: holdings.length,
1245
3605
  holdings,
@@ -1251,6 +3611,9 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1251
3611
  function decodeEntities(s: string | null): string | null {
1252
3612
  if (s == null) return null;
1253
3613
  return s
3614
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
3615
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
3616
+ .replace(/&nbsp;/gi, ' ')
1254
3617
  .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
1255
3618
  .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'");
1256
3619
  }
@@ -1260,8 +3623,8 @@ function decodeEntities(s: string | null): string | null {
1260
3623
  let mfTickerCache: Map<string, { cik: string; seriesId: string }> | null = null;
1261
3624
  async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesId: string } | null> {
1262
3625
  if (!mfTickerCache) {
1263
- const res = await fetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
1264
- if (!res.ok) throw new Error(`SEC fund-ticker lookup error: ${res.status}`);
3626
+ const res = await pwFetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
3627
+ if (!res.ok) throw await httpError(res, 'SEC fund-ticker lookup error');
1265
3628
  const data = (await res.json()) as { fields: string[]; data: Array<[number, string, string, string]> };
1266
3629
  // fields: [cik, seriesId, classId, symbol]
1267
3630
  mfTickerCache = new Map();
@@ -1274,7 +3637,11 @@ async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesI
1274
3637
 
1275
3638
  async function fundHoldings(ticker: string, limit?: number) {
1276
3639
  const t = String(ticker ?? '').trim();
1277
- if (!t) throw new Error('Required argument "ticker" is missing. Pass an ETF/fund ticker like "ARKK" or "SPY".');
3640
+ if (!t) throw new Error(
3641
+ 'A fund ticker is required: pass `ticker`, or its alias `ticker_or_cik` — an ETF or ' +
3642
+ 'mutual-fund ticker like "ARKK", "QQQ" or "VOO". N-PORT funds are keyed by ticker, so a ' +
3643
+ 'bare CIK has nothing to match here. Example: edgar_fund_holdings({ticker: "ARKK"}).'
3644
+ );
1278
3645
  const topN = Math.min(100, Math.max(1, limit ?? 25));
1279
3646
 
1280
3647
  const resolved = await resolveFundTicker(t);
@@ -1284,8 +3651,8 @@ async function fundHoldings(ticker: string, limit?: number) {
1284
3651
  const { cik, seriesId } = resolved;
1285
3652
  const paddedCik = padCik(cik);
1286
3653
 
1287
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
1288
- if (!subRes.ok) throw new Error(`SEC submissions error: ${subRes.status}`);
3654
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3655
+ if (!subRes.ok) throw submissionsError(subRes.status, t, paddedCik);
1289
3656
  const sub = (await subRes.json()) as {
1290
3657
  cik: string; name: string;
1291
3658
  filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[] } };
@@ -1306,7 +3673,7 @@ async function fundHoldings(ticker: string, limit?: number) {
1306
3673
  const accPath = f.accession.replace(/-/g, '');
1307
3674
  const url = `https://www.sec.gov/Archives/edgar/data/${cik}/${accPath}/primary_doc.xml`;
1308
3675
  try {
1309
- const res = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3676
+ const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
1310
3677
  if (!res.ok) return null;
1311
3678
  return { date: f.date, accession: f.accession, xml: stripNs(await res.text()) };
1312
3679
  } catch {
@@ -1345,8 +3712,657 @@ async function fundHoldings(ticker: string, limit?: number) {
1345
3712
  };
1346
3713
  }
1347
3714
 
3715
+ // ── Companies by SIC (peer / competitor lookup) ─────────────────────
3716
+ //
3717
+ // EDGAR exposes an SIC (Standard Industrial Classification) code on every
3718
+ // filer's submissions record, but nothing in this pack (or the sibling `sec`
3719
+ // pack) could go the other direction — SIC -> list of filers. compare_entities
3720
+ // only compares companies already named; there was no way to FIND the peer
3721
+ // set. Fleet #689.
3722
+ //
3723
+ // Upstream: browse-edgar's `output=atom` company-search. Two upstream quirks,
3724
+ // both verified live (curl, 2026-08-30):
3725
+ // 1. Leaving `type` blank (or omitted) makes the endpoint hang/stall rather
3726
+ // than erroring — confirmed by a request that never returned inside 120s.
3727
+ // Always pass a non-empty `type`; default it to "10-K" (active reporters).
3728
+ // 2. The atom feed's <entry title> and <company-info name> are both the
3729
+ // literal string "ARRAY(0x...)" — a long-standing bug on SEC's side where
3730
+ // the feed serializes a PHP/Perl array ref instead of the company name.
3731
+ // Only <cik> is usable from the feed itself, so names/tickers are joined
3732
+ // in afterward from the company_tickers.json bulk file (already used by
3733
+ // sponsor_to_filer) — which only covers filers with a listed ticker.
3734
+ // Filers without one still appear, with ticker/company_name left null
3735
+ // rather than silently dropped (SEC registrants that aren't equity-listed
3736
+ // are still real peers for some questions, e.g. "who else files in SIC X").
3737
+
3738
+ interface SicPeer { cik: string; ticker: string | null; company_name: string | null }
3739
+
3740
+ async function resolveEntitySic(tickerOrCik: string): Promise<{ cik: string; name: string | null; sic: string | null; sic_description: string | null }> {
3741
+ const cik = await resolveCik(tickerOrCik);
3742
+ const paddedCik = padCik(cik);
3743
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3744
+ if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
3745
+ const data = (await res.json()) as { name?: string; sic?: string; sicDescription?: string };
3746
+ return {
3747
+ cik: String(Number(cik)),
3748
+ name: data.name ?? null,
3749
+ sic: data.sic && data.sic.trim() ? data.sic.trim() : null,
3750
+ sic_description: data.sicDescription ?? null,
3751
+ };
3752
+ }
3753
+
3754
+ async function companiesBySic(
3755
+ sicArg: string | undefined,
3756
+ tickerOrCik: string | undefined,
3757
+ formTypeArg: string | undefined,
3758
+ excludeSelfArg: boolean | undefined,
3759
+ limitArg: number | undefined,
3760
+ ): Promise<unknown> {
3761
+ let sic = sicArg?.trim();
3762
+ let sicDescription: string | null = null;
3763
+ let selfCik: string | null = null;
3764
+ let selfName: string | null = null;
3765
+
3766
+ if (!sic) {
3767
+ if (!tickerOrCik || !tickerOrCik.trim()) {
3768
+ return {
3769
+ error: 'An argument is required: pass either `sic` (a SIC code, e.g. "3571") or a company as `ticker_or_cik` — aliases `cik` / `ticker` — whose own SIC should be resolved first.',
3770
+ retry_hint: 'edgar_companies_by_sic({ticker_or_cik: "AAPL"}) or edgar_companies_by_sic({sic: "3571"}).',
3771
+ };
3772
+ }
3773
+ const resolved = await resolveEntitySic(tickerOrCik);
3774
+ if (!resolved.sic) {
3775
+ return {
3776
+ status: 'no_sic',
3777
+ cik: resolved.cik,
3778
+ company_name: resolved.name,
3779
+ message: `SEC EDGAR has no SIC code on file for CIK ${resolved.cik} (${resolved.name ?? tickerOrCik}) — cannot find peers by industry code. Try edgar_company_filings for this filer's own history instead.`,
3780
+ };
3781
+ }
3782
+ sic = resolved.sic;
3783
+ sicDescription = resolved.sic_description;
3784
+ selfCik = resolved.cik;
3785
+ selfName = resolved.name;
3786
+ }
3787
+
3788
+ if (!/^\d{1,4}$/.test(sic)) {
3789
+ return {
3790
+ error: `Invalid SIC code "${sic}" — SEC SIC codes are 1-4 digits (e.g. "3571", "2836", "6021").`,
3791
+ retry_hint: 'Pass a numeric SIC code, or ticker_or_cik to resolve one from a company.',
3792
+ };
3793
+ }
3794
+
3795
+ const formType = (formTypeArg?.trim() || '10-K').toUpperCase();
3796
+ const excludeSelf = excludeSelfArg !== false; // default true
3797
+ const limit = Math.min(100, Math.max(1, Math.floor(Number(limitArg) || 25)));
3798
+
3799
+ // count=100 is SEC's max per page for this endpoint; no pagination beyond
3800
+ // that yet — an SIC bucket with >100 active 10-K filers returns only the
3801
+ // first 100 (flagged via `possibly_more`, not silently).
3802
+ const url = `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&SIC=${encodeURIComponent(sic)}&type=${encodeURIComponent(formType)}&dateb=&owner=include&count=100&output=atom`;
3803
+ const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3804
+ if (!res.ok) throw await httpError(res, 'SEC EDGAR company-search error');
3805
+ const xml = await res.text();
3806
+ const ciks = [...new Set([...xml.matchAll(/<cik>(\d+)<\/cik>/gi)].map((m) => String(Number(m[1]))))];
3807
+
3808
+ if (ciks.length === 0) {
3809
+ return {
3810
+ sic,
3811
+ sic_description: sicDescription,
3812
+ form_type: formType,
3813
+ total_found: 0,
3814
+ peers: [],
3815
+ message: `No SEC filers found under SIC ${sic} with form type ${formType}. The code may be unused/retired, or try form_type:"" — though note the upstream search can stall with no type filter; retry rather than omitting it.`,
3816
+ };
3817
+ }
3818
+
3819
+ const tickerRows = await fetchCompanyTickers().catch(() => [] as SecTickerRow[]);
3820
+ const byTickerCik = new Map<string, SecTickerRow>();
3821
+ for (const r of tickerRows) byTickerCik.set(String(r.cik_str), r);
3822
+
3823
+ // Best-effort sic_description when we didn't already have it (direct `sic`
3824
+ // input, not resolved via a company) — pull it off the first listed peer's
3825
+ // submissions record rather than leaving it null.
3826
+ if (!sicDescription) {
3827
+ const firstListed = ciks.find((c) => byTickerCik.has(c)) ?? ciks[0];
3828
+ const one = await resolveEntitySic(firstListed).catch(() => null);
3829
+ if (one?.sic === sic) sicDescription = one.sic_description;
3830
+ }
3831
+
3832
+ let peerCiks = ciks;
3833
+ if (excludeSelf && selfCik) peerCiks = peerCiks.filter((c) => c !== selfCik);
3834
+
3835
+ // browse-edgar's own ordering mixes long-defunct/delisted filers in with
3836
+ // current ones (SIC covers everyone who EVER filed the form, not just
3837
+ // active reporters) — verified live on SIC 3571: 100 filers returned, only
3838
+ // 5 (AAPL, DELL, OMCL, OSS, SCKT) still carry a listed ticker. Sorting
3839
+ // ticker-matched filers first means `limit` actually returns the
3840
+ // currently-tradable peers a "who competes with X" question wants, instead
3841
+ // of truncating to whichever 25 happened to sort first upstream (mostly
3842
+ // nulls). Unlisted registrants are still real filers, just pushed after.
3843
+ const sortedCiks = [...peerCiks].sort((a, b) => Number(byTickerCik.has(b)) - Number(byTickerCik.has(a)));
3844
+
3845
+ const peers: SicPeer[] = sortedCiks.slice(0, limit).map((c) => {
3846
+ const t = byTickerCik.get(c);
3847
+ return { cik: c, ticker: t?.ticker ?? null, company_name: t?.title ?? null };
3848
+ });
3849
+
3850
+ return {
3851
+ sic,
3852
+ sic_description: sicDescription,
3853
+ ...(selfCik ? { resolved_from: { ticker_or_cik: tickerOrCik, cik: selfCik, company_name: selfName } } : {}),
3854
+ form_type: formType,
3855
+ exclude_self: excludeSelf,
3856
+ total_found: peerCiks.length,
3857
+ returned: peers.length,
3858
+ possibly_more: ciks.length >= 100,
3859
+ source: 'SEC EDGAR company search (browse-edgar), joined with company_tickers.json for name/ticker',
3860
+ note: 'SIC is a broad industry bucket assigned once at registration, not a live competitor analysis — some peers may be inactive or only loosely comparable. Filers with no listed ticker still appear (ticker/company_name: null) — they are real SEC registrants, just not equity-listed.',
3861
+ peers,
3862
+ };
3863
+ }
3864
+
3865
+ // ── Filer subsidiaries (Exhibit 21) ─────────────────────────────────
3866
+ // The reverse of sponsor_to_filer: given a PARENT filer, list its operating
3867
+ // subsidiaries as filed in Exhibit 21 (Item 601(b)(21)) of its most recent
3868
+ // 10-K. Fleet #690 — ZT was rebuilding this per-company via a chain of ~10
3869
+ // trial-registry-candidate + sponsor_to_filer confirmation calls; this
3870
+ // replaces that chain with a single authoritative list plus provenance
3871
+ // (which filing, which exhibit, straight from SEC).
3872
+
3873
+ interface Subsidiary { name: string; jurisdiction: string | null }
3874
+
3875
+ const EX21_TYPE_RE = /^EX-?21(\.\d+)?$/i;
3876
+
3877
+ // Given a filing's -index.html (the human filing-summary page, which —
3878
+ // unlike index.json — carries a "Type" column such as "EX-21"), find the
3879
+ // Exhibit 21 document's URL. index.json's `type` field is a generic
3880
+ // MIME-icon class (e.g. "text.gif" for every .htm file in the filing, exhibit
3881
+ // 21 included), not the SEC exhibit type, so it can't be used to identify
3882
+ // EX-21 among a filing's other .htm exhibits — verified live on Merck's
3883
+ // 2026-02-24 10-K (accession 0000310158-26-000063).
3884
+ function findExhibit21Url(indexHtml: string): string | null {
3885
+ for (const tr of xmlBlocks(indexHtml, 'tr')) {
3886
+ const href = /<a[^>]+href="([^"]+\.(?:htm|html|txt))"/i.exec(tr)?.[1];
3887
+ if (!href) continue;
3888
+ const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => (htmlToText(m[1]) ?? '').trim());
3889
+ if (cells.some((c) => EX21_TYPE_RE.test(c))) {
3890
+ return href.startsWith('http') ? href : `https://www.sec.gov${href}`;
3891
+ }
3892
+ }
3893
+ return null;
3894
+ }
3895
+
3896
+ // Parse the EX-21 document into structured rows. SEC's typical layout is a
3897
+ // 2-column HTML table (Name | Country or State of Incorporation) — verified
3898
+ // live on Merck. Not every filer's EX-21 uses a table (smaller filers
3899
+ // sometimes file a plain paragraph list), so this returns [] rather than
3900
+ // throwing when no table rows parse; the caller falls back to the raw text.
3901
+ function parseSubsidiaryTable(html: string): Subsidiary[] {
3902
+ const out: Subsidiary[] = [];
3903
+ for (const tr of xmlBlocks(html, 'tr')) {
3904
+ const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
3905
+ .map((m) => (decodeEntities(htmlToText(m[1]))?.trim()) ?? '')
3906
+ .filter((c) => c.length > 0);
3907
+ if (cells.length < 2) continue;
3908
+ const [name, jurisdiction] = cells;
3909
+ if (/^name$/i.test(name) || /^(country|state|jurisdiction)/i.test(jurisdiction)) continue; // header row
3910
+ if (name.length > 200) continue; // guard against a non-table paragraph blob matching by accident
3911
+ out.push({ name, jurisdiction: jurisdiction || null });
3912
+ }
3913
+ return out;
3914
+ }
3915
+
3916
+ async function filerToSponsors(
3917
+ tickerOrCik: string | undefined,
3918
+ nameFilterArg: string | undefined,
3919
+ limitArg: number | undefined,
3920
+ ): Promise<unknown> {
3921
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
3922
+ return {
3923
+ error: 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — all three take a ticker ("MRK") or a CIK ("310158").',
3924
+ retry_hint: 'filer_to_sponsors({ticker_or_cik: "MRK"}) or pass a CIK, e.g. "310158".',
3925
+ };
3926
+ }
3927
+ const cik = await resolveCik(tickerOrCik);
3928
+ const paddedCik = padCik(cik);
3929
+ const limit = Math.min(500, Math.max(1, Math.floor(Number(limitArg) || 200)));
3930
+ const nameFilter = nameFilterArg?.trim().toLowerCase();
3931
+
3932
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3933
+ if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
3934
+ const sub = (await subRes.json()) as {
3935
+ name?: string;
3936
+ filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } };
3937
+ };
3938
+ const r = sub.filings?.recent;
3939
+ const forms = r?.form ?? [];
3940
+ const accs = r?.accessionNumber ?? [];
3941
+ const dates = r?.filingDate ?? [];
3942
+ const cikNoZeros = String(Number(cik));
3943
+
3944
+ // Walk the most recent annual filings looking for one that carries an
3945
+ // Exhibit 21 — almost always the latest 10-K, but Item 601(b)(21) only
3946
+ // requires listing SIGNIFICANT subsidiaries, so a small filer's most recent
3947
+ // 10-K can genuinely omit it even when an older one had it.
3948
+ const candidates: { idx: number; form: string }[] = [];
3949
+ for (let i = 0; i < forms.length && candidates.length < 5; i++) {
3950
+ if (forms[i] === '10-K' || forms[i] === '10-K/A') candidates.push({ idx: i, form: forms[i] });
3951
+ }
3952
+ if (candidates.length === 0) {
3953
+ return {
3954
+ cik: cikNoZeros,
3955
+ company_name: sub.name ?? null,
3956
+ status: 'no_10k',
3957
+ message: `No 10-K on file for CIK ${cikNoZeros} (${sub.name ?? tickerOrCik}) in the recent filings index — Exhibit 21 is filed as part of the annual report. Foreign private issuers file 20-F instead, which is not yet covered by this tool.`,
3958
+ subsidiaries: [],
3959
+ };
3960
+ }
3961
+
3962
+ for (const c of candidates) {
3963
+ const accession = accs[c.idx];
3964
+ const accessionPath = accession.replace(/-/g, '');
3965
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
3966
+ const idxRes = await pwFetch(`${folder}/${accession}-index.html`, { headers: SEC_HEADERS });
3967
+ if (!idxRes.ok) continue;
3968
+ const idxHtml = await idxRes.text();
3969
+ const exUrl = findExhibit21Url(idxHtml);
3970
+ if (!exUrl) continue;
3971
+
3972
+ const exRes = await pwFetch(exUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3973
+ if (!exRes.ok) continue;
3974
+ const exHtml = await exRes.text();
3975
+ const parsed = parseSubsidiaryTable(exHtml);
3976
+
3977
+ const provenance = {
3978
+ source: 'SEC EDGAR Exhibit 21 (Item 601(b)(21))',
3979
+ cik: cikNoZeros,
3980
+ company_name: sub.name ?? null,
3981
+ form: c.form,
3982
+ accession,
3983
+ filing_date: dates[c.idx] ?? null,
3984
+ exhibit_url: exUrl,
3985
+ };
3986
+
3987
+ if (parsed.length === 0) {
3988
+ // Exhibit exists but didn't parse as a table — return the raw text so
3989
+ // the caller isn't left with nothing, flagged as unstructured rather
3990
+ // than silently empty.
3991
+ const raw = htmlToText(exHtml);
3992
+ return {
3993
+ ...provenance,
3994
+ status: 'unstructured',
3995
+ message: 'Exhibit 21 was found but is not in the standard 2-column table format this parser expects — returning raw text instead of structured rows.',
3996
+ raw_text: raw.length > 20000 ? raw.slice(0, 20000) + '\n…[truncated]' : raw,
3997
+ subsidiaries: [],
3998
+ };
3999
+ }
4000
+
4001
+ const filtered = nameFilter ? parsed.filter((s) => s.name.toLowerCase().includes(nameFilter)) : parsed;
4002
+ return {
4003
+ ...provenance,
4004
+ status: 'ok',
4005
+ total_subsidiaries: parsed.length,
4006
+ returned: Math.min(filtered.length, limit),
4007
+ ...(nameFilter ? { name_filter: nameFilter, name_filter_matches: filtered.length } : {}),
4008
+ subsidiaries: filtered.slice(0, limit),
4009
+ };
4010
+ }
4011
+
4012
+ return {
4013
+ cik: cikNoZeros,
4014
+ company_name: sub.name ?? null,
4015
+ status: 'no_exhibit_21',
4016
+ message: `Checked the ${candidates.length} most recent 10-K/10-K/A filing(s) for CIK ${cikNoZeros} (${sub.name ?? tickerOrCik}) and none carried an Exhibit 21 — the filer may have no significant subsidiaries to disclose (common for smaller/single-entity companies), or files under a different form (foreign private issuers use 20-F, not covered here).`,
4017
+ checked_filings: candidates.map((c) => ({ form: c.form, accession: accs[c.idx], filing_date: dates[c.idx] ?? null })),
4018
+ subsidiaries: [],
4019
+ };
4020
+ }
4021
+
4022
+ // ── Product-level / disaggregated revenue (10-K segment notes) ─────
4023
+ // Fleet #691. XBRL companyfacts/companyconcept expose only UNDIMENSIONED
4024
+ // facts (see the LongTermDebt/Ford note on companyConcept above) — product-
4025
+ // level revenue is almost always tagged WITH a dimension (a custom segment
4026
+ // axis with a "Keytruda [Member]", or srt:ProductOrServiceAxis), so it is
4027
+ // invisible to those APIs no matter which tag is requested. It IS visible in
4028
+ // SEC's own "Financial Report" R*.htm viewer pages, which render every
4029
+ // dimensional breakdown as a standardized Arelle-generated HTML table — the
4030
+ // SAME table markup across virtually every modern XBRL filer, because SEC's
4031
+ // own renderer produces it, not the filer. Verified live on Merck's FY2025
4032
+ // 10-K (accession 0000310158-26-000063): FilingSummary.xml names report
4033
+ // "9955598 - Disclosure - Segment Reporting - Schedule of Sales of Company's
4034
+ // Products (Details)" -> R119.htm -> a "Sales" row under the
4035
+ // "Operating Segments | Pharmaceutical | Keytruda" dimension = $31,641M.
4036
+
4037
+ interface DisaggRow { dimension: string | null; concept: string; values: { period: string; value: string }[] }
4038
+
4039
+ // Ordered by specificity — the first PATTERN that matches ANY report wins,
4040
+ // not the first report matching ANY pattern, so a precise "Sales of
4041
+ // Products" report outranks a looser "revenue by segment" one when a filer
4042
+ // has both. Geographic-only breakdowns are excluded explicitly: they use the
4043
+ // same report-naming conventions but answer a different question.
4044
+ // Report titles in FilingSummary.xml are structured `<num> - Disclosure - <Note>
4045
+ // - <Subject> (Details)`. Matching on the SUBJECT (the text after the last
4046
+ // " - ") rather than the whole string is what makes the geographic exclusion
4047
+ // safe: ABBV files its product table as "Segment and Geographic Area
4048
+ // Information - Disaggregation of Revenue (Details)", so a blanket
4049
+ // /geographic/ skip on the full title threw away the very report we wanted,
4050
+ // while PFE's genuinely geo-only "Revenues by Geographic Area (Detail)" must
4051
+ // still be skipped. Both are decided correctly on the subject.
4052
+ function reportSubject(longName: string): string {
4053
+ const parts = longName.split(' - ');
4054
+ return (parts.length > 1 ? parts[parts.length - 1] : longName).trim();
4055
+ }
4056
+
4057
+ // Tier 1: the filer names a product/turnover breakdown outright.
4058
+ const REPORT_NAME_PATTERNS: RegExp[] = [
4059
+ /sales of .*compan.*products/i,
4060
+ /revenues? by [\w\s,&]{0,40}product/i,
4061
+ /net sales by product/i,
4062
+ /product (net )?(sales|revenues?)/i,
4063
+ /(net |gross )?(sales|revenues?)[\w\s,&]{0,30}by product/i,
4064
+ /turnover by product/i,
4065
+ /major product/i,
4066
+ /disaggregat(ed|ion) of (net )?revenues?/i,
4067
+ /revenue.*by segment and product/i,
4068
+ /\btop \d+\b/i, // Novartis titles its per-product table "… - Top 20 (Details 5)"
4069
+ ];
4070
+
4071
+ // Tier 2: no product wording, but the report is still the revenue
4072
+ // disaggregation note — its XBRL dimensions carry the products (verified:
4073
+ // Novo Nordisk's "Business segments and geographical areas" rows are Ozempic /
4074
+ // Rybelsus; Sanofi's are Dupixent / Aubagio; Incyte's bare "Revenues
4075
+ // (Details)" is JAKAFI / OPZELURA). Only tried when tier 1 finds nothing.
4076
+ const REPORT_NAME_PATTERNS_FALLBACK: RegExp[] = [
4077
+ /\bnet sales\b/i,
4078
+ /sales by segment/i,
4079
+ /\brevenues?\s*\(detail/i,
4080
+ ];
4081
+
4082
+ // Subjects that are about the mechanics of revenue (reserves, rebates,
4083
+ // concentration) rather than the revenue breakdown itself. Without this,
4084
+ // SRPT's "Reserves for Product Revenues" outranks its "Summary of Product
4085
+ // Revenues", and UTHR's "Revenue Recognition" would match tier 2.
4086
+ const REPORT_NOISE = /footnote|narrative|parenthetical|additional information|concentration|reconciliation|recognition|\breserves?\b|allowance|rebate|discount|deduction|milestone|receivable|contract balance|performance obligation|remaining|returns/i;
4087
+
4088
+ const REPORT_GEO_ONLY = /\bgeograph/i;
4089
+ const REPORT_PRODUCT_SIGNAL = /product|segment|top \d+/i;
4090
+
4091
+ async function findRevenueReport(folder: string): Promise<{ url: string; report_name: string } | null> {
4092
+ const res = await pwFetch(`${folder}/FilingSummary.xml`, { headers: SEC_HEADERS });
4093
+ if (!res.ok) return null;
4094
+ const xml = await res.text();
4095
+ // FilingSummary.xml's <Report> elements carry an `instance="..."`
4096
+ // attribute (e.g. <Report instance="mrk-20251231.htm">) — xmlBlocks
4097
+ // tolerates that.
4098
+ const reports = xmlBlocks(xml, 'Report');
4099
+
4100
+ const candidates = reports
4101
+ .map((rep) => {
4102
+ const longName = xmlVal(rep, 'LongName') ?? '';
4103
+ const html = xmlVal(rep, 'HtmlFileName');
4104
+ return { longName, subject: reportSubject(longName), html };
4105
+ })
4106
+ .filter((c) => {
4107
+ if (!c.html || !c.longName) return false;
4108
+ if (REPORT_NOISE.test(c.subject)) return false;
4109
+ // Geographic-only reports are the wrong axis — but a subject that also
4110
+ // names products or segments is the right one wearing a geo label.
4111
+ if (REPORT_GEO_ONLY.test(c.subject) && !REPORT_PRODUCT_SIGNAL.test(c.subject)) return false;
4112
+ return true;
4113
+ });
4114
+
4115
+ // A filer publishes both the section-level report ("NET PRODUCT REVENUES")
4116
+ // and the rendered table ("… - Schedule of Net Product Revenues (Detail)").
4117
+ // Only the latter parses into rows, so it is tried first — ALNY and SRPT
4118
+ // both returned status "unparsed" because the section-level one won.
4119
+ const isDetails = (n: string) => /\(detail(s)?(\s+\d+)?\)\s*$/i.test(n.trim());
4120
+ for (const tier of [REPORT_NAME_PATTERNS, REPORT_NAME_PATTERNS_FALLBACK]) {
4121
+ for (const detailsOnly of [true, false]) {
4122
+ for (const pat of tier) {
4123
+ for (const c of candidates) {
4124
+ if (detailsOnly !== isDetails(c.longName)) continue;
4125
+ if (!pat.test(c.longName)) continue;
4126
+ return { url: `${folder}/${c.html}`, report_name: decodeEntities(c.longName) ?? c.longName };
4127
+ }
4128
+ }
4129
+ }
4130
+ }
4131
+ return null;
4132
+ }
4133
+
4134
+ // Parse an SEC Financial-Report R*.htm page into dimensional rows. Standard
4135
+ // Arelle-rendered structure (SEC's own viewer, consistent across filers):
4136
+ // - Header <tr>s carry <th class="th"><div>period label</div></th> cells
4137
+ // (e.g. "Dec. 31, 2025").
4138
+ // - A "context" row sets the dimension breadcrumb (e.g. "Operating
4139
+ // Segments | Pharmaceutical | Keytruda") for the data rows that follow
4140
+ // it, until the next context row — identified as a row whose concept
4141
+ // link references an XBRL Axis (`Axis=` in the href) and which carries
4142
+ // no numeric values itself.
4143
+ // - "[Line Items]" / "[Table]" / "[Abstract]" / "[Domain]" label rows are
4144
+ // structural noise (bolded headers with blank values) — skipped, never
4145
+ // treated as a context or a data row.
4146
+ // - A data row carries one value cell (`class="num"`/`"nump"`) per period
4147
+ // column alongside its own concept label (e.g. "Sales").
4148
+ // Best-effort: filers occasionally deviate from this shape (older filings,
4149
+ // non-Arelle renders) — an empty `rows` array means "didn't parse", not
4150
+ // "reports zero", and is flagged as such by the caller.
4151
+ function parseDisaggregatedRevenue(html: string): { periods: string[]; rows: DisaggRow[]; units_note: string | null } {
4152
+ const titleMatch = /<th[^>]*>\s*<div[^>]*>\s*<strong>([\s\S]*?)<\/strong>/i.exec(html);
4153
+ const titleText = titleMatch ? htmlToText(titleMatch[1]) : '';
4154
+ const unitsMatch = /\$\s*in\s*(Thousands|Millions|Billions)/i.exec(titleText);
4155
+ const unitsNote = unitsMatch ? `$ in ${unitsMatch[1]}` : null;
4156
+
4157
+ // Period columns come from two header rows: an optional duration row
4158
+ // (<th class="th" colspan="3">12 Months Ended</th>, carrying no <div>) and
4159
+ // the date row. ABBV writes its date row as
4160
+ // <th class="th">\n<div>Dec. 31, 2025 </div>\n<div>USD ($)</div>\n</th>
4161
+ // — a newline after the tag and several <div>s — which a single-line
4162
+ // "<th class=\"th\"><div>…</div></th>" pattern misses completely. That left
4163
+ // `periods` empty, so every row's `values` filtered down to nothing and the
4164
+ // tool returned status "ok" with 76 rows and not one dollar figure.
4165
+ const durations: string[] = [];
4166
+ const dateLabels: string[] = [];
4167
+ for (const m of html.matchAll(/<th class="th"([^>]*)>([\s\S]*?)<\/th>/gi)) {
4168
+ const divs = [...(m[2] ?? '').matchAll(/<div[^>]*>([\s\S]*?)<\/div>/gi)].map((d) => htmlToText(d[1]).trim());
4169
+ if (divs.length === 0) {
4170
+ const span = Math.max(1, Number(/colspan="(\d+)"/i.exec(m[1] ?? '')?.[1] ?? 1));
4171
+ const label = htmlToText(m[2] ?? '').trim();
4172
+ for (let i = 0; i < span; i++) durations.push(label);
4173
+ continue;
4174
+ }
4175
+ if (divs[0]) dateLabels.push(divs[0]); // later <div>s are the unit ("USD ($)") and axis qualifiers
4176
+ }
4177
+ // Two columns can carry the SAME date over different durations — ABBV reports
4178
+ // both "3 Months Ended Dec. 31, 2025" and "12 Months Ended Dec. 31, 2025", and
4179
+ // reading a quarter as the annual figure is the expensive mistake here. Qualify
4180
+ // only when the bare date is ambiguous, so single-duration filers keep the
4181
+ // period labels they have always returned.
4182
+ const ambiguous = new Set(dateLabels.filter((d, i) => dateLabels.indexOf(d) !== i));
4183
+ const periods = dateLabels.map((d, i) => (ambiguous.has(d) && durations[i] ? `${durations[i]} ${d}` : d));
4184
+
4185
+ const rows: DisaggRow[] = [];
4186
+ let context: string | null = null;
4187
+ for (const tr of xmlBlocks(html, 'tr')) {
4188
+ const labelMatch = /<td[^>]*class="pl[^"]*"[^>]*>([\s\S]*?)<\/td>/i.exec(tr);
4189
+ if (!labelMatch) continue; // header/period row, no row label
4190
+ const label = htmlToText(labelMatch[1]).trim();
4191
+ if (!label) continue;
4192
+ if (/\[(Line Items|Table|Abstract|Domain|Axis)\]/i.test(label)) continue; // structural noise
4193
+
4194
+ // Include the blank <td class="text"> cells: they hold a column's POSITION
4195
+ // when a row has no figure for that period. Collecting only the numeric
4196
+ // cells and zipping them against `periods` shifts every value left past a
4197
+ // gap, which silently files an annual figure under the quarterly column.
4198
+ const valueCells = [...tr.matchAll(/<td class="(num[a-z]*|text)"[^>]*>([\s\S]*?)<\/td>/gi)]
4199
+ .map((m) => (m[1].toLowerCase() === 'text' ? '' : htmlToText(m[2]).trim()));
4200
+ const hasValues = valueCells.some((v) => v.length > 0);
4201
+ const referencesAxis = /Axis=/.test(tr);
4202
+
4203
+ if (referencesAxis && !hasValues) {
4204
+ context = label;
4205
+ continue;
4206
+ }
4207
+ if (!hasValues) continue; // blank-value row that isn't a recognized axis header — skip
4208
+
4209
+ rows.push({
4210
+ dimension: context,
4211
+ concept: label,
4212
+ values: periods.map((p, i) => ({ period: p, value: valueCells[i] ?? '' })).filter((v) => v.value !== ''),
4213
+ });
4214
+ }
4215
+ return { periods, rows, units_note: unitsNote };
4216
+ }
4217
+
4218
+ async function productRevenue(
4219
+ tickerOrCik: string | undefined,
4220
+ productFilterArg: string | undefined,
4221
+ formTypeArg: string | undefined,
4222
+ accessionArg: string | undefined,
4223
+ limitArg: number | undefined,
4224
+ ): Promise<unknown> {
4225
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
4226
+ return {
4227
+ error: 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — all three take a ticker ("MRK") or a CIK ("310158").',
4228
+ retry_hint: 'edgar_product_revenue({ticker_or_cik: "MRK", product_filter: "Keytruda"}).',
4229
+ };
4230
+ }
4231
+ const cik = await resolveCik(tickerOrCik);
4232
+ const paddedCik = padCik(cik);
4233
+ const cikNoZeros = String(Number(cik));
4234
+ // Foreign private issuers file 20-F and Canadian MJDS filers 40-F instead of
4235
+ // a 10-K, and their rendered R*.htm reports exist just the same. With no
4236
+ // form_type from the caller, walk 10-K -> 20-F -> 40-F rather than reporting
4237
+ // "no filing" for every non-US-domestic pharma (NVS, AZN, GSK, SNY, NVO, TAK).
4238
+ const explicitForm = formTypeArg?.trim() ? formTypeArg.trim().toUpperCase() : null;
4239
+ const FORM_FALLBACK_ORDER = ['10-K', '20-F', '40-F'];
4240
+ const formCandidates = explicitForm ? [explicitForm] : FORM_FALLBACK_ORDER;
4241
+ const productFilter = productFilterArg?.trim().toLowerCase();
4242
+ const limit = Math.min(200, Math.max(1, Math.floor(Number(limitArg) || 100)));
4243
+
4244
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
4245
+ if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
4246
+ const sub = (await subRes.json()) as {
4247
+ name?: string;
4248
+ filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } };
4249
+ };
4250
+ const r = sub.filings?.recent;
4251
+ const forms = r?.form ?? [];
4252
+ const accs = r?.accessionNumber ?? [];
4253
+ const dates = r?.filingDate ?? [];
4254
+
4255
+ let acc: string | undefined;
4256
+ let filingDate: string | null = null;
4257
+ let formType: string;
4258
+ if (accessionArg) {
4259
+ const norm = normalizeAccession(accessionArg);
4260
+ if (!norm) throw new Error(`Invalid accession "${accessionArg}" — expected 18 digits shaped 10-2-6.`);
4261
+ acc = norm.dashed;
4262
+ const i = accs.indexOf(acc);
4263
+ filingDate = i >= 0 ? dates[i] ?? null : null;
4264
+ formType = (i >= 0 ? forms[i] : null) ?? explicitForm ?? 'unknown';
4265
+ } else {
4266
+ let idx = -1;
4267
+ let picked: string | null = null;
4268
+ for (const want of formCandidates) {
4269
+ idx = forms.findIndex((f) => f.toUpperCase() === want);
4270
+ if (idx >= 0) { picked = want; break; }
4271
+ }
4272
+ if (idx < 0 || !picked) {
4273
+ return {
4274
+ cik: cikNoZeros,
4275
+ company_name: sub.name ?? null,
4276
+ status: 'no_filing',
4277
+ resolved_form: null,
4278
+ forms_checked: formCandidates,
4279
+ message: `No ${formCandidates.join(' / ')} on file for CIK ${cikNoZeros} (${sub.name ?? tickerOrCik}) in the recent filings index.`,
4280
+ rows: [],
4281
+ };
4282
+ }
4283
+ formType = picked;
4284
+ acc = accs[idx];
4285
+ filingDate = dates[idx] ?? null;
4286
+ }
4287
+
4288
+ const accessionPath = acc!.replace(/-/g, '');
4289
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
4290
+ const report = await findRevenueReport(folder);
4291
+ if (!report) {
4292
+ return {
4293
+ cik: cikNoZeros,
4294
+ company_name: sub.name ?? null,
4295
+ form: formType,
4296
+ resolved_form: formType,
4297
+ accession: acc,
4298
+ filing_date: filingDate,
4299
+ status: 'not_found',
4300
+ message: `No product/segment revenue disaggregation report was found in this filing's FilingSummary.xml (checked report titles for "sales of products", "disaggregation of revenue", "revenue by product", etc.). Either this filer doesn't disaggregate revenue by product in XBRL, or it uses a title this heuristic doesn't recognize — try edgar_filing_text({ticker_or_cik:"${tickerOrCik}", form_type:"${formType}"}) and read the segment/revenue footnote directly.`,
4301
+ rows: [],
4302
+ };
4303
+ }
4304
+
4305
+ const repRes = await pwFetch(report.url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
4306
+ if (!repRes.ok) throw await httpError(repRes, 'SEC EDGAR financial report error');
4307
+ const repHtml = await repRes.text();
4308
+ const parsed = parseDisaggregatedRevenue(repHtml);
4309
+
4310
+ const citation = {
4311
+ source: 'SEC EDGAR XBRL Financial Report (Arelle-rendered R.htm viewer)',
4312
+ cik: cikNoZeros,
4313
+ company_name: sub.name ?? null,
4314
+ form: formType,
4315
+ resolved_form: formType,
4316
+ accession: acc,
4317
+ filing_date: filingDate,
4318
+ report_name: report.report_name,
4319
+ report_url: report.url,
4320
+ };
4321
+
4322
+ if (parsed.rows.length === 0) {
4323
+ return {
4324
+ ...citation,
4325
+ status: 'unparsed',
4326
+ message: 'Found a matching revenue-disaggregation report but could not parse it into rows with this parser (the filer\'s table structure deviates from the standard Arelle layout). Read report_url directly, or use edgar_filing_text for the prose segment note.',
4327
+ periods: parsed.periods,
4328
+ rows: [],
4329
+ };
4330
+ }
4331
+
4332
+ const filtered = productFilter
4333
+ ? parsed.rows.filter((row) => (row.dimension ?? row.concept).toLowerCase().includes(productFilter))
4334
+ : parsed.rows;
4335
+
4336
+ return {
4337
+ ...citation,
4338
+ status: 'ok',
4339
+ units_note: parsed.units_note,
4340
+ periods: parsed.periods,
4341
+ total_rows: parsed.rows.length,
4342
+ returned: Math.min(filtered.length, limit),
4343
+ ...(productFilter ? { product_filter: productFilter, product_filter_matches: filtered.length } : {}),
4344
+ note: 'Rows come from the filer\'s own disaggregated-revenue XBRL dimension (e.g. product/segment breakdown), rendered by SEC\'s standard viewer — not free-text extraction. `dimension` is the full breadcrumb (e.g. "Operating Segments | Pharmaceutical | Keytruda"); `concept` is the line-item label (usually "Sales" or "Revenues").',
4345
+ rows: filtered.slice(0, limit),
4346
+ };
4347
+ }
4348
+
1348
4349
  // ── callTool router ─────────────────────────────────────────────────
1349
4350
 
4351
+ /**
4352
+ * The one company argument, under any of the three names this pack uses for it.
4353
+ * `ticker_or_cik`, `cik` and `ticker` all mean "a ticker OR a CIK" here —
4354
+ * resolveCik() accepts either — so which word a caller reached for is not
4355
+ * information, it is noise. Coalesce rather than branch, first non-empty wins.
4356
+ */
4357
+ function companyArg(args: Record<string, unknown>): string {
4358
+ for (const key of ['ticker_or_cik', 'cik', 'ticker'] as const) {
4359
+ const v = args[key];
4360
+ if (typeof v === 'string' && v.trim()) return v;
4361
+ if (typeof v === 'number') return String(v);
4362
+ }
4363
+ return args.ticker_or_cik as string;
4364
+ }
4365
+
1350
4366
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
1351
4367
  switch (name) {
1352
4368
  case 'edgar_search_filings':
@@ -1357,32 +4373,57 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1357
4373
  args.end_date as string | undefined,
1358
4374
  args.limit as number | undefined,
1359
4375
  );
4376
+ // Every tool below takes the same thing — a company — and this pack spells
4377
+ // it three ways across its own tools (`ticker_or_cik`, `cik`, `ticker`), so
4378
+ // a model filling arguments from prose reaches for whichever word it read
4379
+ // last. All three are DECLARED in each inputSchema and coalesced here; an
4380
+ // undeclared argument is dropped today and would be rejected once
4381
+ // REJECT_UNKNOWN_ARGS is enforced (#2013), so declaring is what makes the
4382
+ // alias real. `required` is empty on those schemas for the same reason —
4383
+ // a flat list cannot say "one of these three" (fleet #2058, swept in #2069).
1360
4384
  case 'edgar_company_filings':
1361
4385
  return companyFilings(
1362
- args.ticker_or_cik as string,
4386
+ companyArg(args),
1363
4387
  args.form_type as string | undefined,
1364
4388
  args.limit as number | undefined,
1365
4389
  );
1366
4390
  case 'edgar_company_facts':
1367
- return companyFacts(args.cik as string);
4391
+ return companyFacts(companyArg(args));
4392
+ case 'edgar_company_snapshot':
4393
+ return companySnapshot(
4394
+ companyArg(args),
4395
+ args.form_type as string | undefined,
4396
+ args.filings_limit as number | undefined,
4397
+ );
1368
4398
  case 'edgar_insider_transactions':
1369
4399
  return insiderTransactions(
1370
- args.ticker_or_cik as string,
4400
+ companyArg(args),
1371
4401
  args.limit as number | undefined,
1372
4402
  args.include_derivatives as boolean | undefined,
1373
4403
  );
1374
4404
  case 'edgar_institutional_holdings':
1375
4405
  return institutionalHoldings(
1376
- args.ticker_or_cik as string,
4406
+ companyArg(args),
1377
4407
  args.limit as number | undefined,
1378
4408
  );
1379
4409
  case 'edgar_fund_holdings':
1380
- return fundHoldings(args.ticker as string, args.limit as number | undefined);
4410
+ return fundHoldings((args.ticker ?? args.ticker_or_cik) as string, args.limit as number | undefined);
1381
4411
  case 'edgar_company_concept':
1382
- // Accept `ticker_or_cik` too: the schema/handler use `cik` (which takes a
1383
- // ticker or CIK) but the sibling tools use `ticker_or_cik` and the error
1384
- // message names it — agents naturally pass it and were rejected.
1385
- return companyConcept((args.cik ?? args.ticker_or_cik) as string, args.concept as string);
4412
+ // Accept `ticker` / `ticker_or_cik` / `metric` too. The canonical names
4413
+ // are `cik` (which takes a ticker OR a CIK) and `concept`, but this
4414
+ // pack's own sibling tools spell the company argument three different
4415
+ // ways and the description calls the metric a "metric" — so a caller
4416
+ // filling arguments from prose reaches for whichever word it just read.
4417
+ // Measured: every `partial` outcome recorded for this tool over three
4418
+ // days (12/12) sent `ticker`, and a third of those sent `metric` as
4419
+ // well; all were rejected before the pack ran (fleet #2058).
4420
+ return companyConcept(
4421
+ (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
4422
+ (args.concept ?? args.metric) as string,
4423
+ args.period as string | undefined,
4424
+ args.fiscal_year as string | number | undefined,
4425
+ args.fiscal_period as string | undefined,
4426
+ );
1386
4427
  case 'edgar_filing_documents':
1387
4428
  return filingDocuments(
1388
4429
  args.accession as string | undefined,
@@ -1390,10 +4431,43 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1390
4431
  args.include_primary_text as boolean | undefined,
1391
4432
  args.form_type as string | undefined,
1392
4433
  );
4434
+ case 'edgar_filing_text':
4435
+ return filingText(
4436
+ args.accession as string | undefined,
4437
+ (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
4438
+ args.section as string | undefined,
4439
+ args.max_chars as number | undefined,
4440
+ args.offset as number | undefined,
4441
+ args.form_type as string | undefined,
4442
+ );
1393
4443
  case 'edgar_ticker_to_cik':
1394
- return tickerToCik(args.ticker as string);
4444
+ return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
4445
+ case 'sponsor_to_filer':
4446
+ return sponsorToFiler((args.sponsor ?? args.name ?? args.ticker) as string);
4447
+ case 'filer_to_sponsors':
4448
+ return filerToSponsors(
4449
+ companyArg(args) as string | undefined,
4450
+ args.name_filter as string | undefined,
4451
+ args.limit as number | undefined,
4452
+ );
1395
4453
  case 'edgar_xbrl_frames':
1396
4454
  return xbrlFrames(args);
4455
+ case 'edgar_companies_by_sic':
4456
+ return companiesBySic(
4457
+ args.sic as string | undefined,
4458
+ companyArg(args) as string | undefined,
4459
+ args.form_type as string | undefined,
4460
+ args.exclude_self as boolean | undefined,
4461
+ args.limit as number | undefined,
4462
+ );
4463
+ case 'edgar_product_revenue':
4464
+ return productRevenue(
4465
+ companyArg(args) as string | undefined,
4466
+ args.product_filter as string | undefined,
4467
+ args.form_type as string | undefined,
4468
+ args.accession as string | undefined,
4469
+ args.limit as number | undefined,
4470
+ );
1397
4471
  default:
1398
4472
  throw new Error(`Unknown tool: ${name}`);
1399
4473
  }
@@ -1414,11 +4488,11 @@ async function xbrlFrames(args: Record<string, unknown>) {
1414
4488
  const limit = Math.min(200, Math.max(1, Number(args.limit) || 25));
1415
4489
 
1416
4490
  const url = `${DATA_BASE}/api/xbrl/frames/${taxonomy}/${encodeURIComponent(concept)}/${encodeURIComponent(unit)}/${period}.json`;
1417
- const res = await fetch(url, { headers: SEC_HEADERS });
4491
+ const res = await pwFetch(url, { headers: SEC_HEADERS });
1418
4492
  if (res.status === 404) {
1419
4493
  return { error: 'not_found', concept, period, unit, taxonomy, message: `No SEC frame for ${taxonomy}/${concept}/${unit}/${period}. Check the tag spelling/casing, unit (USD vs shares), and period type (add "I" for balance-sheet/instant items like Assets).` };
1420
4494
  }
1421
- if (!res.ok) throw new Error(`SEC frames error: ${res.status}`);
4495
+ if (!res.ok) throw await httpError(res, 'SEC frames error');
1422
4496
  const data = (await res.json()) as { taxonomy?: string; tag?: string; uom?: string; label?: string; description?: string; data?: FrameDatum[] };
1423
4497
  const rows = (data.data ?? []).slice().sort((a, b) => sortDesc ? (b.val ?? 0) - (a.val ?? 0) : (a.val ?? 0) - (b.val ?? 0));
1424
4498