@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/LICENSE +1 -1
- package/README.md +121 -9
- package/bin/cli.js +17 -0
- package/package.json +15 -3
- package/server.json +1 -1
- package/src/index.ts +3267 -193
- package/src/server.ts +45 -0
- package/tsconfig.json +5 -1
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 `<script>`
|
|
570
|
+
* in a title cannot decode into markup that survives — EMBL-EBI's ChEMBL 500
|
|
571
|
+
* page renders as `500 Internal Server Error < 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(/ /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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
1398
|
+
name: 'sponsor_to_filer',
|
|
272
1399
|
description:
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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: ['
|
|
1410
|
+
required: ['sponsor'],
|
|
281
1411
|
},
|
|
282
1412
|
},
|
|
283
1413
|
{
|
|
284
|
-
name: '
|
|
1414
|
+
name: 'filer_to_sponsors',
|
|
285
1415
|
description:
|
|
286
|
-
'
|
|
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: '
|
|
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
|
-
|
|
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
|
|
378
|
-
if (!res.ok) throw
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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:
|
|
403
|
-
cik
|
|
404
|
-
form_type: s.
|
|
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.
|
|
407
|
-
location: s.
|
|
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
|
-
|
|
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
|
-
'
|
|
432
|
-
'
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2629
|
+
const res = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
|
|
673
2630
|
headers: SEC_HEADERS,
|
|
674
2631
|
});
|
|
675
|
-
if (
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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(
|
|
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
|
|
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
|
|
2895
|
+
for (const [, values] of unitEntries(doc.units)) {
|
|
892
2896
|
for (const e of values) {
|
|
893
|
-
if (!
|
|
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
|
-
|
|
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
|
|
917
|
-
if (factsRes.ok
|
|
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
|
|
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
|
-
|
|
943
|
-
|
|
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
|
|
3060
|
+
for (const [unit, values] of unitEntries(data.units)) {
|
|
965
3061
|
for (const e of values) {
|
|
966
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
|
1093
|
-
if (!res.ok) throw
|
|
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
|
|
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
|
|
1166
|
-
if (!subRes.ok) throw
|
|
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
|
|
1192
|
-
if (!idxRes.ok) throw
|
|
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
|
|
1202
|
-
if (!tableRes.ok) throw
|
|
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(/ /gi, ' ')
|
|
1254
3617
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1255
3618
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/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
|
|
1264
|
-
if (!res.ok) throw
|
|
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(
|
|
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
|
|
1288
|
-
if (!subRes.ok) throw
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
|
|
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
|
|
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
|
|
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
|
|