@pipeworx/mcp-edgar 0.3.1 → 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 +3305 -205
- 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
|
+
]);
|
|
136
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;
|
|
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.',
|
|
1293
|
+
},
|
|
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.',
|
|
264
1389
|
},
|
|
265
|
-
limit: { type: 'number', description: 'Top N holdings by value to return (1-100, default 25)' },
|
|
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,31 +1463,134 @@ 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
|
-
'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
|
|
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: {
|
|
322
1523
|
accession: {
|
|
323
1524
|
type: 'string',
|
|
324
|
-
description: 'SEC accession number of
|
|
1525
|
+
description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead.',
|
|
325
1526
|
},
|
|
326
1527
|
ticker: {
|
|
327
1528
|
type: 'string',
|
|
328
|
-
description: 'The filer\'s ticker (e.g. "AAPL") or company name. Provide this OR cik
|
|
1529
|
+
description: 'The filer\'s ticker (e.g. "AAPL", "NVDA") or company name. Provide this OR cik. Tickers are auto-resolved to CIKs.',
|
|
329
1530
|
},
|
|
330
1531
|
cik: {
|
|
331
1532
|
type: 'string',
|
|
332
1533
|
description: 'The filer\'s CIK number (e.g. "320193"). Provide this OR ticker.',
|
|
333
1534
|
},
|
|
1535
|
+
form_type: {
|
|
1536
|
+
type: 'string',
|
|
1537
|
+
description: 'When accession is omitted, the form type of the latest filing to fetch, e.g. "10-K", "10-Q", "8-K", "DEF 14A". Omit both accession and form_type to get the single most recent filing of any type.',
|
|
1538
|
+
},
|
|
334
1539
|
include_primary_text: {
|
|
335
1540
|
type: 'boolean',
|
|
336
|
-
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.',
|
|
337
1591
|
},
|
|
338
1592
|
},
|
|
339
|
-
required: [
|
|
1593
|
+
required: [],
|
|
340
1594
|
},
|
|
341
1595
|
},
|
|
342
1596
|
];
|
|
@@ -370,21 +1624,25 @@ async function searchFilings(
|
|
|
370
1624
|
if (endDate) params.set('enddt', endDate);
|
|
371
1625
|
}
|
|
372
1626
|
|
|
373
|
-
const res = await
|
|
374
|
-
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');
|
|
375
1629
|
|
|
376
1630
|
const data = (await res.json()) as {
|
|
377
1631
|
hits: {
|
|
378
1632
|
hits: {
|
|
379
1633
|
_source: {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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;
|
|
388
1646
|
};
|
|
389
1647
|
_id: string;
|
|
390
1648
|
}[];
|
|
@@ -392,15 +1650,30 @@ async function searchFilings(
|
|
|
392
1650
|
};
|
|
393
1651
|
};
|
|
394
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.
|
|
395
1658
|
const results = (data.hits?.hits ?? []).map((hit) => {
|
|
396
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;
|
|
397
1666
|
return {
|
|
398
|
-
entity_name:
|
|
399
|
-
cik
|
|
400
|
-
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,
|
|
401
1672
|
filing_date: s.file_date,
|
|
402
|
-
period_of_report: s.
|
|
403
|
-
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,
|
|
404
1677
|
filing_id: hit._id,
|
|
405
1678
|
};
|
|
406
1679
|
});
|
|
@@ -418,16 +1691,319 @@ async function searchFilings(
|
|
|
418
1691
|
// identity. Delegates to the shared resolver so the ticker/name-resolution logic
|
|
419
1692
|
// stays consistent across every SEC-keyed pack (edgar, sec).
|
|
420
1693
|
async function tickerToCik(ticker: string) {
|
|
421
|
-
|
|
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
|
+
};
|
|
422
1991
|
}
|
|
423
1992
|
|
|
424
1993
|
async function resolveCik(tickerOrCik: string): Promise<string> {
|
|
425
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.
|
|
426
2004
|
throw new Error(
|
|
427
|
-
'
|
|
428
|
-
'
|
|
429
|
-
'or edgar_company_filings({ticker_or_cik: "320193"}) by CIK number (Apple\'s CIK). ' +
|
|
430
|
-
'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.'
|
|
431
2007
|
);
|
|
432
2008
|
}
|
|
433
2009
|
if (isNumericCik(tickerOrCik)) return tickerOrCik.trim();
|
|
@@ -435,62 +2011,110 @@ async function resolveCik(tickerOrCik: string): Promise<string> {
|
|
|
435
2011
|
return result.cik;
|
|
436
2012
|
}
|
|
437
2013
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
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
|
+
}
|
|
445
2030
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
};
|
|
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[];
|
|
463
2047
|
};
|
|
464
2048
|
};
|
|
2049
|
+
}
|
|
465
2050
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
}
|
|
474
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[] = [];
|
|
475
2073
|
for (let i = 0; i < recent.accessionNumber.length && filings.length < count; i++) {
|
|
476
2074
|
const form = recent.form[i];
|
|
477
|
-
if (
|
|
2075
|
+
if (!keep(form)) continue;
|
|
478
2076
|
|
|
479
2077
|
const accession = recent.accessionNumber[i];
|
|
480
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;
|
|
481
2086
|
filings.push({
|
|
482
2087
|
accession_number: accession,
|
|
483
2088
|
filing_date: recent.filingDate[i],
|
|
484
2089
|
form,
|
|
2090
|
+
...(items && items.length ? { items } : {}),
|
|
485
2091
|
primary_document: recent.primaryDocument[i],
|
|
486
2092
|
document_url: `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accessionPath}/${recent.primaryDocument[i]}`,
|
|
487
2093
|
});
|
|
488
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);
|
|
489
2105
|
|
|
490
2106
|
return {
|
|
491
2107
|
cik: data.cik,
|
|
492
2108
|
company_name: data.name,
|
|
493
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,
|
|
494
2118
|
sic_description: data.sicDescription,
|
|
495
2119
|
state_of_incorporation: data.stateOfIncorporation,
|
|
496
2120
|
fiscal_year_end: data.fiscalYearEnd,
|
|
@@ -499,6 +2123,128 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
|
|
|
499
2123
|
};
|
|
500
2124
|
}
|
|
501
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
|
+
|
|
502
2248
|
// ── Filing documents (list docs inside one filing by accession) ─────
|
|
503
2249
|
|
|
504
2250
|
// Normalize an accession number (dashed or undashed) to both forms.
|
|
@@ -510,6 +2256,53 @@ function normalizeAccession(raw: string): { dashed: string; nodash: string } | n
|
|
|
510
2256
|
return { dashed, nodash: digits };
|
|
511
2257
|
}
|
|
512
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
|
+
|
|
513
2306
|
// Strip an HTML document to readable plaintext. Workers have no DOMParser;
|
|
514
2307
|
// drop script/style blocks, remove tags, decode entities, collapse whitespace.
|
|
515
2308
|
function htmlToText(html: string): string {
|
|
@@ -525,23 +2318,44 @@ function htmlToText(html: string): string {
|
|
|
525
2318
|
}
|
|
526
2319
|
|
|
527
2320
|
async function filingDocuments(
|
|
528
|
-
accession: string,
|
|
2321
|
+
accession: string | undefined,
|
|
529
2322
|
tickerOrCik: string,
|
|
530
2323
|
includePrimaryText?: boolean,
|
|
2324
|
+
wantFormTypeArg?: string,
|
|
531
2325
|
) {
|
|
532
|
-
|
|
533
|
-
if (!acc) {
|
|
2326
|
+
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
534
2327
|
throw new Error(
|
|
535
|
-
`
|
|
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).',
|
|
536
2329
|
);
|
|
537
2330
|
}
|
|
538
|
-
|
|
2331
|
+
const cik = await resolveCik(tickerOrCik);
|
|
2332
|
+
// Accession is optional: when omitted, resolve the latest filing matching
|
|
2333
|
+
// form_type (or the single most recent filing of any type) so one-shot
|
|
2334
|
+
// questions like "documents in NVIDIA's latest 10-K" work without the caller
|
|
2335
|
+
// first having to look up an accession number.
|
|
2336
|
+
let acc = accession ? normalizeAccession(accession) : null;
|
|
2337
|
+
if (accession && !acc) {
|
|
539
2338
|
throw new Error(
|
|
540
|
-
|
|
541
|
-
acc.dashed + '", ticker: "AAPL"}).',
|
|
2339
|
+
`Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0000320193-25-000079", with or without dashes). Get accession numbers from edgar_company_filings, or omit accession and pass form_type to auto-resolve the latest.`,
|
|
542
2340
|
);
|
|
543
2341
|
}
|
|
544
|
-
|
|
2342
|
+
if (!acc) {
|
|
2343
|
+
const wantForm = wantFormTypeArg?.trim().toUpperCase();
|
|
2344
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
|
|
2345
|
+
if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
|
|
2346
|
+
const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
|
|
2347
|
+
const r = sub.filings?.recent;
|
|
2348
|
+
const forms = r?.form ?? [];
|
|
2349
|
+
const accs = r?.accessionNumber ?? [];
|
|
2350
|
+
const idx = wantForm ? forms.findIndex((f) => f.toUpperCase() === wantForm) : 0;
|
|
2351
|
+
if (idx < 0 || !accs[idx]) {
|
|
2352
|
+
throw new Error(
|
|
2353
|
+
`No ${wantForm ?? 'recent'} filing found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
acc = normalizeAccession(accs[idx]);
|
|
2357
|
+
if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
|
|
2358
|
+
}
|
|
545
2359
|
const cikNoZeros = String(Number(cik.replace(/\D/g, ''))); // archive path uses CIK with no leading zeros
|
|
546
2360
|
const paddedCik = padCik(cik);
|
|
547
2361
|
|
|
@@ -552,7 +2366,7 @@ async function filingDocuments(
|
|
|
552
2366
|
let primaryDocument: string | null = null;
|
|
553
2367
|
let primaryDocDescription: string | null = null;
|
|
554
2368
|
try {
|
|
555
|
-
const subRes = await
|
|
2369
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
556
2370
|
if (subRes.ok) {
|
|
557
2371
|
const sub = (await subRes.json()) as {
|
|
558
2372
|
name?: string;
|
|
@@ -583,13 +2397,13 @@ async function filingDocuments(
|
|
|
583
2397
|
}
|
|
584
2398
|
|
|
585
2399
|
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
|
|
586
|
-
const idxRes = await
|
|
2400
|
+
const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
|
|
587
2401
|
if (idxRes.status === 404) {
|
|
588
2402
|
throw new Error(
|
|
589
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.`,
|
|
590
2404
|
);
|
|
591
2405
|
}
|
|
592
|
-
if (!idxRes.ok) throw
|
|
2406
|
+
if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
|
|
593
2407
|
const idx = (await idxRes.json()) as {
|
|
594
2408
|
directory?: { item?: { name: string; type?: string; size?: string | number; 'last-modified'?: string }[] };
|
|
595
2409
|
};
|
|
@@ -615,26 +2429,194 @@ async function filingDocuments(
|
|
|
615
2429
|
documents,
|
|
616
2430
|
};
|
|
617
2431
|
|
|
618
|
-
if (includePrimaryText && primaryDocument) {
|
|
619
|
-
try {
|
|
620
|
-
const docUrl = `${folder}/${primaryDocument}`;
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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;
|
|
627
2556
|
} else {
|
|
628
|
-
|
|
629
|
-
result.primary_text_error = `fetch ${docRes.status}`;
|
|
2557
|
+
sectionFound = false;
|
|
630
2558
|
}
|
|
631
|
-
} catch (e) {
|
|
632
|
-
result.primary_text = null;
|
|
633
|
-
result.primary_text_error = String(e);
|
|
634
2559
|
}
|
|
635
2560
|
}
|
|
636
2561
|
|
|
637
|
-
|
|
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
|
+
);
|
|
638
2620
|
}
|
|
639
2621
|
|
|
640
2622
|
async function companyFacts(cikOrTicker: string) {
|
|
@@ -644,10 +2626,11 @@ async function companyFacts(cikOrTicker: string) {
|
|
|
644
2626
|
// resolveCik like every sibling: validates the arg and resolves tickers.
|
|
645
2627
|
const cik = await resolveCik(cikOrTicker);
|
|
646
2628
|
const paddedCik = padCik(cik);
|
|
647
|
-
const res = await
|
|
2629
|
+
const res = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
|
|
648
2630
|
headers: SEC_HEADERS,
|
|
649
2631
|
});
|
|
650
|
-
if (
|
|
2632
|
+
if (res.status === 404) throw await companyFactsNotFound(cikOrTicker, paddedCik);
|
|
2633
|
+
if (!res.ok) throw await httpError(res, 'SEC EDGAR company facts error');
|
|
651
2634
|
|
|
652
2635
|
const data = (await res.json()) as {
|
|
653
2636
|
cik: number;
|
|
@@ -667,52 +2650,22 @@ async function companyFacts(cikOrTicker: string) {
|
|
|
667
2650
|
};
|
|
668
2651
|
};
|
|
669
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.
|
|
670
2657
|
const usGaap = data.facts?.['us-gaap'] ?? {};
|
|
671
|
-
const
|
|
672
|
-
'Revenues',
|
|
673
|
-
'RevenueFromContractWithCustomerExcludingAssessedTax',
|
|
674
|
-
'NetIncomeLoss',
|
|
675
|
-
'Assets',
|
|
676
|
-
'Liabilities',
|
|
677
|
-
'StockholdersEquity',
|
|
678
|
-
'CashAndCashEquivalentsAtCarryingValue',
|
|
679
|
-
'EarningsPerShareBasic',
|
|
680
|
-
'EarningsPerShareDiluted',
|
|
681
|
-
'CommonStockSharesOutstanding',
|
|
682
|
-
'OperatingIncomeLoss',
|
|
683
|
-
'GrossProfit',
|
|
684
|
-
'ResearchAndDevelopmentExpense',
|
|
685
|
-
];
|
|
686
|
-
|
|
687
|
-
const metrics: Record<
|
|
688
|
-
string,
|
|
689
|
-
{ label: string; most_recent_annual: { year: number; value: number; filed: string } | null }
|
|
690
|
-
> = {};
|
|
691
|
-
|
|
692
|
-
for (const key of KEY_METRICS) {
|
|
693
|
-
const fact = usGaap[key];
|
|
694
|
-
if (!fact) continue;
|
|
695
|
-
|
|
696
|
-
const usdEntries = fact.units['USD'] ?? fact.units['shares'] ?? [];
|
|
697
|
-
// Same fix as companyConcept: don't gate on `frame !== undefined`;
|
|
698
|
-
// off-calendar filers (NVDA, AAPL) get their modern annuals dropped.
|
|
699
|
-
// Sort by period end (desc) to surface the actual most-recent 10-K.
|
|
700
|
-
const annual = usdEntries
|
|
701
|
-
.filter((e) => e.form === '10-K' || e.form === '10-K/A')
|
|
702
|
-
.sort((a, b) => (b.end ?? '').localeCompare(a.end ?? ''));
|
|
703
|
-
|
|
704
|
-
metrics[key] = {
|
|
705
|
-
label: fact.label,
|
|
706
|
-
most_recent_annual: annual[0]
|
|
707
|
-
? { year: annual[0].fy, value: annual[0].val, filed: annual[0].filed }
|
|
708
|
-
: null,
|
|
709
|
-
};
|
|
710
|
-
}
|
|
2658
|
+
const summary = summarizeAnnualFinancials(usGaap);
|
|
711
2659
|
|
|
712
2660
|
return {
|
|
713
2661
|
cik: String(data.cik),
|
|
714
2662
|
company_name: data.entityName,
|
|
715
|
-
|
|
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,
|
|
716
2669
|
available_concepts: Object.keys(usGaap).length,
|
|
717
2670
|
};
|
|
718
2671
|
}
|
|
@@ -745,12 +2698,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
745
2698
|
revenues: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
|
|
746
2699
|
netincome: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
|
|
747
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'],
|
|
748
2712
|
cash: [
|
|
749
2713
|
'CashAndCashEquivalentsAtCarryingValue',
|
|
750
2714
|
'Cash',
|
|
751
2715
|
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
|
|
752
2716
|
],
|
|
753
|
-
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
|
+
],
|
|
754
2726
|
// Common concepts an LLM asks for that were MISSING → 404 (net income was
|
|
755
2727
|
// covered but these weren't). Keys are bare-alphanumeric (the lookup strips
|
|
756
2728
|
// spaces/punctuation), so "total assets"/"earnings per share" etc. all match.
|
|
@@ -768,7 +2740,9 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
768
2740
|
operatingincome: ['OperatingIncomeLoss'],
|
|
769
2741
|
operatingincomeloss: ['OperatingIncomeLoss'],
|
|
770
2742
|
// ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
|
|
771
|
-
longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
|
|
2743
|
+
longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt', 'LongTermDebtAndFinanceLeaseLiability', 'DebtLongtermAndShorttermCombinedAmount'],
|
|
2744
|
+
longtermdebtandfinanceleaseliability: ['LongTermDebtAndFinanceLeaseLiability', 'LongTermDebt', 'LongTermDebtAndCapitalLeaseObligations'],
|
|
2745
|
+
debtlongtermandshorttermcombinedamount: ['DebtLongtermAndShorttermCombinedAmount', 'LongTermDebt', 'LongTermDebtNoncurrent'],
|
|
772
2746
|
salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
|
|
773
2747
|
revenuefromcontractwithcustomerexcludingassessedtax: [
|
|
774
2748
|
'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
|
|
@@ -800,6 +2774,19 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
800
2774
|
// only, which silently dropped every foreign filer's data even when the tag
|
|
801
2775
|
// existed → 404s / empty results.
|
|
802
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
|
+
}
|
|
803
2790
|
|
|
804
2791
|
// ifrs-full taxonomy fallback for foreign private issuers (they report under
|
|
805
2792
|
// ifrs-full, not us-gaap). Keyed by the same normalized metric name; tried only
|
|
@@ -820,12 +2807,36 @@ const IFRS_FALLBACK: Record<string, string[]> = {
|
|
|
820
2807
|
equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
|
|
821
2808
|
};
|
|
822
2809
|
|
|
823
|
-
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);
|
|
824
2834
|
// Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
|
|
825
2835
|
// Production analytics: 11% errors on edgar largely from LLMs passing
|
|
826
2836
|
// tickers as cik (then padCik strips letters → 0000000000 → 404).
|
|
827
2837
|
const cik = await resolveCik(cikOrTicker);
|
|
828
2838
|
const paddedCik = padCik(cik);
|
|
2839
|
+
let resolvedEntityName = '';
|
|
829
2840
|
|
|
830
2841
|
// Normalize the lookup key to bare alphanumerics so natural-language input
|
|
831
2842
|
// ("net income", "Long-Term Debt", "earnings per share") matches the no-space
|
|
@@ -845,6 +2856,24 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
845
2856
|
{ end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string }[]
|
|
846
2857
|
>;
|
|
847
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]));
|
|
848
2877
|
|
|
849
2878
|
// Fetch EVERY candidate that exists, then pick the one with the most recent
|
|
850
2879
|
// 10-K data — not the first non-empty one. Filers switch revenue tags over
|
|
@@ -856,16 +2885,16 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
856
2885
|
const found: { doc: ConceptDoc; latestEnd: string }[] = [];
|
|
857
2886
|
const tryNamespace = async (ns: string, tags: string[]) => {
|
|
858
2887
|
for (const candidate of tags) {
|
|
859
|
-
const r = await
|
|
2888
|
+
const r = await pwFetch(
|
|
860
2889
|
`${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${ns}/${encodeURIComponent(candidate)}.json`,
|
|
861
2890
|
{ headers: SEC_HEADERS },
|
|
862
2891
|
);
|
|
863
2892
|
if (!r.ok) { lastStatus = r.status; continue; }
|
|
864
2893
|
const doc = (await r.json()) as ConceptDoc;
|
|
865
2894
|
let latestEnd = '';
|
|
866
|
-
for (const values of
|
|
2895
|
+
for (const [, values] of unitEntries(doc.units)) {
|
|
867
2896
|
for (const e of values) {
|
|
868
|
-
if (!
|
|
2897
|
+
if (!ALL_PERIODIC_FORMS.has(e.form)) continue;
|
|
869
2898
|
if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
|
|
870
2899
|
}
|
|
871
2900
|
}
|
|
@@ -885,19 +2914,26 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
885
2914
|
// LongTermDebtNoncurrent, many filers use LongTermDebtAndCapitalLease...). Pull
|
|
886
2915
|
// the full facts once and find the freshest us-gaap tag whose NAME matches the
|
|
887
2916
|
// concept keywords, preferring balance-sheet stock tags over cash-flow tags.
|
|
888
|
-
|
|
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) {
|
|
889
2922
|
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
890
2923
|
const tokens = candidates.map(norm).filter((t) => t.length >= 3);
|
|
891
|
-
const factsRes = await
|
|
892
|
-
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) {
|
|
893
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 ?? '';
|
|
894
2930
|
const gaap = facts.facts?.['us-gaap'] ?? {};
|
|
895
2931
|
const FLOW = /^(ProceedsFrom|RepaymentsOf|PaymentsOf|IncreaseDecrease|AmortizationOf|Gain|Loss)/;
|
|
896
2932
|
const matches: { isFlow: boolean; latestEnd: string; doc: ConceptDoc }[] = [];
|
|
897
2933
|
for (const [tag, entry] of Object.entries(gaap)) {
|
|
898
2934
|
if (!tokens.some((t) => norm(tag).includes(t))) continue;
|
|
899
2935
|
let latestEnd = '';
|
|
900
|
-
for (const values of
|
|
2936
|
+
for (const [, values] of unitEntries(entry.units)) {
|
|
901
2937
|
for (const e of values) {
|
|
902
2938
|
if (e.form !== '10-K' && e.form !== '10-K/A') continue;
|
|
903
2939
|
if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
|
|
@@ -913,10 +2949,82 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
913
2949
|
if (matches.length) found.push({ doc: matches[0].doc, latestEnd: matches[0].latestEnd });
|
|
914
2950
|
}
|
|
915
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
|
+
}
|
|
916
2993
|
if (found.length === 0) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
+
};
|
|
920
3028
|
}
|
|
921
3029
|
found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
|
|
922
3030
|
const data = found[0].doc;
|
|
@@ -934,14 +3042,29 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
934
3042
|
// caught this.
|
|
935
3043
|
// - DO dedupe: a 10-K and its 10-K/A amendments both report the same
|
|
936
3044
|
// (fy, fp, end) tuple. Keep the most-recently-filed version.
|
|
937
|
-
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
|
+
};
|
|
938
3059
|
const dedup = new Map<string, { entry: Raw; unit: string }>();
|
|
939
|
-
for (const [unit, values] of
|
|
3060
|
+
for (const [unit, values] of unitEntries(data.units)) {
|
|
940
3061
|
for (const e of values) {
|
|
941
|
-
if (!
|
|
3062
|
+
if (!forms.has(e.form)) continue; // period-scoped: annual (10-K/20-F/40-F), quarterly (10-Q), or all
|
|
942
3063
|
const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
|
|
943
3064
|
const prior = dedup.get(key);
|
|
944
|
-
if (!prior
|
|
3065
|
+
if (!prior
|
|
3066
|
+
|| (labelFits(e) && !labelFits(prior.entry))
|
|
3067
|
+
|| (labelFits(e) === labelFits(prior.entry) && (e.filed ?? '') > (prior.entry.filed ?? ''))) {
|
|
945
3068
|
dedup.set(key, { entry: e, unit });
|
|
946
3069
|
}
|
|
947
3070
|
}
|
|
@@ -949,20 +3072,235 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
949
3072
|
const entries = [...dedup.values()]
|
|
950
3073
|
.map(({ entry, unit }) => ({
|
|
951
3074
|
fiscal_year: entry.fy,
|
|
3075
|
+
fiscal_period: entry.fp ?? 'FY', // FY (annual) | Q1 | Q2 | Q3
|
|
3076
|
+
period_start: entry.start,
|
|
952
3077
|
period_end: entry.end,
|
|
953
3078
|
value: entry.val,
|
|
3079
|
+
form: entry.form, // 10-K / 10-Q / 20-F / 40-F (+ /A amendments)
|
|
954
3080
|
filed: entry.filed,
|
|
955
3081
|
unit,
|
|
3082
|
+
derived: false as boolean | undefined,
|
|
3083
|
+
note: undefined as string | undefined,
|
|
956
3084
|
}))
|
|
957
3085
|
.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
958
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
|
+
|
|
959
3277
|
return {
|
|
960
3278
|
cik: String(data.cik),
|
|
961
3279
|
company_name: data.entityName,
|
|
962
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
|
+
: {}),
|
|
963
3292
|
label: data.label,
|
|
964
3293
|
description: data.description,
|
|
965
|
-
|
|
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)
|
|
966
3304
|
};
|
|
967
3305
|
}
|
|
968
3306
|
|
|
@@ -994,9 +3332,13 @@ function xmlVal(block: string, tag: string): string | null {
|
|
|
994
3332
|
return plain ? plain[1].trim() : null;
|
|
995
3333
|
}
|
|
996
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.
|
|
997
3339
|
function xmlBlocks(xml: string, tag: string): string[] {
|
|
998
3340
|
const out: string[] = [];
|
|
999
|
-
const re = new RegExp(`<${tag}
|
|
3341
|
+
const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, 'gi');
|
|
1000
3342
|
let m: RegExpExecArray | null;
|
|
1001
3343
|
while ((m = re.exec(xml)) !== null) out.push(m[1]);
|
|
1002
3344
|
return out;
|
|
@@ -1064,8 +3406,8 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
|
|
|
1064
3406
|
const paddedCik = padCik(cik);
|
|
1065
3407
|
const count = Math.min(25, Math.max(1, limit ?? 10));
|
|
1066
3408
|
|
|
1067
|
-
const res = await
|
|
1068
|
-
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);
|
|
1069
3411
|
const data = (await res.json()) as {
|
|
1070
3412
|
cik: string;
|
|
1071
3413
|
name: string;
|
|
@@ -1095,7 +3437,7 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
|
|
|
1095
3437
|
const rawDoc = t.doc.replace(/^xsl[^/]*\//i, '');
|
|
1096
3438
|
const url = `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accPath}/${rawDoc}`;
|
|
1097
3439
|
try {
|
|
1098
|
-
const r = await
|
|
3440
|
+
const r = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
1099
3441
|
if (!r.ok) return { accession_number: t.accession, filing_date: t.date, form: t.form, error: `fetch ${r.status}`, filing_url: url };
|
|
1100
3442
|
const xml = await r.text();
|
|
1101
3443
|
const parsed = parseForm4Transactions(xml, includeDerivatives ?? false);
|
|
@@ -1137,8 +3479,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1137
3479
|
const paddedCik = padCik(cik);
|
|
1138
3480
|
const topN = Math.min(100, Math.max(1, limit ?? 25));
|
|
1139
3481
|
|
|
1140
|
-
const subRes = await
|
|
1141
|
-
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);
|
|
1142
3484
|
const sub = (await subRes.json()) as {
|
|
1143
3485
|
cik: string;
|
|
1144
3486
|
name: string;
|
|
@@ -1163,8 +3505,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1163
3505
|
// is neither primary_doc.xml (cover page) nor an xsl-rendered viewer copy.
|
|
1164
3506
|
const accPath = target.accession.replace(/-/g, '');
|
|
1165
3507
|
const folder = `https://www.sec.gov/Archives/edgar/data/${sub.cik}/${accPath}`;
|
|
1166
|
-
const idxRes = await
|
|
1167
|
-
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');
|
|
1168
3510
|
const idx = (await idxRes.json()) as { directory: { item: { name: string }[] } };
|
|
1169
3511
|
const infoFile = idx.directory.item.find(
|
|
1170
3512
|
(it) => it.name.toLowerCase().endsWith('.xml') && it.name !== 'primary_doc.xml' && !/^xsl/i.test(it.name),
|
|
@@ -1173,8 +3515,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1173
3515
|
throw new Error(`13F information table not found in filing ${target.accession}.`);
|
|
1174
3516
|
}
|
|
1175
3517
|
|
|
1176
|
-
const tableRes = await
|
|
1177
|
-
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');
|
|
1178
3520
|
const xml = stripNs(await tableRes.text());
|
|
1179
3521
|
|
|
1180
3522
|
// Aggregate rows by issuer+cusip+putCall (managers file multiple sub-portfolio
|
|
@@ -1199,15 +3541,44 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1199
3541
|
|
|
1200
3542
|
const all = [...byKey.values()].sort((a, b) => b.value_usd - a.value_usd);
|
|
1201
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
|
+
|
|
1202
3564
|
const holdings = all.slice(0, topN).map((h) => ({
|
|
1203
3565
|
issuer: h.issuer,
|
|
1204
3566
|
cusip: h.cusip,
|
|
1205
3567
|
put_call: h.put_call,
|
|
3568
|
+
direction: directionOf(h.put_call),
|
|
1206
3569
|
value_usd: h.value_usd,
|
|
1207
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.
|
|
1208
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,
|
|
1209
3578
|
}));
|
|
1210
3579
|
|
|
3580
|
+
const hasOptions = putValue > 0 || callValue > 0;
|
|
3581
|
+
|
|
1211
3582
|
return {
|
|
1212
3583
|
cik: sub.cik,
|
|
1213
3584
|
manager_name: sub.name,
|
|
@@ -1215,6 +3586,20 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1215
3586
|
report_period: target.period,
|
|
1216
3587
|
filed_date: target.filed,
|
|
1217
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).',
|
|
1218
3603
|
total_positions: all.length,
|
|
1219
3604
|
holdings_returned: holdings.length,
|
|
1220
3605
|
holdings,
|
|
@@ -1226,6 +3611,9 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1226
3611
|
function decodeEntities(s: string | null): string | null {
|
|
1227
3612
|
if (s == null) return null;
|
|
1228
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, ' ')
|
|
1229
3617
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1230
3618
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'");
|
|
1231
3619
|
}
|
|
@@ -1235,8 +3623,8 @@ function decodeEntities(s: string | null): string | null {
|
|
|
1235
3623
|
let mfTickerCache: Map<string, { cik: string; seriesId: string }> | null = null;
|
|
1236
3624
|
async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesId: string } | null> {
|
|
1237
3625
|
if (!mfTickerCache) {
|
|
1238
|
-
const res = await
|
|
1239
|
-
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');
|
|
1240
3628
|
const data = (await res.json()) as { fields: string[]; data: Array<[number, string, string, string]> };
|
|
1241
3629
|
// fields: [cik, seriesId, classId, symbol]
|
|
1242
3630
|
mfTickerCache = new Map();
|
|
@@ -1249,7 +3637,11 @@ async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesI
|
|
|
1249
3637
|
|
|
1250
3638
|
async function fundHoldings(ticker: string, limit?: number) {
|
|
1251
3639
|
const t = String(ticker ?? '').trim();
|
|
1252
|
-
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
|
+
);
|
|
1253
3645
|
const topN = Math.min(100, Math.max(1, limit ?? 25));
|
|
1254
3646
|
|
|
1255
3647
|
const resolved = await resolveFundTicker(t);
|
|
@@ -1259,8 +3651,8 @@ async function fundHoldings(ticker: string, limit?: number) {
|
|
|
1259
3651
|
const { cik, seriesId } = resolved;
|
|
1260
3652
|
const paddedCik = padCik(cik);
|
|
1261
3653
|
|
|
1262
|
-
const subRes = await
|
|
1263
|
-
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);
|
|
1264
3656
|
const sub = (await subRes.json()) as {
|
|
1265
3657
|
cik: string; name: string;
|
|
1266
3658
|
filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[] } };
|
|
@@ -1281,7 +3673,7 @@ async function fundHoldings(ticker: string, limit?: number) {
|
|
|
1281
3673
|
const accPath = f.accession.replace(/-/g, '');
|
|
1282
3674
|
const url = `https://www.sec.gov/Archives/edgar/data/${cik}/${accPath}/primary_doc.xml`;
|
|
1283
3675
|
try {
|
|
1284
|
-
const res = await
|
|
3676
|
+
const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
1285
3677
|
if (!res.ok) return null;
|
|
1286
3678
|
return { date: f.date, accession: f.accession, xml: stripNs(await res.text()) };
|
|
1287
3679
|
} catch {
|
|
@@ -1320,8 +3712,657 @@ async function fundHoldings(ticker: string, limit?: number) {
|
|
|
1320
3712
|
};
|
|
1321
3713
|
}
|
|
1322
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
|
+
|
|
1323
4349
|
// ── callTool router ─────────────────────────────────────────────────
|
|
1324
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
|
+
|
|
1325
4366
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
1326
4367
|
switch (name) {
|
|
1327
4368
|
case 'edgar_search_filings':
|
|
@@ -1332,42 +4373,101 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
1332
4373
|
args.end_date as string | undefined,
|
|
1333
4374
|
args.limit as number | undefined,
|
|
1334
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).
|
|
1335
4384
|
case 'edgar_company_filings':
|
|
1336
4385
|
return companyFilings(
|
|
1337
|
-
args
|
|
4386
|
+
companyArg(args),
|
|
1338
4387
|
args.form_type as string | undefined,
|
|
1339
4388
|
args.limit as number | undefined,
|
|
1340
4389
|
);
|
|
1341
4390
|
case 'edgar_company_facts':
|
|
1342
|
-
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
|
+
);
|
|
1343
4398
|
case 'edgar_insider_transactions':
|
|
1344
4399
|
return insiderTransactions(
|
|
1345
|
-
args
|
|
4400
|
+
companyArg(args),
|
|
1346
4401
|
args.limit as number | undefined,
|
|
1347
4402
|
args.include_derivatives as boolean | undefined,
|
|
1348
4403
|
);
|
|
1349
4404
|
case 'edgar_institutional_holdings':
|
|
1350
4405
|
return institutionalHoldings(
|
|
1351
|
-
args
|
|
4406
|
+
companyArg(args),
|
|
1352
4407
|
args.limit as number | undefined,
|
|
1353
4408
|
);
|
|
1354
4409
|
case 'edgar_fund_holdings':
|
|
1355
|
-
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);
|
|
1356
4411
|
case 'edgar_company_concept':
|
|
1357
|
-
// Accept `
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
|
|
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
|
+
);
|
|
1361
4427
|
case 'edgar_filing_documents':
|
|
1362
4428
|
return filingDocuments(
|
|
1363
|
-
args.accession as string,
|
|
4429
|
+
args.accession as string | undefined,
|
|
1364
4430
|
(args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
|
|
1365
4431
|
args.include_primary_text as boolean | undefined,
|
|
4432
|
+
args.form_type as string | undefined,
|
|
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,
|
|
1366
4442
|
);
|
|
1367
4443
|
case 'edgar_ticker_to_cik':
|
|
1368
|
-
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
|
+
);
|
|
1369
4453
|
case 'edgar_xbrl_frames':
|
|
1370
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
|
+
);
|
|
1371
4471
|
default:
|
|
1372
4472
|
throw new Error(`Unknown tool: ${name}`);
|
|
1373
4473
|
}
|
|
@@ -1388,11 +4488,11 @@ async function xbrlFrames(args: Record<string, unknown>) {
|
|
|
1388
4488
|
const limit = Math.min(200, Math.max(1, Number(args.limit) || 25));
|
|
1389
4489
|
|
|
1390
4490
|
const url = `${DATA_BASE}/api/xbrl/frames/${taxonomy}/${encodeURIComponent(concept)}/${encodeURIComponent(unit)}/${period}.json`;
|
|
1391
|
-
const res = await
|
|
4491
|
+
const res = await pwFetch(url, { headers: SEC_HEADERS });
|
|
1392
4492
|
if (res.status === 404) {
|
|
1393
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).` };
|
|
1394
4494
|
}
|
|
1395
|
-
if (!res.ok) throw
|
|
4495
|
+
if (!res.ok) throw await httpError(res, 'SEC frames error');
|
|
1396
4496
|
const data = (await res.json()) as { taxonomy?: string; tag?: string; uom?: string; label?: string; description?: string; data?: FrameDatum[] };
|
|
1397
4497
|
const rows = (data.data ?? []).slice().sort((a, b) => sortDesc ? (b.val ?? 0) - (a.val ?? 0) : (a.val ?? 0) - (b.val ?? 0));
|
|
1398
4498
|
|