@pipeworx/mcp-edgar 0.3.2 → 0.3.4
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 +3429 -198
- 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,654 @@ 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
|
+
// Fleet #2382. Everything that isn't a timeout/abort here is a genuine
|
|
456
|
+
// NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
|
|
457
|
+
// Cloudflare's own "Network connection lost." — meaning `fetch()` itself
|
|
458
|
+
// threw and no HTTP response of any kind was ever received. Until this fix
|
|
459
|
+
// that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
|
|
460
|
+
// (or the Workers-runtime equivalent) names no upstream, carries no class
|
|
461
|
+
// token, and reads exactly like a defect in OUR code — because it says
|
|
462
|
+
// nothing about the call at all. It landed in `error`, the tier that means
|
|
463
|
+
// "Pipeworx has a defect", for every one of the (at the time of writing)
|
|
464
|
+
// ~470 packs that call this helper directly with no wrapper of their own.
|
|
465
|
+
//
|
|
466
|
+
// `dexscreener` hit this independently (fleet #1579) and fixed it with a
|
|
467
|
+
// bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
|
|
468
|
+
// but only covers one pack; every other caller of this shared helper still
|
|
469
|
+
// leaked the raw exception. Moving the same fix HERE — the one place that
|
|
470
|
+
// already carries the timeout case — covers every pack that uses
|
|
471
|
+
// `fetchWithTimeout` without a wrapper, for free, and without widening
|
|
472
|
+
// `classifyToolError`'s regex list: the fix is giving the message a proper
|
|
473
|
+
// `upstream_down:` token at the point the two facts (no response was ever
|
|
474
|
+
// received, and which host we were trying to reach) are actually in hand,
|
|
475
|
+
// not teaching the classifier to guess from prose after the fact.
|
|
476
|
+
//
|
|
477
|
+
// Safe on the same grounds as the timeout branch above: no argument a
|
|
478
|
+
// caller passes can make `fetch()` itself throw a connection-level error,
|
|
479
|
+
// so this is always an availability failure, never a caller mistake. Same
|
|
480
|
+
// `markInternalOrigin` treatment — an origin we run that never answered is
|
|
481
|
+
// still ours, not a third party's outage.
|
|
482
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
483
|
+
throw new Error(
|
|
484
|
+
markInternalOrigin(
|
|
485
|
+
`upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
|
|
486
|
+
`No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
|
|
487
|
+
'are valid — do not re-check them on the strength of this error. Retry shortly.',
|
|
488
|
+
url,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function detailSuffix(detail: string): string {
|
|
495
|
+
return detail ? ` — ${detail}` : '';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function readDetail(res: Response): Promise<string> {
|
|
499
|
+
let raw: string;
|
|
500
|
+
try {
|
|
501
|
+
raw = await res.text();
|
|
502
|
+
} catch {
|
|
503
|
+
// Body already consumed, or the connection died mid-read. The status alone
|
|
504
|
+
// is still worth throwing — never let the error path throw its own error.
|
|
505
|
+
return '';
|
|
506
|
+
}
|
|
507
|
+
return summarizeErrorBody(raw);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Turn ANY error body — JSON, HTML, XML or plain text — into one short phrase
|
|
512
|
+
* that never contains markup.
|
|
513
|
+
*
|
|
514
|
+
* This used to just drop an HTML or XML body on the floor, on the reasoning
|
|
515
|
+
* that markup crowds out the status. That was half right. Dropping it loses the
|
|
516
|
+
* one sentence a caller could have acted on: an `Access Denied` title, an SDMX
|
|
517
|
+
* `<message:Error>` text, an OPS fault string. A 2026-08-30 support sweep
|
|
518
|
+
* measured 13 of 291 caller-facing error rows carrying a raw page or document
|
|
519
|
+
* verbatim, across 11 packs, and in every one of them the useful content —
|
|
520
|
+
* "Access Denied", "Invalid country code", "SCRAPE_TIMEOUT" — was in there,
|
|
521
|
+
* buried in markup the agent had to parse out of a string (fleet #712).
|
|
522
|
+
*
|
|
523
|
+
* So: extract the meaning, discard the markup. The output is passed through
|
|
524
|
+
* `stripMarkup` unconditionally, which is what lets `check:error-body-leak`
|
|
525
|
+
* assert mechanically that no caller-facing message can contain `<?xml`,
|
|
526
|
+
* `<!DOCTYPE` or `<html`.
|
|
527
|
+
*/
|
|
528
|
+
function summarizeErrorBody(raw: string): string {
|
|
529
|
+
if (!raw || !raw.trim()) return '';
|
|
530
|
+
|
|
531
|
+
const head = raw.slice(0, 400).trimStart().toLowerCase();
|
|
532
|
+
|
|
533
|
+
// An HTML error page (Cloudflare interstitial, nginx default, a login
|
|
534
|
+
// redirect) says what it is in its <title>, and almost nowhere else.
|
|
535
|
+
if (head.startsWith('<!doctype') || head.startsWith('<html')) {
|
|
536
|
+
const title = htmlTitle(raw);
|
|
537
|
+
return title
|
|
538
|
+
? `${title} (upstream returned an HTML error page, not an API response)`
|
|
539
|
+
: 'upstream returned an HTML error page, not an API response';
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// XML fault documents — EPO OPS, SDMX (`<message:Error>`), SOAP faults. The
|
|
543
|
+
// human sentence sits in a child element whose tag name says what it is.
|
|
544
|
+
if (head.startsWith('<?xml') || head.startsWith('<')) {
|
|
545
|
+
const fault = xmlFaultText(raw);
|
|
546
|
+
return fault
|
|
547
|
+
? `${stripMarkup(fault).slice(0, MAX_DETAIL)} (from the upstream's XML error document)`
|
|
548
|
+
: 'upstream returned an XML error document with no readable message';
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Most JSON error bodies bury one human sentence among ids and echoed request
|
|
552
|
+
// params. Prefer that sentence; fall back to the whole body when the shape is
|
|
553
|
+
// unfamiliar, since an unfamiliar shape is exactly when we can least afford to
|
|
554
|
+
// guess wrong and show nothing.
|
|
555
|
+
const fromJson = messageFromJson(raw);
|
|
556
|
+
return stripMarkup(fromJson ?? raw).slice(0, MAX_DETAIL);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The `<title>` of an HTML error page, or its first `<h1>` — the two places a
|
|
560
|
+
* bot wall, a 502 and an "Access Denied" all state what happened. */
|
|
561
|
+
function htmlTitle(raw: string): string | null {
|
|
562
|
+
const head = raw.slice(0, 4000);
|
|
563
|
+
for (const re of [/<title[^>]*>([\s\S]*?)<\/title>/i, /<h1[^>]*>([\s\S]*?)<\/h1>/i]) {
|
|
564
|
+
const m = re.exec(head);
|
|
565
|
+
const text = m ? stripMarkup(m[1]) : '';
|
|
566
|
+
if (text) return text.slice(0, 160);
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Tag names that carry the explanation in an XML fault document, namespace
|
|
572
|
+
* prefix optional (`<message:Error>`, `<com:Text>`, `<faultstring>`). */
|
|
573
|
+
const XML_FAULT_TAG_RE =
|
|
574
|
+
/<(?:[A-Za-z0-9_.-]+:)?(?:text|message|description|faultstring|reason|detail|title|errormessage|error)\b[^>]*>([^<]{2,400})</i;
|
|
575
|
+
|
|
576
|
+
function xmlFaultText(raw: string): string | null {
|
|
577
|
+
const head = raw.slice(0, 8000);
|
|
578
|
+
const tagged = XML_FAULT_TAG_RE.exec(head);
|
|
579
|
+
if (tagged && tagged[1].trim()) return tagged[1];
|
|
580
|
+
|
|
581
|
+
// Nothing conventionally named — take the longest text node instead. A fault
|
|
582
|
+
// document with one sentence in an oddly named element is still readable;
|
|
583
|
+
// returning nothing at all is not.
|
|
584
|
+
let best = '';
|
|
585
|
+
for (const m of head.matchAll(/>([^<>]{8,400})</g)) {
|
|
586
|
+
const text = m[1].trim();
|
|
587
|
+
if (text.length > best.length) best = text;
|
|
588
|
+
}
|
|
589
|
+
return best || null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Remove every tag and stray angle bracket, then collapse whitespace.
|
|
594
|
+
*
|
|
595
|
+
* Applied to everything on the way out, including the JSON and plain-text
|
|
596
|
+
* paths, because an upstream is free to embed markup in a JSON string field —
|
|
597
|
+
* and a leak is a leak regardless of which branch produced it.
|
|
598
|
+
*/
|
|
599
|
+
function stripMarkup(s: string): string {
|
|
600
|
+
return collapse(decodeEntities$shared(s.replace(/<[^>]*>/g, ' ')).replace(/[<>]/g, ' '));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** The handful of entities that show up in error-page titles. Decoded AFTER
|
|
604
|
+
* tags are stripped and BEFORE the angle-bracket sweep, so `<script>`
|
|
605
|
+
* in a title cannot decode into markup that survives — EMBL-EBI's ChEMBL 500
|
|
606
|
+
* page renders as `500 Internal Server Error < EMBL-EBI` otherwise. */
|
|
607
|
+
function decodeEntities$shared(s: string): string {
|
|
608
|
+
return s
|
|
609
|
+
.replace(/&(?:amp|#0*38);/gi, '&')
|
|
610
|
+
.replace(/&(?:lt|#0*60);/gi, '<')
|
|
611
|
+
.replace(/&(?:gt|#0*62);/gi, '>')
|
|
612
|
+
.replace(/&(?:quot|#0*34);/gi, '"')
|
|
613
|
+
.replace(/&(?:#0*39|apos|#x0*27);/gi, "'")
|
|
614
|
+
.replace(/ /gi, ' ');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The conventional "what went wrong" field, under any of the names upstreams
|
|
618
|
+
* actually use. Checked in order; first non-empty string wins. */
|
|
619
|
+
const MESSAGE_KEYS = [
|
|
620
|
+
'message', 'error_message', 'errorMessage', 'detail', 'details',
|
|
621
|
+
'description', 'error_description', 'reason', 'title', 'fault',
|
|
622
|
+
];
|
|
623
|
+
|
|
624
|
+
function messageFromJson(raw: string): string | null {
|
|
625
|
+
let parsed: unknown;
|
|
626
|
+
try {
|
|
627
|
+
parsed = JSON.parse(raw);
|
|
628
|
+
} catch {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
return pickMessage(parsed, 0);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function pickMessage(node: unknown, depth: number): string | null {
|
|
635
|
+
// Two levels covers `{error: {message}}` and `{errors: [{detail}]}`, the two
|
|
636
|
+
// shapes that account for nearly all of them, without walking a large payload.
|
|
637
|
+
if (depth > 2 || node == null) return null;
|
|
638
|
+
|
|
639
|
+
if (typeof node === 'string') return node.trim() || null;
|
|
640
|
+
|
|
641
|
+
if (Array.isArray(node)) {
|
|
642
|
+
for (const item of node) {
|
|
643
|
+
const found = pickMessage(item, depth + 1);
|
|
644
|
+
if (found) return found;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (typeof node !== 'object') return null;
|
|
650
|
+
const obj = node as Record<string, unknown>;
|
|
651
|
+
|
|
652
|
+
for (const key of MESSAGE_KEYS) {
|
|
653
|
+
const v = obj[key];
|
|
654
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
655
|
+
}
|
|
656
|
+
// `{error: …}` where error is itself an object or a string — the single most
|
|
657
|
+
// common wrapper, so it is worth descending into by name rather than scanning
|
|
658
|
+
// every key and risking picking up an echoed request parameter.
|
|
659
|
+
for (const key of ['error', 'errors', 'fault', 'Error', 'data']) {
|
|
660
|
+
if (key in obj) {
|
|
661
|
+
const found = pickMessage(obj[key], depth + 1);
|
|
662
|
+
if (found) return found;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Errors are read in a single line of log output; newlines and runs of
|
|
669
|
+
* whitespace make a multi-line body unreadable there. */
|
|
670
|
+
function collapse(s: string): string {
|
|
671
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
19
675
|
// Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
|
|
20
676
|
// imports — so publish-pack.sh can inline it into standalone pack builds the same
|
|
21
677
|
// way it inlines the McpToolExport type.
|
|
@@ -67,7 +723,7 @@ function rankMatches<T>(
|
|
|
67
723
|
return scored.map(({ item, kind, score }) => ({ item, kind, score }));
|
|
68
724
|
}
|
|
69
725
|
|
|
70
|
-
interface SecTickerRow { cik_str: number; ticker: string; title: string }
|
|
726
|
+
interface SecTickerRow$shared { cik_str: number; ticker: string; title: string }
|
|
71
727
|
|
|
72
728
|
interface SecEntity {
|
|
73
729
|
ticker: string;
|
|
@@ -98,7 +754,7 @@ async function resolveSecEntity(
|
|
|
98
754
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
99
755
|
const res = await doFetch(SEC_TICKERS_URL, { headers: opts.headers });
|
|
100
756
|
if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
|
|
101
|
-
const data = (await res.json()) as Record<string, SecTickerRow>;
|
|
757
|
+
const data = (await res.json()) as Record<string, SecTickerRow$shared>;
|
|
102
758
|
const rows = Object.values(data);
|
|
103
759
|
|
|
104
760
|
// 1) Exact ticker match — the common, unambiguous case.
|
|
@@ -120,10 +776,10 @@ async function resolveSecEntity(
|
|
|
120
776
|
return best;
|
|
121
777
|
}
|
|
122
778
|
|
|
123
|
-
throw new Error(`No SEC company matches "${query}". Pass a ticker
|
|
779
|
+
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
780
|
}
|
|
125
781
|
|
|
126
|
-
function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEntity {
|
|
782
|
+
function toEntity(r: SecTickerRow$shared, matched_by: 'ticker' | 'company_name'): SecEntity {
|
|
127
783
|
return {
|
|
128
784
|
ticker: r.ticker,
|
|
129
785
|
cik: String(r.cik_str),
|
|
@@ -133,7 +789,320 @@ function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEn
|
|
|
133
789
|
};
|
|
134
790
|
}
|
|
135
791
|
|
|
792
|
+
const GENERIC_CORP_WORDS = new Set([
|
|
793
|
+
'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED',
|
|
794
|
+
'LLC', 'LP', 'PLC', 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
|
|
795
|
+
'GROUP', 'HOLDINGS', 'HOLDING', 'AND', 'OF', 'US', 'USA', 'INTERNATIONAL',
|
|
796
|
+
'GLOBAL',
|
|
797
|
+
]);
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Split a corporate/organization name into its SIGNIFICANT tokens — words
|
|
801
|
+
* that aren't generic corporate boilerplate (Inc, Co, Ltd, Group, ...) or
|
|
802
|
+
* punctuation — sorted LONGEST FIRST. Built for cross-registry name joins
|
|
803
|
+
* where the two registries anchor on different words of the same name: SEC
|
|
804
|
+
* lists Eli Lilly as "ELI LILLY & Co", but Drugs@FDA's sponsor_name field
|
|
805
|
+
* uses "LILLY" — the longer, more distinctive token, not the first one
|
|
806
|
+
* ("ELI" alone is short and matches too loosely). Pure (no I/O); callers
|
|
807
|
+
* typically try tokens in order until one call to their OWN registry
|
|
808
|
+
* returns a result.
|
|
809
|
+
*/
|
|
810
|
+
function significantNameTokens(name: string): string[] {
|
|
811
|
+
const tokens = name
|
|
812
|
+
.toUpperCase()
|
|
813
|
+
.replace(/[.,&/()-]/g, ' ')
|
|
814
|
+
.split(/\s+/)
|
|
815
|
+
.filter((t) => t.length > 1 && !GENERIC_CORP_WORDS.has(t));
|
|
816
|
+
return [...new Set(tokens)].sort((a, b) => b.length - a.length);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Pick "the most recent annual value" out of SEC companyfacts XBRL data —
|
|
822
|
+
* the one operation every company-facts tool does, and the one that shipped
|
|
823
|
+
* wrong twice (sec + edgar packs, identical code) for the largest filers.
|
|
824
|
+
*
|
|
825
|
+
* Two traps in the raw companyfacts feed, both confirmed live 2026-08-28
|
|
826
|
+
* (fleet #594):
|
|
827
|
+
*
|
|
828
|
+
* 1. `fy` is the fiscal year of the FILING, not of the reported period. A
|
|
829
|
+
* 10-K carries two or three prior years as comparatives, and every one of
|
|
830
|
+
* them is stamped with the filing's fy. Amazon's FY2020 net income
|
|
831
|
+
* ($21.3B, period 2020-01-01..2020-12-31) appears with fy=2022 because it
|
|
832
|
+
* was restated in the FY2022 10-K — and Amazon's actual FY2022 figure is a
|
|
833
|
+
* $2.7B LOSS. Sorting by fy therefore mislabels the year AND can pick a
|
|
834
|
+
* comparative over the current period. The period is `start`/`end`; the
|
|
835
|
+
* year has to be derived from `end`.
|
|
836
|
+
*
|
|
837
|
+
* 2. `frame` is assigned to the LAST-FILED fact for a calendar period, not to
|
|
838
|
+
* the 10-K that first reported it. Since proxies (DEF 14A) began carrying
|
|
839
|
+
* XBRL, Amazon's CY2021..CY2025 annual frames all sit on the 2026 proxy,
|
|
840
|
+
* so a `form === '10-K' && frame` filter sees no 10-K annual fact newer
|
|
841
|
+
* than CY2020 and confidently returns that.
|
|
842
|
+
*
|
|
843
|
+
* And one presentation trap: a concept the filer has RETIRED (Microsoft's
|
|
844
|
+
* `Revenues` stops at FY2010 — it moved to
|
|
845
|
+
* RevenueFromContractWithCustomerExcludingAssessedTax under ASC 606) is
|
|
846
|
+
* accurate per-concept but wrong the moment it is presented under
|
|
847
|
+
* "most recent" beside concepts that are genuinely current. The fix is
|
|
848
|
+
* labelling and precedence, not deletion: a caller building a long series
|
|
849
|
+
* legitimately wants the retired concept, so it stays — flagged `stale`,
|
|
850
|
+
* sorted last, with the current concept surfaced in `latest_annual`.
|
|
851
|
+
*
|
|
852
|
+
* Kept dependency-free on purpose: publish-pack.sh inlines this file into the
|
|
853
|
+
* standalone npm build of any pack that imports it.
|
|
854
|
+
*/
|
|
855
|
+
|
|
856
|
+
interface XbrlFactEntry {
|
|
857
|
+
start?: string;
|
|
858
|
+
end: string;
|
|
859
|
+
val: number;
|
|
860
|
+
accn?: string;
|
|
861
|
+
fy?: number;
|
|
862
|
+
fp?: string;
|
|
863
|
+
form?: string;
|
|
864
|
+
filed?: string;
|
|
865
|
+
frame?: string;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
interface XbrlConcept {
|
|
869
|
+
label: string;
|
|
870
|
+
description?: string;
|
|
871
|
+
units: Record<string, XbrlFactEntry[]>;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
type XbrlConceptMap = Record<string, XbrlConcept>;
|
|
875
|
+
|
|
876
|
+
interface AnnualValue {
|
|
877
|
+
/** Fiscal year the period ENDS in (derived from period_end — see fiscalYearOf). */
|
|
878
|
+
year: number;
|
|
879
|
+
value: number;
|
|
880
|
+
filed: string;
|
|
881
|
+
/** Present for duration concepts (income statement); absent for instants (balance sheet). */
|
|
882
|
+
period_start?: string;
|
|
883
|
+
period_end: string;
|
|
884
|
+
form: string;
|
|
885
|
+
unit: string;
|
|
886
|
+
concept: string;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
interface KeyFinancial {
|
|
890
|
+
label: string;
|
|
891
|
+
most_recent_annual: Omit<AnnualValue, 'concept'> | null;
|
|
892
|
+
/**
|
|
893
|
+
* true when this concept's latest annual value ends before the filer's latest
|
|
894
|
+
* annual report period — i.e. the filer stopped reporting it. The number is
|
|
895
|
+
* real history, not the company's current figure.
|
|
896
|
+
*/
|
|
897
|
+
stale: boolean;
|
|
898
|
+
stale_note?: string;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Annual-report forms. 10-KT (transition period) is deliberately excluded. */
|
|
902
|
+
const ANNUAL_FORMS$shared = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
|
|
903
|
+
/** Preferred unit per concept, in order. Anything else falls back to the first unit present. */
|
|
904
|
+
const UNIT_PREFERENCE = ['USD', 'USD/shares', 'shares', 'pure'];
|
|
905
|
+
const DAY_MS = 86_400_000;
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Fiscal year a period belongs to, by the near-universal convention of naming
|
|
909
|
+
* the year the period ENDS in (Apple FY2025 ends 2025-09-27, Walmart FY2026
|
|
910
|
+
* ends 2026-01-31). The one exception handled: 52/53-week years that end in
|
|
911
|
+
* the first days of January belong to the prior year (a year ending
|
|
912
|
+
* 2027-01-02 is fiscal 2026).
|
|
913
|
+
*/
|
|
914
|
+
function fiscalYearOf(periodEnd: string): number {
|
|
915
|
+
const y = Number(periodEnd.slice(0, 4));
|
|
916
|
+
const m = Number(periodEnd.slice(5, 7));
|
|
917
|
+
const d = Number(periodEnd.slice(8, 10));
|
|
918
|
+
return m === 1 && d <= 7 ? y - 1 : y;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function preferredUnit(units: Record<string, XbrlFactEntry[]>): string | null {
|
|
922
|
+
for (const u of UNIT_PREFERENCE) if (units[u]?.length) return u;
|
|
923
|
+
const first = Object.keys(units).find((u) => units[u]?.length);
|
|
924
|
+
return first ?? null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function isAnnualPeriod(e: XbrlFactEntry): boolean {
|
|
928
|
+
if (!e.start) return true; // instant (balance-sheet) fact
|
|
929
|
+
const days = (Date.parse(e.end) - Date.parse(e.start)) / DAY_MS;
|
|
930
|
+
return days >= 340 && days <= 380; // 52/53-week years included, quarters/YTD excluded
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The most recent annual value for one concept: latest period_end among
|
|
935
|
+
* annual-report facts, ties broken by latest filed (a restated figure from a
|
|
936
|
+
* later 10-K wins over the original).
|
|
937
|
+
*/
|
|
938
|
+
function latestAnnual(concepts: XbrlConceptMap, concept: string): AnnualValue | null {
|
|
939
|
+
const fact = concepts[concept];
|
|
940
|
+
if (!fact?.units) return null;
|
|
941
|
+
const unit = preferredUnit(fact.units);
|
|
942
|
+
if (!unit) return null;
|
|
943
|
+
let best: XbrlFactEntry | null = null;
|
|
944
|
+
for (const e of fact.units[unit]) {
|
|
945
|
+
if (!e.form || !ANNUAL_FORMS$shared.has(e.form) || !e.end || typeof e.val !== 'number') continue;
|
|
946
|
+
if (!isAnnualPeriod(e)) continue;
|
|
947
|
+
if (
|
|
948
|
+
!best ||
|
|
949
|
+
e.end > best.end ||
|
|
950
|
+
(e.end === best.end && (e.filed ?? '') > (best.filed ?? ''))
|
|
951
|
+
) best = e;
|
|
952
|
+
}
|
|
953
|
+
if (!best) return null;
|
|
954
|
+
return {
|
|
955
|
+
year: fiscalYearOf(best.end),
|
|
956
|
+
value: best.val,
|
|
957
|
+
filed: best.filed ?? '',
|
|
958
|
+
...(best.start ? { period_start: best.start } : {}),
|
|
959
|
+
period_end: best.end,
|
|
960
|
+
form: best.form ?? '',
|
|
961
|
+
unit,
|
|
962
|
+
concept,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Among alternative concepts for the same line item (filers migrate tags —
|
|
968
|
+
* Revenues → RevenueFromContractWithCustomerExcludingAssessedTax under ASC
|
|
969
|
+
* 606), the one with the most recent period wins; ties go to list order.
|
|
970
|
+
*/
|
|
971
|
+
function freshestOf(concepts: XbrlConceptMap, candidates: string[]): AnnualValue | null {
|
|
972
|
+
let best: AnnualValue | null = null;
|
|
973
|
+
for (const c of candidates) {
|
|
974
|
+
const hit = latestAnnual(concepts, c);
|
|
975
|
+
if (hit && (!best || hit.period_end > best.period_end)) best = hit;
|
|
976
|
+
}
|
|
977
|
+
return best;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Canonical line items → the us-gaap concepts filers use for them, most-current-first. */
|
|
981
|
+
const LINE_ITEM_CONCEPTS: Record<string, string[]> = {
|
|
982
|
+
revenue: [
|
|
983
|
+
'RevenueFromContractWithCustomerExcludingAssessedTax',
|
|
984
|
+
'Revenues',
|
|
985
|
+
'SalesRevenueNet',
|
|
986
|
+
'RevenueFromContractWithCustomerIncludingAssessedTax',
|
|
987
|
+
'SalesRevenueGoodsNet',
|
|
988
|
+
'RevenuesNetOfInterestExpense',
|
|
989
|
+
],
|
|
990
|
+
net_income: ['NetIncomeLoss', 'ProfitLoss'],
|
|
991
|
+
operating_income: ['OperatingIncomeLoss'],
|
|
992
|
+
gross_profit: ['GrossProfit'],
|
|
993
|
+
total_assets: ['Assets'],
|
|
994
|
+
total_liabilities: ['Liabilities'],
|
|
995
|
+
stockholders_equity: [
|
|
996
|
+
'StockholdersEquity',
|
|
997
|
+
'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest',
|
|
998
|
+
],
|
|
999
|
+
cash_and_equivalents: [
|
|
1000
|
+
'CashAndCashEquivalentsAtCarryingValue',
|
|
1001
|
+
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
|
|
1002
|
+
'Cash',
|
|
1003
|
+
],
|
|
1004
|
+
eps_basic: ['EarningsPerShareBasic'],
|
|
1005
|
+
eps_diluted: ['EarningsPerShareDiluted'],
|
|
1006
|
+
shares_outstanding: ['CommonStockSharesOutstanding'],
|
|
1007
|
+
research_and_development: ['ResearchAndDevelopmentExpense'],
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
interface AnnualFinancialsSummary {
|
|
1011
|
+
/** Fiscal year of the filer's most recent annual report, across every concept examined. */
|
|
1012
|
+
latest_fiscal_year: number | null;
|
|
1013
|
+
latest_period_end: string | null;
|
|
1014
|
+
/** One entry per canonical line item, choosing whichever concept the filer currently reports under. */
|
|
1015
|
+
latest_annual: Record<string, AnnualValue | null>;
|
|
1016
|
+
/** Per-concept detail. Current concepts first, retired (stale) concepts last. */
|
|
1017
|
+
key_financials: Record<string, KeyFinancial>;
|
|
1018
|
+
/** Concepts the filer has stopped reporting — present in key_financials, flagged, never in latest_annual. */
|
|
1019
|
+
stale_concepts: string[];
|
|
1020
|
+
year_note: string;
|
|
1021
|
+
}
|
|
136
1022
|
|
|
1023
|
+
/**
|
|
1024
|
+
* Build the whole "key financials" view: canonical line items resolved to the
|
|
1025
|
+
* concept the filer currently uses, plus per-concept detail with staleness
|
|
1026
|
+
* flagged at the point of use and stale concepts sorted last.
|
|
1027
|
+
*/
|
|
1028
|
+
function summarizeAnnualFinancials(concepts: XbrlConceptMap): AnnualFinancialsSummary {
|
|
1029
|
+
// Every concept any line item could draw from, in a stable order.
|
|
1030
|
+
const perConcept: { concept: string; label: string; value: AnnualValue | null }[] = [];
|
|
1031
|
+
const seen = new Set<string>();
|
|
1032
|
+
for (const candidates of Object.values(LINE_ITEM_CONCEPTS)) {
|
|
1033
|
+
for (const c of candidates) {
|
|
1034
|
+
if (seen.has(c) || !concepts[c]) continue;
|
|
1035
|
+
seen.add(c);
|
|
1036
|
+
perConcept.push({ concept: c, label: concepts[c].label, value: latestAnnual(concepts, c) });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
let latestEnd: string | null = null;
|
|
1041
|
+
for (const row of perConcept) {
|
|
1042
|
+
if (row.value && (!latestEnd || row.value.period_end > latestEnd)) latestEnd = row.value.period_end;
|
|
1043
|
+
}
|
|
1044
|
+
const latestFy = latestEnd ? fiscalYearOf(latestEnd) : null;
|
|
1045
|
+
|
|
1046
|
+
// A concept whose latest annual period ends more than ~100 days before the
|
|
1047
|
+
// filer's latest annual period has been retired by the filer. (Instants and
|
|
1048
|
+
// durations in the same 10-K share an end date, so anything beyond a few
|
|
1049
|
+
// days is a genuinely older report.)
|
|
1050
|
+
const isStale = (v: AnnualValue | null) =>
|
|
1051
|
+
!!v && !!latestEnd && (Date.parse(latestEnd) - Date.parse(v.period_end)) / DAY_MS > 100;
|
|
1052
|
+
|
|
1053
|
+
// Canonical line items: the freshest NON-STALE concept per item. A line the
|
|
1054
|
+
// filer only ever reported under a since-retired concept (Amazon's
|
|
1055
|
+
// GrossProfit stops at FY2009) is null here — the stale value stays in
|
|
1056
|
+
// key_financials, flagged, where it cannot be read as current.
|
|
1057
|
+
const latest_annual: Record<string, AnnualValue | null> = {};
|
|
1058
|
+
for (const [item, candidates] of Object.entries(LINE_ITEM_CONCEPTS)) {
|
|
1059
|
+
const v = freshestOf(concepts, candidates);
|
|
1060
|
+
latest_annual[item] = v && !isStale(v) ? v : null;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const itemOf = (concept: string) =>
|
|
1064
|
+
Object.entries(LINE_ITEM_CONCEPTS).find(([, cs]) => cs.includes(concept))?.[0];
|
|
1065
|
+
|
|
1066
|
+
const current = perConcept.filter((r) => !isStale(r.value));
|
|
1067
|
+
const stale = perConcept.filter((r) => isStale(r.value));
|
|
1068
|
+
const key_financials: Record<string, KeyFinancial> = {};
|
|
1069
|
+
for (const r of [...current, ...stale]) {
|
|
1070
|
+
const entry: KeyFinancial = {
|
|
1071
|
+
label: r.label,
|
|
1072
|
+
most_recent_annual: r.value ? stripConcept(r.value) : null,
|
|
1073
|
+
stale: isStale(r.value),
|
|
1074
|
+
};
|
|
1075
|
+
if (entry.stale && r.value) {
|
|
1076
|
+
const item = itemOf(r.concept);
|
|
1077
|
+
const replacement = item ? latest_annual[item] : null;
|
|
1078
|
+
const successor =
|
|
1079
|
+
replacement && replacement.concept !== r.concept
|
|
1080
|
+
? ` The filer now reports this line under ${replacement.concept} (FY${replacement.year}) — see latest_annual.${item}.`
|
|
1081
|
+
: ' No current concept for this line item was found in the filer\'s us-gaap facts.';
|
|
1082
|
+
entry.stale_note =
|
|
1083
|
+
`STALE: this concept's latest annual value is for FY${r.value.year} (period ending ${r.value.period_end}), ` +
|
|
1084
|
+
`but the filer's most recent annual report covers FY${latestFy}. The number is real history, not the company's current figure.` +
|
|
1085
|
+
successor;
|
|
1086
|
+
}
|
|
1087
|
+
key_financials[r.concept] = entry;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
latest_fiscal_year: latestFy,
|
|
1092
|
+
latest_period_end: latestEnd,
|
|
1093
|
+
latest_annual,
|
|
1094
|
+
key_financials,
|
|
1095
|
+
stale_concepts: stale.map((r) => r.concept),
|
|
1096
|
+
year_note:
|
|
1097
|
+
'`year` is the fiscal year the reporting period ENDS in, derived from period_end. It is NOT the SEC `fy` field, ' +
|
|
1098
|
+
'which stamps prior-year comparatives with the year of the filing that restated them.',
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function stripConcept(v: AnnualValue): Omit<AnnualValue, 'concept'> {
|
|
1103
|
+
const { concept: _concept, ...rest } = v;
|
|
1104
|
+
return rest;
|
|
1105
|
+
}
|
|
137
1106
|
/**
|
|
138
1107
|
* EDGAR MCP — SEC EDGAR public APIs (free, no auth)
|
|
139
1108
|
*
|
|
@@ -143,12 +1112,24 @@ function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEn
|
|
|
143
1112
|
* - edgar_company_facts: get structured XBRL financial data for a company
|
|
144
1113
|
* - edgar_company_concept: get a specific financial metric over time
|
|
145
1114
|
* - edgar_ticker_to_cik: look up CIK from ticker symbol
|
|
1115
|
+
* - edgar_company_snapshot: CIK + recent filings + headline XBRL figures in ONE call
|
|
1116
|
+
* (the resolve -> filings -> concept chain, collapsed; fleet #2182)
|
|
146
1117
|
* - edgar_filing_documents: list the documents inside one filing by accession number
|
|
1118
|
+
* - edgar_filing_text: full/paged plaintext of a filing's primary document (by section optional)
|
|
147
1119
|
*
|
|
148
1120
|
* Note: SEC requires a descriptive User-Agent header per their guidelines.
|
|
149
1121
|
*/
|
|
150
1122
|
|
|
151
1123
|
|
|
1124
|
+
// Bound every fetch() in this pack to a fixed timeout — an upstream that
|
|
1125
|
+
// degrades without erroring would otherwise hold the Worker in `await fetch()`
|
|
1126
|
+
// until its own execution budget kills the request (minutes, not seconds).
|
|
1127
|
+
// Mirrors the epoFetch / usaspending retryFetch pattern (fleet #685).
|
|
1128
|
+
async function pwFetch(url: string | URL, init?: RequestInit): Promise<Response> {
|
|
1129
|
+
return fetchWithTimeout(url, init ?? {}, 'Edgar');
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
|
|
152
1133
|
const EFTS_BASE = 'https://efts.sec.gov/LATEST';
|
|
153
1134
|
const DATA_BASE = 'https://data.sec.gov';
|
|
154
1135
|
const SEC_HEADERS: Record<string, string> = {
|
|
@@ -160,7 +1141,8 @@ const tools: McpToolExport['tools'] = [
|
|
|
160
1141
|
{
|
|
161
1142
|
name: 'edgar_search_filings',
|
|
162
1143
|
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
|
|
1144
|
+
'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).',
|
|
1145
|
+
summary: 'Full-text search across every SEC filing, using EDGAR\'s own search index.',
|
|
164
1146
|
inputSchema: {
|
|
165
1147
|
type: 'object' as const,
|
|
166
1148
|
properties: {
|
|
@@ -179,143 +1161,397 @@ const tools: McpToolExport['tools'] = [
|
|
|
179
1161
|
{
|
|
180
1162
|
name: 'edgar_company_filings',
|
|
181
1163
|
description:
|
|
182
|
-
'AUTHORITATIVE list of recent SEC filings for a specific US public company.
|
|
1164
|
+
'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.',
|
|
1165
|
+
summary: 'Every SEC filing a US public company has made, newest first.',
|
|
183
1166
|
inputSchema: {
|
|
184
1167
|
type: 'object' as const,
|
|
185
1168
|
properties: {
|
|
186
1169
|
ticker_or_cik: {
|
|
187
1170
|
type: 'string',
|
|
188
|
-
description: 'Ticker symbol (e.g., "AAPL") or CIK number (e.g., "320193")',
|
|
1171
|
+
description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker symbol (e.g., "AAPL") or CIK number (e.g., "320193")',
|
|
189
1172
|
},
|
|
190
1173
|
form_type: {
|
|
191
1174
|
type: 'string',
|
|
192
1175
|
description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K"). Omit for all types.',
|
|
193
1176
|
},
|
|
194
1177
|
limit: { type: 'number', description: 'Max filings to return (1-40, default 20)' },
|
|
1178
|
+
cik: {
|
|
1179
|
+
type: 'string',
|
|
1180
|
+
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.',
|
|
1181
|
+
},
|
|
1182
|
+
ticker: {
|
|
1183
|
+
type: 'string',
|
|
1184
|
+
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.',
|
|
1185
|
+
},
|
|
195
1186
|
},
|
|
196
|
-
|
|
1187
|
+
// Deliberately empty, with the check done in the handler instead. A flat
|
|
1188
|
+
// `required` list cannot express "ticker_or_cik OR cik OR ticker", so
|
|
1189
|
+
// declaring the aliases above and keeping the canonical name required
|
|
1190
|
+
// would reject every call that used one — the gateway's pre-flight
|
|
1191
|
+
// refuses before the pack ever runs (fleet #2058, swept in #2069).
|
|
1192
|
+
required: [],
|
|
1193
|
+
|
|
197
1194
|
},
|
|
198
1195
|
},
|
|
199
1196
|
{
|
|
200
1197
|
name: 'edgar_company_facts',
|
|
201
1198
|
description:
|
|
202
|
-
'AUTHORITATIVE full XBRL fundamentals dump for a US public company
|
|
1199
|
+
'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.',
|
|
1200
|
+
summary: 'Every figure a US public company has reported to the SEC in XBRL, annual and quarterly.',
|
|
203
1201
|
inputSchema: {
|
|
204
1202
|
type: 'object' as const,
|
|
205
1203
|
properties: {
|
|
206
1204
|
cik: {
|
|
207
1205
|
type: 'string',
|
|
208
|
-
description: 'Ticker ("NVDA") or CIK number ("320193"). Tickers are auto-resolved to CIKs internally.',
|
|
1206
|
+
description: 'REQUIRED (or one of its aliases `ticker` / `ticker_or_cik`). Ticker ("NVDA") or CIK number ("320193"). Tickers are auto-resolved to CIKs internally.',
|
|
1207
|
+
},
|
|
1208
|
+
ticker: {
|
|
1209
|
+
type: 'string',
|
|
1210
|
+
description: 'Alias for `cik` — same thing, a ticker or a CIK. The spelling edgar_fund_holdings and edgar_ticker_to_cik use.',
|
|
1211
|
+
},
|
|
1212
|
+
ticker_or_cik: {
|
|
1213
|
+
type: 'string',
|
|
1214
|
+
description: 'Alias for `cik` — the spelling edgar_company_filings, edgar_insider_transactions and edgar_product_revenue use.',
|
|
209
1215
|
},
|
|
210
1216
|
},
|
|
211
|
-
|
|
1217
|
+
// Deliberately empty, with the check done in the handler instead. A flat
|
|
1218
|
+
// `required` list cannot express "ticker_or_cik OR cik OR ticker", so
|
|
1219
|
+
// declaring the aliases above and keeping the canonical name required
|
|
1220
|
+
// would reject every call that used one — the gateway's pre-flight
|
|
1221
|
+
// refuses before the pack ever runs (fleet #2058, swept in #2069).
|
|
1222
|
+
required: [],
|
|
1223
|
+
|
|
212
1224
|
},
|
|
213
1225
|
},
|
|
214
1226
|
{
|
|
215
1227
|
name: 'edgar_company_concept',
|
|
216
1228
|
description:
|
|
217
|
-
'AUTHORITATIVE historical financials for any US public company. Source: SEC XBRL filings (the official numbers companies file, not third-party scrapes).
|
|
1229
|
+
'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.',
|
|
1230
|
+
summary: 'One financial metric for a US public company across every period it has reported, from SEC XBRL.',
|
|
218
1231
|
inputSchema: {
|
|
219
1232
|
type: 'object' as const,
|
|
220
1233
|
properties: {
|
|
221
1234
|
cik: {
|
|
222
1235
|
type: 'string',
|
|
223
|
-
description: 'Ticker (e.g., "AAPL") or CIK number (e.g., "320193"). Tickers are auto-resolved.',
|
|
1236
|
+
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
1237
|
},
|
|
225
1238
|
concept: {
|
|
226
1239
|
type: 'string',
|
|
227
1240
|
description:
|
|
228
|
-
'Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
|
|
1241
|
+
'REQUIRED (alias `metric`). Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
|
|
1242
|
+
},
|
|
1243
|
+
period: {
|
|
1244
|
+
type: 'string',
|
|
1245
|
+
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.',
|
|
1246
|
+
enum: ['all', 'annual', 'quarterly'],
|
|
1247
|
+
},
|
|
1248
|
+
fiscal_year: {
|
|
1249
|
+
type: 'string',
|
|
1250
|
+
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.',
|
|
1251
|
+
},
|
|
1252
|
+
fiscal_period: {
|
|
1253
|
+
type: 'string',
|
|
1254
|
+
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.',
|
|
1255
|
+
enum: ['FY', 'Q1', 'Q2', 'Q3', 'Q4'],
|
|
1256
|
+
},
|
|
1257
|
+
ticker: {
|
|
1258
|
+
type: 'string',
|
|
1259
|
+
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.',
|
|
1260
|
+
},
|
|
1261
|
+
ticker_or_cik: {
|
|
1262
|
+
type: 'string',
|
|
1263
|
+
description: 'Alias for `cik` — the spelling used by edgar_company_filings, edgar_insider_transactions and edgar_product_revenue.',
|
|
1264
|
+
},
|
|
1265
|
+
metric: {
|
|
1266
|
+
type: 'string',
|
|
1267
|
+
description: 'Alias for `concept` — the word this tool\'s own description uses for the thing you are asking about.',
|
|
229
1268
|
},
|
|
230
1269
|
},
|
|
231
|
-
|
|
1270
|
+
// Deliberately empty, with the check done in companyConcept() instead —
|
|
1271
|
+
// the same pattern edgar_filing_text/edgar_filing_documents already use.
|
|
1272
|
+
// A flat `required` list cannot express "cik OR ticker OR ticker_or_cik",
|
|
1273
|
+
// so declaring the aliases above and keeping `cik` required would reject
|
|
1274
|
+
// every call that used one, which is exactly the production failure this
|
|
1275
|
+
// replaces: 12 of 12 `partial` outcomes on this tool over 3 days had the
|
|
1276
|
+
// adjudicator fill `ticker` (4 of them `metric` too), and the gateway's
|
|
1277
|
+
// pre-flight rejected them with "cik is required" before the pack ran
|
|
1278
|
+
// (fleet #2058). The pack-side check below names all accepted spellings.
|
|
1279
|
+
required: [],
|
|
232
1280
|
},
|
|
233
1281
|
},
|
|
234
1282
|
{
|
|
235
1283
|
name: 'edgar_insider_transactions',
|
|
236
1284
|
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.
|
|
1285
|
+
'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".',
|
|
1286
|
+
summary: 'Who bought or sold a company\'s stock as an insider, from SEC Forms 3, 4 and 5.',
|
|
238
1287
|
inputSchema: {
|
|
239
1288
|
type: 'object' as const,
|
|
240
1289
|
properties: {
|
|
241
1290
|
ticker_or_cik: {
|
|
242
1291
|
type: 'string',
|
|
243
|
-
description: 'Ticker symbol (e.g., "TSLA") or CIK number (e.g., "1318605")',
|
|
1292
|
+
description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker symbol (e.g., "TSLA") or CIK number (e.g., "1318605")',
|
|
244
1293
|
},
|
|
245
1294
|
limit: { type: 'number', description: 'Max Form 4/3/5 filings to parse (1-25, default 10)' },
|
|
246
1295
|
include_derivatives: {
|
|
247
1296
|
type: 'boolean',
|
|
248
1297
|
description: 'Also include derivative (options/RSU) transactions. Default false (non-derivative common-stock only).',
|
|
249
1298
|
},
|
|
1299
|
+
cik: {
|
|
1300
|
+
type: 'string',
|
|
1301
|
+
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.',
|
|
1302
|
+
},
|
|
1303
|
+
ticker: {
|
|
1304
|
+
type: 'string',
|
|
1305
|
+
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.',
|
|
1306
|
+
},
|
|
250
1307
|
},
|
|
251
|
-
|
|
1308
|
+
// Deliberately empty, with the check done in the handler instead. A flat
|
|
1309
|
+
// `required` list cannot express "ticker_or_cik OR cik OR ticker", so
|
|
1310
|
+
// declaring the aliases above and keeping the canonical name required
|
|
1311
|
+
// would reject every call that used one — the gateway's pre-flight
|
|
1312
|
+
// refuses before the pack ever runs (fleet #2058, swept in #2069).
|
|
1313
|
+
required: [],
|
|
1314
|
+
|
|
252
1315
|
},
|
|
253
1316
|
},
|
|
254
1317
|
{
|
|
255
1318
|
name: 'edgar_institutional_holdings',
|
|
256
1319
|
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.",
|
|
1320
|
+
"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.",
|
|
1321
|
+
summary: 'What a large institutional investor holds, from its SEC Form 13F filing.',
|
|
258
1322
|
inputSchema: {
|
|
259
1323
|
type: 'object' as const,
|
|
260
1324
|
properties: {
|
|
261
1325
|
ticker_or_cik: {
|
|
262
1326
|
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.',
|
|
1327
|
+
description: 'REQUIRED (or one of its aliases `cik` / `ticker`). The institutional manager\'s ticker (e.g. "BRK-B") or CIK (e.g. "1067983"). NOT the held stock — the fund/manager doing the filing.',
|
|
264
1328
|
},
|
|
265
1329
|
limit: { type: 'number', description: 'Top N holdings by value to return (1-100, default 25)' },
|
|
1330
|
+
cik: {
|
|
1331
|
+
type: 'string',
|
|
1332
|
+
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.',
|
|
1333
|
+
},
|
|
1334
|
+
ticker: {
|
|
1335
|
+
type: 'string',
|
|
1336
|
+
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.',
|
|
1337
|
+
},
|
|
266
1338
|
},
|
|
267
|
-
|
|
1339
|
+
// Deliberately empty, with the check done in the handler instead. A flat
|
|
1340
|
+
// `required` list cannot express "ticker_or_cik OR cik OR ticker", so
|
|
1341
|
+
// declaring the aliases above and keeping the canonical name required
|
|
1342
|
+
// would reject every call that used one — the gateway's pre-flight
|
|
1343
|
+
// refuses before the pack ever runs (fleet #2058, swept in #2069).
|
|
1344
|
+
required: [],
|
|
1345
|
+
|
|
268
1346
|
},
|
|
269
1347
|
},
|
|
270
1348
|
{
|
|
271
1349
|
name: 'edgar_fund_holdings',
|
|
272
1350
|
description:
|
|
273
1351
|
"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.",
|
|
1352
|
+
summary: 'What a US ETF or mutual fund owns, from its monthly SEC Form N-PORT filing.',
|
|
274
1353
|
inputSchema: {
|
|
275
1354
|
type: 'object' as const,
|
|
276
1355
|
properties: {
|
|
277
|
-
ticker: { type: 'string', description: 'ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
|
|
1356
|
+
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.' },
|
|
278
1357
|
limit: { type: 'number', description: 'Top N holdings by weight to return (1-100, default 25)' },
|
|
1358
|
+
ticker_or_cik: {
|
|
1359
|
+
type: 'string',
|
|
1360
|
+
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.',
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
// Deliberately empty, with the check done in fundHoldings() instead —
|
|
1364
|
+
// a flat `required` list cannot express "ticker OR ticker_or_cik", so
|
|
1365
|
+
// keeping `ticker` required would reject every aliased call at the
|
|
1366
|
+
// gateway's pre-flight before the pack ran (fleet #2069).
|
|
1367
|
+
required: [],
|
|
1368
|
+
},
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
name: 'edgar_company_snapshot',
|
|
1372
|
+
description:
|
|
1373
|
+
'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.',
|
|
1374
|
+
summary: 'A US public company\'s SEC identity, recent filings and headline financials in one call.',
|
|
1375
|
+
inputSchema: {
|
|
1376
|
+
type: 'object' as const,
|
|
1377
|
+
properties: {
|
|
1378
|
+
ticker_or_cik: {
|
|
1379
|
+
type: 'string',
|
|
1380
|
+
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.',
|
|
1381
|
+
},
|
|
1382
|
+
cik: {
|
|
1383
|
+
type: 'string',
|
|
1384
|
+
description: 'Alias for `ticker_or_cik` — same thing. The spelling edgar_company_facts and edgar_company_concept use.',
|
|
1385
|
+
},
|
|
1386
|
+
ticker: {
|
|
1387
|
+
type: 'string',
|
|
1388
|
+
description: 'Alias for `ticker_or_cik` — same thing. The spelling edgar_ticker_to_cik uses.',
|
|
1389
|
+
},
|
|
1390
|
+
form_type: {
|
|
1391
|
+
type: 'string',
|
|
1392
|
+
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.',
|
|
1393
|
+
},
|
|
1394
|
+
filings_limit: {
|
|
1395
|
+
type: 'number',
|
|
1396
|
+
description: 'How many filings to return after the form filter (1-40, default 10).',
|
|
1397
|
+
},
|
|
279
1398
|
},
|
|
280
|
-
required
|
|
1399
|
+
// Deliberately empty — a flat `required` list cannot express
|
|
1400
|
+
// "ticker_or_cik OR cik OR ticker"; the handler refuses with a message
|
|
1401
|
+
// that names all three (fleet #2058 / #2069).
|
|
1402
|
+
required: [],
|
|
281
1403
|
},
|
|
282
1404
|
},
|
|
283
1405
|
{
|
|
284
1406
|
name: 'edgar_ticker_to_cik',
|
|
285
1407
|
description:
|
|
286
|
-
'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives` for disambiguation. Cheap, no rate limit concerns. Most other tools also accept tickers/names directly and call this internally — only use it explicitly when you want the CIK as data.',
|
|
1408
|
+
'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.',
|
|
1409
|
+
summary: 'The SEC CIK number for a stock ticker or company name, from EDGAR\'s company list.',
|
|
287
1410
|
inputSchema: {
|
|
288
1411
|
type: 'object' as const,
|
|
289
1412
|
properties: {
|
|
290
1413
|
ticker: {
|
|
291
1414
|
type: 'string',
|
|
292
|
-
description: 'Stock ticker symbol (e.g., "AAPL", "MSFT", "TSLA") or company name (e.g., "Apple", "Microsoft")',
|
|
1415
|
+
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")',
|
|
293
1416
|
},
|
|
1417
|
+
ticker_or_cik: {
|
|
1418
|
+
type: 'string',
|
|
1419
|
+
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.',
|
|
1420
|
+
},
|
|
1421
|
+
company: {
|
|
1422
|
+
type: 'string',
|
|
1423
|
+
description: 'Alias for `ticker` — use it when what you have is a company NAME ("Apple Inc.") rather than a symbol. Same argument, same behaviour.',
|
|
1424
|
+
},
|
|
1425
|
+
},
|
|
1426
|
+
// Deliberately empty, with the check done in tickerToCik() instead —
|
|
1427
|
+
// a flat `required` list cannot express "ticker OR ticker_or_cik OR
|
|
1428
|
+
// company" (fleet #2069).
|
|
1429
|
+
required: [],
|
|
1430
|
+
},
|
|
1431
|
+
},
|
|
1432
|
+
{
|
|
1433
|
+
name: 'sponsor_to_filer',
|
|
1434
|
+
description:
|
|
1435
|
+
'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.',
|
|
1436
|
+
summary: 'The SEC filer entities linked to a plan sponsor, from EDGAR Form 5500 filings.',
|
|
1437
|
+
inputSchema: {
|
|
1438
|
+
type: 'object' as const,
|
|
1439
|
+
properties: {
|
|
1440
|
+
sponsor: {
|
|
1441
|
+
type: 'string',
|
|
1442
|
+
description: 'Organization name to resolve — a trial sponsor, drug developer, or company name, e.g. "Merck Sharp and Dohme", "Lexeo Therapeutics", "Dizal Pharmaceuticals".',
|
|
1443
|
+
},
|
|
1444
|
+
},
|
|
1445
|
+
required: ['sponsor'],
|
|
1446
|
+
},
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
name: 'filer_to_sponsors',
|
|
1450
|
+
description:
|
|
1451
|
+
'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.',
|
|
1452
|
+
summary: 'The plan sponsors linked to an SEC filer entity, from EDGAR Form 5500 filings.',
|
|
1453
|
+
inputSchema: {
|
|
1454
|
+
type: 'object' as const,
|
|
1455
|
+
properties: {
|
|
1456
|
+
ticker_or_cik: {
|
|
1457
|
+
type: 'string',
|
|
1458
|
+
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.',
|
|
1459
|
+
},
|
|
1460
|
+
name_filter: {
|
|
1461
|
+
type: 'string',
|
|
1462
|
+
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".',
|
|
1463
|
+
},
|
|
1464
|
+
limit: { type: 'number', description: 'Max subsidiaries to return (1-500, default 200). Large parents can disclose 500+.' },
|
|
1465
|
+
cik: {
|
|
1466
|
+
type: 'string',
|
|
1467
|
+
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.',
|
|
1468
|
+
},
|
|
1469
|
+
ticker: {
|
|
1470
|
+
type: 'string',
|
|
1471
|
+
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.',
|
|
1472
|
+
},
|
|
1473
|
+
},
|
|
1474
|
+
// Deliberately empty, with the check done in the handler instead. A flat
|
|
1475
|
+
// `required` list cannot express "ticker_or_cik OR cik OR ticker", so
|
|
1476
|
+
// declaring the aliases above and keeping the canonical name required
|
|
1477
|
+
// would reject every call that used one — the gateway's pre-flight
|
|
1478
|
+
// refuses before the pack ever runs (fleet #2058, swept in #2069).
|
|
1479
|
+
required: [],
|
|
1480
|
+
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
{
|
|
1484
|
+
name: 'edgar_xbrl_frames',
|
|
1485
|
+
description:
|
|
1486
|
+
'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.',
|
|
1487
|
+
summary: 'One XBRL financial concept\'s reported value across every US public company for one period, from EDGAR.',
|
|
1488
|
+
inputSchema: {
|
|
1489
|
+
type: 'object' as const,
|
|
1490
|
+
properties: {
|
|
1491
|
+
concept: { type: 'string', description: 'US-GAAP (or dei) tag, e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense".' },
|
|
1492
|
+
period: { type: 'string', description: 'Calendar frame: "CY2023" (annual duration), "CY2023Q1" (quarterly duration), or "CY2023Q1I" (instant, balance-sheet items at period end).' },
|
|
1493
|
+
unit: { type: 'string', description: 'Unit of measure (default "USD"). Use "shares" for share counts, "USD-per-shares" for per-share.' },
|
|
1494
|
+
taxonomy: { type: 'string', description: 'Taxonomy: "us-gaap" (default) or "dei".' },
|
|
1495
|
+
sort: { type: 'string', description: '"desc" (default, largest first) or "asc".', enum: ['desc', 'asc'] },
|
|
1496
|
+
limit: { type: 'number', description: 'Max companies to return (1-200, default 25).' },
|
|
1497
|
+
},
|
|
1498
|
+
required: ['concept', 'period'],
|
|
1499
|
+
},
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
name: 'edgar_companies_by_sic',
|
|
1503
|
+
description:
|
|
1504
|
+
'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.',
|
|
1505
|
+
summary: 'Every SEC filer classified under one industry code — a company\'s public peers.',
|
|
1506
|
+
inputSchema: {
|
|
1507
|
+
type: 'object' as const,
|
|
1508
|
+
properties: {
|
|
1509
|
+
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.' },
|
|
1510
|
+
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`.' },
|
|
1511
|
+
cik: { type: 'string', description: 'Alias for `ticker_or_cik` — takes a ticker or a CIK. Declared because sibling SEC tools spell this argument differently.' },
|
|
1512
|
+
ticker: { type: 'string', description: 'Alias for `ticker_or_cik` — takes a ticker or a CIK. Declared because sibling SEC tools spell this argument differently.' },
|
|
1513
|
+
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.' },
|
|
1514
|
+
exclude_self: { type: 'boolean', description: 'When resolving via ticker_or_cik, exclude that company itself from the peer list. Default true.' },
|
|
1515
|
+
limit: { type: 'number', description: 'Max peers to return (1-100, default 25).' },
|
|
294
1516
|
},
|
|
295
|
-
required: ['ticker'],
|
|
296
1517
|
},
|
|
297
1518
|
},
|
|
298
1519
|
{
|
|
299
|
-
name: '
|
|
1520
|
+
name: 'edgar_product_revenue',
|
|
300
1521
|
description:
|
|
301
|
-
'
|
|
1522
|
+
'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.',
|
|
1523
|
+
summary: 'A US public company\'s revenue broken out by product or segment, from its EDGAR XBRL filings.',
|
|
302
1524
|
inputSchema: {
|
|
303
1525
|
type: 'object' as const,
|
|
304
1526
|
properties: {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1527
|
+
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.' },
|
|
1528
|
+
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.' },
|
|
1529
|
+
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`.' },
|
|
1530
|
+
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.' },
|
|
1531
|
+
limit: { type: 'number', description: 'Max rows to return (1-200, default 100).' },
|
|
1532
|
+
cik: {
|
|
1533
|
+
type: 'string',
|
|
1534
|
+
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.',
|
|
1535
|
+
},
|
|
1536
|
+
ticker: {
|
|
1537
|
+
type: 'string',
|
|
1538
|
+
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.',
|
|
1539
|
+
},
|
|
311
1540
|
},
|
|
312
|
-
|
|
1541
|
+
// Deliberately empty, with the check done in the handler instead. A flat
|
|
1542
|
+
// `required` list cannot express "ticker_or_cik OR cik OR ticker", so
|
|
1543
|
+
// declaring the aliases above and keeping the canonical name required
|
|
1544
|
+
// would reject every call that used one — the gateway's pre-flight
|
|
1545
|
+
// refuses before the pack ever runs (fleet #2058, swept in #2069).
|
|
1546
|
+
required: [],
|
|
1547
|
+
|
|
313
1548
|
},
|
|
314
1549
|
},
|
|
315
1550
|
{
|
|
316
1551
|
name: 'edgar_filing_documents',
|
|
317
1552
|
description:
|
|
318
1553
|
'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.',
|
|
1554
|
+
summary: 'The documents and attachments inside one SEC filing, by accession number.',
|
|
319
1555
|
inputSchema: {
|
|
320
1556
|
type: 'object' as const,
|
|
321
1557
|
properties: {
|
|
@@ -337,7 +1573,72 @@ const tools: McpToolExport['tools'] = [
|
|
|
337
1573
|
},
|
|
338
1574
|
include_primary_text: {
|
|
339
1575
|
type: 'boolean',
|
|
340
|
-
description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false.',
|
|
1576
|
+
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.',
|
|
1577
|
+
},
|
|
1578
|
+
ticker_or_cik: {
|
|
1579
|
+
type: 'string',
|
|
1580
|
+
description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_company_filings and edgar_insider_transactions use. Takes either a ticker or a CIK.',
|
|
1581
|
+
},
|
|
1582
|
+
},
|
|
1583
|
+
required: [],
|
|
1584
|
+
},
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
name: 'edgar_filing_text',
|
|
1588
|
+
description:
|
|
1589
|
+
'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, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). 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. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events); `search` runs within that slice when both are given. Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled, when `search` is not used: 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 how far `search` can scan) and can make a late section or search term 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", "how many tests did $TICKER process this quarter". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
|
|
1590
|
+
summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
|
|
1591
|
+
inputSchema: {
|
|
1592
|
+
type: 'object' as const,
|
|
1593
|
+
properties: {
|
|
1594
|
+
accession: {
|
|
1595
|
+
type: 'string',
|
|
1596
|
+
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.',
|
|
1597
|
+
},
|
|
1598
|
+
ticker: {
|
|
1599
|
+
type: 'string',
|
|
1600
|
+
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.',
|
|
1601
|
+
},
|
|
1602
|
+
cik: {
|
|
1603
|
+
type: 'string',
|
|
1604
|
+
description: 'Filer CIK number (e.g. "1652935"). Provide this OR ticker.',
|
|
1605
|
+
},
|
|
1606
|
+
form_type: {
|
|
1607
|
+
type: 'string',
|
|
1608
|
+
description: 'When accession is omitted, the form type of the latest filing to fetch — "10-K", "10-Q", "8-K", "DEF 14A", etc. A question asking for the "most recent 10-K OR 10-Q" (or otherwise not committed to one type) should OMIT this field entirely rather than guess "10-K" by habit — omitting form_type (along with accession) returns the single most recent filing of ANY type, and a 10-Q is very often more recent than the last 10-K since it files quarterly while the 10-K only files once a year (verified live 2026-09-25, fleet #2450: NTRA\'s most recent 10-Q was filed 2026-08-07, five months after its 2026-02-27 10-K — passing form_type:"10-K" here silently skips the newer filing and the KPI it asked about).',
|
|
1609
|
+
},
|
|
1610
|
+
section: {
|
|
1611
|
+
type: 'string',
|
|
1612
|
+
enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events'],
|
|
1613
|
+
description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false).',
|
|
1614
|
+
},
|
|
1615
|
+
max_chars: {
|
|
1616
|
+
type: 'number',
|
|
1617
|
+
description: 'Max characters to return in this page (1000–100000, default 50000). Doc text past this is available via next_offset.',
|
|
1618
|
+
},
|
|
1619
|
+
offset: {
|
|
1620
|
+
type: 'number',
|
|
1621
|
+
description: 'Character offset to start from (default 0). Pass the prior result\'s next_offset to page forward.',
|
|
1622
|
+
},
|
|
1623
|
+
search: {
|
|
1624
|
+
type: 'string',
|
|
1625
|
+
description: 'Find a specific fact instead of paging blind. This is a SUBSTRING match, not a relevance search — pass the exact 2-4 word phrase most likely to appear VERBATIM in the prose ("tests processed", "processed approximately", "going concern"), never a compound of every concept in the question. A phrase that ANDs several unrelated question-words together ("oncology Signatera revenue test volume") returns ZERO matches even in the CORRECT filing, because the filing\'s own sentence never contains all of those words together — verified live 2026-09-25 (fleet #2450) on a real Natera 10-Q that DOES report the exact number asked for: "Signatera revenue" and the 4-word compound above both found nothing, while the filing\'s own wording ("processed approximately", "tests processed") is what actually appears. When unsure of the filing\'s exact phrasing, prefer the SHORTEST distinctive 2-3 word fragment of the metric name itself (a unit, a verb+noun like "processed approximately") over restating the question. Case-insensitive substring match over the WHOLE document text (or the whole `section` slice, if both are given), run BEFORE max_chars/offset windowing. Returns every matching passage (surrounding context + its own offset in the document) instead of the normal paged `text` — use the returned offsets with a follow-up call (no `search`, `offset` set to one of them) if you need more surrounding text than the passage gives. Zero matches means try again with a SHORTER, more literal phrase before concluding the filing does not disclose it — this is a real answer about wording, not a tool failure. Accepted aliases: `contains`, `find`, `phrase`.',
|
|
1626
|
+
},
|
|
1627
|
+
contains: {
|
|
1628
|
+
type: 'string',
|
|
1629
|
+
description: 'Alias for `search`.',
|
|
1630
|
+
},
|
|
1631
|
+
find: {
|
|
1632
|
+
type: 'string',
|
|
1633
|
+
description: 'Alias for `search`.',
|
|
1634
|
+
},
|
|
1635
|
+
phrase: {
|
|
1636
|
+
type: 'string',
|
|
1637
|
+
description: 'Alias for `search`.',
|
|
1638
|
+
},
|
|
1639
|
+
ticker_or_cik: {
|
|
1640
|
+
type: 'string',
|
|
1641
|
+
description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_company_filings and edgar_insider_transactions use. Takes either a ticker or a CIK.',
|
|
341
1642
|
},
|
|
342
1643
|
},
|
|
343
1644
|
required: [],
|
|
@@ -374,21 +1675,25 @@ async function searchFilings(
|
|
|
374
1675
|
if (endDate) params.set('enddt', endDate);
|
|
375
1676
|
}
|
|
376
1677
|
|
|
377
|
-
const res = await
|
|
378
|
-
if (!res.ok) throw
|
|
1678
|
+
const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
|
|
1679
|
+
if (!res.ok) throw await httpError(res, 'SEC EDGAR search error');
|
|
379
1680
|
|
|
380
1681
|
const data = (await res.json()) as {
|
|
381
1682
|
hits: {
|
|
382
1683
|
hits: {
|
|
383
1684
|
_source: {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1685
|
+
ciks?: string[];
|
|
1686
|
+
display_names?: string[];
|
|
1687
|
+
form?: string;
|
|
1688
|
+
file_type?: string;
|
|
1689
|
+
root_forms?: string[];
|
|
1690
|
+
file_date?: string;
|
|
1691
|
+
period_ending?: string;
|
|
1692
|
+
biz_locations?: string[];
|
|
1693
|
+
biz_states?: string[];
|
|
1694
|
+
adsh?: string;
|
|
1695
|
+
items?: string[];
|
|
1696
|
+
file_description?: string;
|
|
392
1697
|
};
|
|
393
1698
|
_id: string;
|
|
394
1699
|
}[];
|
|
@@ -396,15 +1701,30 @@ async function searchFilings(
|
|
|
396
1701
|
};
|
|
397
1702
|
};
|
|
398
1703
|
|
|
1704
|
+
// EFTS `_source` field names (verified against efts.sec.gov): display_names
|
|
1705
|
+
// ["Name (TICKER) (CIK 000...)"], ciks[], form, period_ending, biz_locations[],
|
|
1706
|
+
// adsh (accession), and — for 8-Ks — items[] (e.g. "3.01"). The prior mapper
|
|
1707
|
+
// read entity_name/form_type/biz_location, which do NOT exist in this payload,
|
|
1708
|
+
// so every field but the date/id serialized as undefined and dropped.
|
|
399
1709
|
const results = (data.hits?.hits ?? []).map((hit) => {
|
|
400
1710
|
const s = hit._source;
|
|
1711
|
+
const display = s.display_names?.[0] ?? '';
|
|
1712
|
+
// display_names is "NAME (TICKER) (CIK 000...)" (double-space before each
|
|
1713
|
+
// paren group; TICKER paren is absent for non-tickered filers). Take the name
|
|
1714
|
+
// before the first double-space-paren so both shapes strip cleanly.
|
|
1715
|
+
const entityName = display.split(/\s{2,}\(/)[0].trim() || display;
|
|
1716
|
+
const cik = s.ciks?.[0] ? String(parseInt(s.ciks[0], 10)) : undefined;
|
|
401
1717
|
return {
|
|
402
|
-
entity_name:
|
|
403
|
-
cik
|
|
404
|
-
form_type: s.
|
|
1718
|
+
entity_name: entityName || undefined,
|
|
1719
|
+
cik,
|
|
1720
|
+
form_type: s.form ?? s.file_type ?? s.root_forms?.[0],
|
|
1721
|
+
// 8-K item codes — the signal (3.01 delisting vs 1.01 agreement). Empty for non-8-Ks.
|
|
1722
|
+
items: s.items && s.items.length ? s.items : undefined,
|
|
405
1723
|
filing_date: s.file_date,
|
|
406
|
-
period_of_report: s.
|
|
407
|
-
location: s.
|
|
1724
|
+
period_of_report: s.period_ending,
|
|
1725
|
+
location: s.biz_locations?.[0] ?? s.biz_states?.[0],
|
|
1726
|
+
// Accession — feed straight into edgar_filing_documents/edgar_filing_text, no second lookup.
|
|
1727
|
+
accession: s.adsh,
|
|
408
1728
|
filing_id: hit._id,
|
|
409
1729
|
};
|
|
410
1730
|
});
|
|
@@ -422,16 +1742,319 @@ async function searchFilings(
|
|
|
422
1742
|
// identity. Delegates to the shared resolver so the ticker/name-resolution logic
|
|
423
1743
|
// stays consistent across every SEC-keyed pack (edgar, sec).
|
|
424
1744
|
async function tickerToCik(ticker: string) {
|
|
425
|
-
|
|
1745
|
+
if (typeof ticker !== 'string' || !ticker.trim()) {
|
|
1746
|
+
throw new Error(
|
|
1747
|
+
'A company is required: pass `ticker`, or its aliases `ticker_or_cik` / `company` — ' +
|
|
1748
|
+
'all three take a stock ticker ("AAPL") or a company name ("Apple Inc."). ' +
|
|
1749
|
+
'Example: edgar_ticker_to_cik({ticker: "AAPL"}).'
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
let entity: Awaited<ReturnType<typeof resolveSecEntity>> | FilerNameMatch;
|
|
1753
|
+
try {
|
|
1754
|
+
entity = await resolveSecEntity(ticker, { headers: SEC_HEADERS });
|
|
1755
|
+
} catch (err) {
|
|
1756
|
+
if (!/No SEC company matches/.test(String((err as Error)?.message))) throw err;
|
|
1757
|
+
entity = await resolveAnyFilerByName(ticker, err as Error);
|
|
1758
|
+
}
|
|
1759
|
+
// Measured (docs/edgar-cohort-retention-2026-09-17.md, fleet #2182): 66% of
|
|
1760
|
+
// one-shot EDGAR callers call this tool and nothing else — they resolved a
|
|
1761
|
+
// ticker and had no signal about what to do next — while 90%+ of depth
|
|
1762
|
+
// callers go on to chain edgar_company_filings + edgar_company_concept by
|
|
1763
|
+
// hand. Name the one-call path in the response, where a direct-tool caller
|
|
1764
|
+
// actually sees it (a meta-tool's description is invisible to them).
|
|
1765
|
+
return {
|
|
1766
|
+
...entity,
|
|
1767
|
+
next: {
|
|
1768
|
+
tool: 'edgar_company_snapshot',
|
|
1769
|
+
args: { ticker_or_cik: entity.cik },
|
|
1770
|
+
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.',
|
|
1771
|
+
},
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// --- delisted / non-listed filers by name (fleet #2305) ---------------------
|
|
1776
|
+
// company_tickers.json lists only CURRENTLY-listed companies, so a bankrupt or
|
|
1777
|
+
// delisted filer — exactly the ones people research — resolved to "No SEC
|
|
1778
|
+
// company matches" by name even though EDGAR holds all its filings under a
|
|
1779
|
+
// CIK (Amyris, CIK 1365916, tickers: []). EDGAR's own entity typeahead
|
|
1780
|
+
// (efts search-index, the box on efts full-text search) covers every filer.
|
|
1781
|
+
// Accept a hit only when its distinctive name tokens EQUAL the query's: a
|
|
1782
|
+
// looser match would hand back a different entity ("WNA Private Equity Fund
|
|
1783
|
+
// B-Amyris LLC" for "Amyris") as a clean success.
|
|
1784
|
+
|
|
1785
|
+
interface FilerNameMatch {
|
|
1786
|
+
ticker: null;
|
|
1787
|
+
cik: string;
|
|
1788
|
+
cik_padded: string;
|
|
1789
|
+
company_name: string;
|
|
1790
|
+
matched_by: 'edgar_entity_search';
|
|
1791
|
+
note: string;
|
|
1792
|
+
alternatives?: { company_name: string; cik: string }[];
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
async function resolveAnyFilerByName(name: string, original: Error): Promise<FilerNameMatch> {
|
|
1796
|
+
const q = name.trim();
|
|
1797
|
+
const url = `https://efts.sec.gov/LATEST/search-index?keysTyped=${encodeURIComponent(q)}`;
|
|
1798
|
+
const res = await pwFetch(url, { headers: SEC_HEADERS }).catch(() => null);
|
|
1799
|
+
if (!res || !res.ok) throw original;
|
|
1800
|
+
const data = (await res.json().catch(() => null)) as
|
|
1801
|
+
| { hits?: { hits?: { _id: string; _source?: { entity?: string; rank?: number } }[] } }
|
|
1802
|
+
| null;
|
|
1803
|
+
const hits = (data?.hits?.hits ?? [])
|
|
1804
|
+
.filter((h) => /^\d+$/.test(h._id) && h._source?.entity)
|
|
1805
|
+
.map((h) => ({ cik: String(Number(h._id)), company_name: h._source!.entity!, rank: h._source!.rank ?? 0 }));
|
|
1806
|
+
const key = (s: string) => [...new Set(significantTokens(s))].sort().join(' ');
|
|
1807
|
+
const want = key(q);
|
|
1808
|
+
const exact = want ? hits.filter((h) => key(h.company_name) === want).sort((a, b) => b.rank - a.rank) : [];
|
|
1809
|
+
if (!exact.length) {
|
|
1810
|
+
const seen = hits.slice(0, 5).map((h) => `${h.company_name} (CIK ${h.cik})`).join('; ');
|
|
1811
|
+
throw new Error(
|
|
1812
|
+
`${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.` : ''} ` +
|
|
1813
|
+
'A delisted company\'s OLD ticker cannot be resolved (SEC keeps no ticker history) — pass the company name or CIK instead.'
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
const best = exact[0];
|
|
1817
|
+
const alts = exact.slice(1, 4).map((h) => ({ company_name: h.company_name, cik: h.cik }));
|
|
1818
|
+
return {
|
|
1819
|
+
ticker: null,
|
|
1820
|
+
cik: best.cik,
|
|
1821
|
+
cik_padded: best.cik.padStart(10, '0'),
|
|
1822
|
+
company_name: best.company_name,
|
|
1823
|
+
matched_by: 'edgar_entity_search',
|
|
1824
|
+
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.',
|
|
1825
|
+
...(alts.length ? { alternatives: alts } : {}),
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// --- sponsor_to_filer -------------------------------------------------------
|
|
1830
|
+
// Resolve a sponsor/subsidiary NAME to the US-listed filer that reports it.
|
|
1831
|
+
// The hard case: trial registries name operating subsidiaries ("Merck Sharp
|
|
1832
|
+
// and Dohme"); SEC names the listed parent ("Merck & Co", MRK). We also refuse
|
|
1833
|
+
// to collapse every miss to "not found" — the status distinguishes listed /
|
|
1834
|
+
// subsidiary-of-listed / SEC-registered-but-unlisted / no-US-registrant.
|
|
1835
|
+
|
|
1836
|
+
interface SecTickerRow { cik_str: number; ticker: string; title: string }
|
|
1837
|
+
|
|
1838
|
+
// Generic corporate/industry words that don't distinguish one biotech from
|
|
1839
|
+
// another — excluded when picking a sponsor's "distinctive" token.
|
|
1840
|
+
const GENERIC_NAME_TOKENS = new Set([
|
|
1841
|
+
'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED',
|
|
1842
|
+
'LLC', 'LP', 'PLC', 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
|
|
1843
|
+
'PHARMACEUTICALS', 'PHARMACEUTICAL', 'PHARMA', 'THERAPEUTICS', 'THERAPEUTIC',
|
|
1844
|
+
'BIOSCIENCES', 'BIOSCIENCE', 'BIO', 'BIOPHARMA', 'BIOPHARMACEUTICALS',
|
|
1845
|
+
'BIOTECH', 'BIOTECHNOLOGY', 'MEDICAL', 'MEDICINE', 'MEDICINES', 'SCIENCES',
|
|
1846
|
+
'SCIENCE', 'HEALTH', 'HEALTHCARE', 'GROUP', 'HOLDINGS', 'HOLDING',
|
|
1847
|
+
'LABORATORIES', 'LABS', 'AND', 'OF', 'US', 'USA', 'INTERNATIONAL', 'GLOBAL',
|
|
1848
|
+
]);
|
|
1849
|
+
|
|
1850
|
+
const normName = (s: string): string =>
|
|
1851
|
+
s.toUpperCase().replace(/[.,&/()-]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1852
|
+
|
|
1853
|
+
function significantTokens(name: string): string[] {
|
|
1854
|
+
return normName(name).split(' ').filter((t) => t.length > 1 && !GENERIC_NAME_TOKENS.has(t));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function distinctiveToken(name: string): string {
|
|
1858
|
+
return significantTokens(name)[0] ?? normName(name).split(' ')[0] ?? '';
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// SEC company names use "&", not "and". Produce query variants: &-normalized
|
|
1862
|
+
// full name first, then progressively drop trailing generic words (browse-edgar
|
|
1863
|
+
// matches on a name prefix and a too-specific full name can return nothing).
|
|
1864
|
+
function secQueryVariants(name: string): string[] {
|
|
1865
|
+
const amp = name.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
|
|
1866
|
+
const out = [amp];
|
|
1867
|
+
const words = amp.split(' ');
|
|
1868
|
+
for (let end = words.length; end > 1; end--) {
|
|
1869
|
+
const last = words[end - 1].toUpperCase().replace(/[.,&]/g, '');
|
|
1870
|
+
if (GENERIC_NAME_TOKENS.has(last)) out.push(words.slice(0, end - 1).join(' '));
|
|
1871
|
+
else break;
|
|
1872
|
+
}
|
|
1873
|
+
return [...new Set(out.filter(Boolean))];
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
async function fetchCompanyTickers(): Promise<SecTickerRow[]> {
|
|
1877
|
+
const res = await pwFetch('https://www.sec.gov/files/company_tickers.json', { headers: SEC_HEADERS });
|
|
1878
|
+
if (!res.ok) throw await httpError(res, 'SEC ticker lookup error');
|
|
1879
|
+
const data = (await res.json()) as Record<string, SecTickerRow>;
|
|
1880
|
+
return Object.values(data);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Does the sponsor name have its OWN SEC filer CIK (registered entity — a
|
|
1884
|
+
* subsidiary, or a private co that filed a Form D / draft S-1)? browse-edgar
|
|
1885
|
+
* orders by relevance so the leading <cik> tags are the closest matches; each
|
|
1886
|
+
* candidate is validated against its real name from the submissions API
|
|
1887
|
+
* (which doubles as the entity profile). Returns {cik, profile} or null. */
|
|
1888
|
+
async function edgarOwnCik(sponsor: string): Promise<{ cik: string; profile: EntityProfile } | null> {
|
|
1889
|
+
const sig = significantTokens(sponsor);
|
|
1890
|
+
const need = Math.min(2, sig.length || 1);
|
|
1891
|
+
for (const q of secQueryVariants(sponsor)) {
|
|
1892
|
+
const url = `https://www.sec.gov/cgi-bin/browse-edgar?company=${encodeURIComponent(q)}&action=getcompany&type=&dateb=&owner=include&count=10&output=atom`;
|
|
1893
|
+
const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } }).catch(() => null);
|
|
1894
|
+
if (!res || !res.ok) continue;
|
|
1895
|
+
const xml = await res.text();
|
|
1896
|
+
const ciks = [...xml.matchAll(/<cik>(\d+)<\/cik>/gi)].map((m) => String(Number(m[1])));
|
|
1897
|
+
for (const cik of [...new Set(ciks)].slice(0, 3)) {
|
|
1898
|
+
const profile = await edgarEntityProfile(cik);
|
|
1899
|
+
if (!profile?.name) continue;
|
|
1900
|
+
const nameToks = new Set(significantTokens(profile.name));
|
|
1901
|
+
const overlap = sig.filter((t) => nameToks.has(t)).length;
|
|
1902
|
+
if (overlap >= need) return { cik, profile };
|
|
1903
|
+
}
|
|
1904
|
+
if (ciks.length) break; // first variant that returned candidates is authoritative
|
|
1905
|
+
}
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
interface ListedFiler { name: string; ticker: string; cik: string; hits: number }
|
|
1910
|
+
|
|
1911
|
+
/** EDGAR full-text: LISTED filers (display_name carries a ticker) whose 10-Ks
|
|
1912
|
+
* mention the sponsor, ranked by mention count. */
|
|
1913
|
+
async function fullTextListedFilers(sponsor: string): Promise<{ candidates: ListedFiler[]; total: number }> {
|
|
1914
|
+
// SEC filings write "&", not "and" — normalize so "Merck Sharp and Dohme"
|
|
1915
|
+
// matches the same volume as "Merck Sharp & Dohme".
|
|
1916
|
+
const phrase = sponsor.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
|
|
1917
|
+
const params = new URLSearchParams({ q: `"${phrase}"`, forms: '10-K', from: '0', size: '100' });
|
|
1918
|
+
const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
|
|
1919
|
+
if (!res.ok) return { candidates: [], total: 0 };
|
|
1920
|
+
const data = (await res.json()) as {
|
|
1921
|
+
hits?: { total?: { value?: number }; hits?: { _source?: { display_names?: string[] } }[] };
|
|
1922
|
+
};
|
|
1923
|
+
const byCik = new Map<string, ListedFiler>();
|
|
1924
|
+
for (const h of data.hits?.hits ?? []) {
|
|
1925
|
+
for (const dn of h._source?.display_names ?? []) {
|
|
1926
|
+
// "Merck & Co. Inc. (MRK) (CIK 0000310158)" — parse name, ticker?, cik.
|
|
1927
|
+
const parens = [...dn.matchAll(/\(([^)]+)\)/g)].map((m) => m[1].trim());
|
|
1928
|
+
const cikPart = parens.find((p) => /^CIK\s*\d+/i.test(p));
|
|
1929
|
+
const cik = cikPart ? String(Number(cikPart.replace(/CIK\s*/i, ''))) : '';
|
|
1930
|
+
const ticker = parens.find((p) => /^[A-Z][A-Z.]{0,5}$/.test(p) && !/^CIK/i.test(p));
|
|
1931
|
+
if (!cik || !ticker) continue; // listed filers only
|
|
1932
|
+
const name = dn.slice(0, dn.indexOf('(')).trim();
|
|
1933
|
+
const cur = byCik.get(cik);
|
|
1934
|
+
if (cur) cur.hits += 1;
|
|
1935
|
+
else byCik.set(cik, { name, ticker, cik, hits: 1 });
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const candidates = [...byCik.values()].sort((a, b) => b.hits - a.hits);
|
|
1939
|
+
return { candidates, total: data.hits?.total?.value ?? 0 };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
interface EntityProfile { name?: string; state?: string; recent_forms?: string[] }
|
|
1943
|
+
|
|
1944
|
+
/** Best-effort entity profile for a bare CIK — forms filed + state, to explain
|
|
1945
|
+
* an "unlisted" verdict (e.g. "only Form D" ⇒ private placement). */
|
|
1946
|
+
async function edgarEntityProfile(cik: string): Promise<EntityProfile | null> {
|
|
1947
|
+
try {
|
|
1948
|
+
const padded = cik.padStart(10, '0');
|
|
1949
|
+
const res = await pwFetch(`${DATA_BASE}/submissions/CIK${padded}.json`, { headers: SEC_HEADERS });
|
|
1950
|
+
if (!res.ok) return null;
|
|
1951
|
+
const d = (await res.json()) as { name?: string; stateOfIncorporation?: string; filings?: { recent?: { form?: string[] } } };
|
|
1952
|
+
const forms = [...new Set(d.filings?.recent?.form ?? [])].slice(0, 8);
|
|
1953
|
+
return { name: d.name, state: d.stateOfIncorporation, recent_forms: forms };
|
|
1954
|
+
} catch {
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function filerOut(r: SecTickerRow) {
|
|
1960
|
+
return { company_name: r.title, ticker: r.ticker, cik: String(r.cik_str), cik_padded: String(r.cik_str).padStart(10, '0') };
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
async function sponsorToFiler(sponsor: string): Promise<unknown> {
|
|
1964
|
+
if (typeof sponsor !== 'string' || !sponsor.trim()) {
|
|
1965
|
+
return {
|
|
1966
|
+
error: 'Required argument "sponsor" is missing or empty.',
|
|
1967
|
+
retry_hint: 'Pass an organization name, e.g. sponsor_to_filer({ sponsor: "Merck Sharp and Dohme" }).',
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
const name = sponsor.trim();
|
|
1971
|
+
const disc = distinctiveToken(name);
|
|
1972
|
+
const tickers = await fetchCompanyTickers();
|
|
1973
|
+
|
|
1974
|
+
// 1) Direct listed match (exact / prefix / whole-word — not loose substring).
|
|
1975
|
+
const ranked = rankMatches(name, tickers, (r) => r.title);
|
|
1976
|
+
if (ranked.length && ranked[0].score >= 2) {
|
|
1977
|
+
const m = ranked[0].item;
|
|
1978
|
+
return {
|
|
1979
|
+
status: 'resolved',
|
|
1980
|
+
method: 'direct',
|
|
1981
|
+
confidence: ranked[0].score >= 3 ? 'high' : 'medium',
|
|
1982
|
+
sponsor: name,
|
|
1983
|
+
filer: filerOut(m),
|
|
1984
|
+
alternatives: ranked.slice(1, 4).map((x) => filerOut(x.item)),
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Own SEC CIK? (registered filer entity — subsidiary or private that filed)
|
|
1989
|
+
const own = await edgarOwnCik(name).catch(() => null);
|
|
1990
|
+
if (own) {
|
|
1991
|
+
const listedSelf = tickers.find((t) => String(t.cik_str) === own.cik);
|
|
1992
|
+
if (listedSelf) {
|
|
1993
|
+
return { status: 'resolved', method: 'direct_cik', confidence: 'high', sponsor: name, filer: filerOut(listedSelf) };
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// 3) Full-text: listed filers referencing the sponsor; confirm a parent by
|
|
1998
|
+
// requiring the sponsor's distinctive token in the filer's name (kills
|
|
1999
|
+
// "merely mentioned by an unrelated filer" false positives).
|
|
2000
|
+
const ft = await fullTextListedFilers(name).catch(() => ({ candidates: [] as ListedFiler[], total: 0 }));
|
|
2001
|
+
const confirmed = ft.candidates.find((c) => normName(c.name).split(' ').includes(disc));
|
|
2002
|
+
if (confirmed) {
|
|
2003
|
+
return {
|
|
2004
|
+
status: 'resolved_via_parent',
|
|
2005
|
+
method: 'subsidiary_fulltext',
|
|
2006
|
+
confidence: confirmed.hits >= 10 ? 'high' : 'medium',
|
|
2007
|
+
sponsor: name,
|
|
2008
|
+
filer: { company_name: confirmed.name, ticker: confirmed.ticker, cik: confirmed.cik, cik_padded: confirmed.cik.padStart(10, '0') },
|
|
2009
|
+
evidence: {
|
|
2010
|
+
parent_mention_filings: confirmed.hits,
|
|
2011
|
+
distinctive_token: disc,
|
|
2012
|
+
own_subsidiary_cik: own?.cik ?? null,
|
|
2013
|
+
own_subsidiary_name: own?.profile?.name ?? null,
|
|
2014
|
+
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.',
|
|
2015
|
+
},
|
|
2016
|
+
alternatives: ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits })),
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// 4) No confirmed parent — classify the miss honestly.
|
|
2021
|
+
const mentions = ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits }));
|
|
2022
|
+
if (own) {
|
|
2023
|
+
return {
|
|
2024
|
+
status: 'us_registrant_unlisted',
|
|
2025
|
+
confidence: 'high',
|
|
2026
|
+
sponsor: name,
|
|
2027
|
+
own_cik: own.cik,
|
|
2028
|
+
likely: 'private',
|
|
2029
|
+
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.`,
|
|
2030
|
+
entity: own.profile,
|
|
2031
|
+
full_text_mentions: mentions,
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
return {
|
|
2035
|
+
status: 'no_us_registrant',
|
|
2036
|
+
confidence: 'high',
|
|
2037
|
+
sponsor: name,
|
|
2038
|
+
likely: 'non_us_listed_or_private',
|
|
2039
|
+
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.`,
|
|
2040
|
+
full_text_mentions: mentions,
|
|
2041
|
+
};
|
|
426
2042
|
}
|
|
427
2043
|
|
|
428
2044
|
async function resolveCik(tickerOrCik: string): Promise<string> {
|
|
429
2045
|
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
2046
|
+
// Names EVERY accepted spelling, because `required` is empty on these
|
|
2047
|
+
// schemas (a flat list cannot say "one of these three") so this is the
|
|
2048
|
+
// only place a caller is told what to send. The words "is required" are
|
|
2049
|
+
// load-bearing: error-class.ts books the refusal as user_error rather
|
|
2050
|
+
// than the `error` bucket that feeds Problem Tools (fleet #2069).
|
|
2051
|
+
// No tool name in the example: resolveCik() is shared by four tools, and a
|
|
2052
|
+
// refusal that says `edgar_company_filings(...)` to a caller of
|
|
2053
|
+
// edgar_company_facts hands it the wrong word to copy — which is the exact
|
|
2054
|
+
// failure this whole change exists to stop.
|
|
430
2055
|
throw new Error(
|
|
431
|
-
'
|
|
432
|
-
'
|
|
433
|
-
'or edgar_company_filings({ticker_or_cik: "320193"}) by CIK number (Apple\'s CIK). ' +
|
|
434
|
-
'Tickers are auto-resolved to CIKs internally.'
|
|
2056
|
+
'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — ' +
|
|
2057
|
+
'all three take a ticker ("AAPL") or a CIK ("320193"), and tickers are auto-resolved.'
|
|
435
2058
|
);
|
|
436
2059
|
}
|
|
437
2060
|
if (isNumericCik(tickerOrCik)) return tickerOrCik.trim();
|
|
@@ -439,62 +2062,110 @@ async function resolveCik(tickerOrCik: string): Promise<string> {
|
|
|
439
2062
|
return result.cik;
|
|
440
2063
|
}
|
|
441
2064
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (
|
|
2065
|
+
/**
|
|
2066
|
+
* A 404 from data.sec.gov/submissions/ means the CIK simply isn't a registrant —
|
|
2067
|
+
* that's a caller mistake, not an EDGAR outage, so say which CIK failed and how to
|
|
2068
|
+
* get a real one instead of surfacing a bare status code.
|
|
2069
|
+
*/
|
|
2070
|
+
function submissionsError(status: number, tickerOrCik: string, paddedCik: string): Error {
|
|
2071
|
+
if (status === 404) {
|
|
2072
|
+
return new Error(
|
|
2073
|
+
`No SEC registrant with CIK ${paddedCik} (resolved from "${tickerOrCik}"). ` +
|
|
2074
|
+
'That CIK does not exist in EDGAR — the company may be private, foreign-listed, or the ' +
|
|
2075
|
+
'identifier may be wrong. Resolve a real one with edgar_ticker_to_cik({ticker: "…"}), ' +
|
|
2076
|
+
'which also accepts company names and returns `alternatives` for disambiguation.'
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
return new Error(`SEC EDGAR submissions error: ${status}`);
|
|
2080
|
+
}
|
|
449
2081
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
};
|
|
2082
|
+
interface SubmissionsPayload {
|
|
2083
|
+
cik: string;
|
|
2084
|
+
name: string;
|
|
2085
|
+
sic: string;
|
|
2086
|
+
sicDescription: string;
|
|
2087
|
+
stateOfIncorporation: string;
|
|
2088
|
+
fiscalYearEnd: string;
|
|
2089
|
+
tickers: string[];
|
|
2090
|
+
filings: {
|
|
2091
|
+
recent: {
|
|
2092
|
+
accessionNumber: string[];
|
|
2093
|
+
filingDate: string[];
|
|
2094
|
+
form: string[];
|
|
2095
|
+
primaryDocument: string[];
|
|
2096
|
+
items: string[];
|
|
2097
|
+
size: number[];
|
|
467
2098
|
};
|
|
468
2099
|
};
|
|
2100
|
+
}
|
|
469
2101
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
2102
|
+
interface FilingRow {
|
|
2103
|
+
accession_number: string;
|
|
2104
|
+
filing_date: string;
|
|
2105
|
+
form: string;
|
|
2106
|
+
items?: string[];
|
|
2107
|
+
primary_document: string;
|
|
2108
|
+
document_url: string;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
async function fetchSubmissions(tickerOrCik: string, paddedCik: string): Promise<SubmissionsPayload> {
|
|
2112
|
+
const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
2113
|
+
if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
|
|
2114
|
+
return (await res.json()) as SubmissionsPayload;
|
|
2115
|
+
}
|
|
478
2116
|
|
|
2117
|
+
/**
|
|
2118
|
+
* Map the submissions payload's column-oriented `recent` block to filing rows.
|
|
2119
|
+
* `keep` decides per form; `count` caps the output after filtering.
|
|
2120
|
+
*/
|
|
2121
|
+
function recentFilingRows(data: SubmissionsPayload, keep: (form: string) => boolean, count: number): FilingRow[] {
|
|
2122
|
+
const recent = data.filings.recent;
|
|
2123
|
+
const filings: FilingRow[] = [];
|
|
479
2124
|
for (let i = 0; i < recent.accessionNumber.length && filings.length < count; i++) {
|
|
480
2125
|
const form = recent.form[i];
|
|
481
|
-
if (
|
|
2126
|
+
if (!keep(form)) continue;
|
|
482
2127
|
|
|
483
2128
|
const accession = recent.accessionNumber[i];
|
|
484
2129
|
const accessionPath = accession.replace(/-/g, '');
|
|
2130
|
+
// submissions payload carries `items` as a comma-joined string per filing,
|
|
2131
|
+
// populated for 8-Ks (e.g. "3.01,9.01"). Split to an array; the item code
|
|
2132
|
+
// (3.01 delisting vs 1.01 agreement) is the signal, so no second fetch needed.
|
|
2133
|
+
const rawItems = recent.items?.[i] ?? '';
|
|
2134
|
+
const items = rawItems
|
|
2135
|
+
? rawItems.split(',').map((s) => s.trim()).filter(Boolean)
|
|
2136
|
+
: undefined;
|
|
485
2137
|
filings.push({
|
|
486
2138
|
accession_number: accession,
|
|
487
2139
|
filing_date: recent.filingDate[i],
|
|
488
2140
|
form,
|
|
2141
|
+
...(items && items.length ? { items } : {}),
|
|
489
2142
|
primary_document: recent.primaryDocument[i],
|
|
490
2143
|
document_url: `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accessionPath}/${recent.primaryDocument[i]}`,
|
|
491
2144
|
});
|
|
492
2145
|
}
|
|
2146
|
+
return filings;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
async function companyFilings(tickerOrCik: string, formType?: string, limit?: number) {
|
|
2150
|
+
const cik = await resolveCik(tickerOrCik);
|
|
2151
|
+
const paddedCik = padCik(cik);
|
|
2152
|
+
const count = Math.min(40, Math.max(1, limit ?? 20));
|
|
2153
|
+
|
|
2154
|
+
const data = await fetchSubmissions(tickerOrCik, paddedCik);
|
|
2155
|
+
const filings = recentFilingRows(data, (form) => !formType || form === formType, count);
|
|
493
2156
|
|
|
494
2157
|
return {
|
|
495
2158
|
cik: data.cik,
|
|
496
2159
|
company_name: data.name,
|
|
497
2160
|
tickers: data.tickers ?? [],
|
|
2161
|
+
// The NUMERIC code as well as the prose (fleet #2124). `sic_description`
|
|
2162
|
+
// was already returned but is free text ("Biological Products, (No
|
|
2163
|
+
// Diagnostic Substances)"), so every consumer that wants to branch on
|
|
2164
|
+
// industry has to pattern-match prose that SEC is free to reword. The code
|
|
2165
|
+
// is the fact. entity_profile uses it to decide whether asking the FDA
|
|
2166
|
+
// Purple Book about this filer can possibly return anything; it is already
|
|
2167
|
+
// in the submissions payload, so this costs no extra request.
|
|
2168
|
+
sic: data.sic,
|
|
498
2169
|
sic_description: data.sicDescription,
|
|
499
2170
|
state_of_incorporation: data.stateOfIncorporation,
|
|
500
2171
|
fiscal_year_end: data.fiscalYearEnd,
|
|
@@ -503,6 +2174,128 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
|
|
|
503
2174
|
};
|
|
504
2175
|
}
|
|
505
2176
|
|
|
2177
|
+
// --- edgar_company_snapshot --------------------------------------------------
|
|
2178
|
+
// The resolve -> list filings -> pull financials chain, collapsed into one call.
|
|
2179
|
+
// Measured in docs/edgar-cohort-retention-2026-09-17.md (fleet #2114 / #2182):
|
|
2180
|
+
// 90%+ of depth EDGAR callers assemble edgar_company_filings +
|
|
2181
|
+
// edgar_company_concept by hand after edgar_ticker_to_cik, three calls every
|
|
2182
|
+
// time; 66% of one-shot callers stop after ticker resolution. Neither
|
|
2183
|
+
// edgar_company_facts (concepts, no filings) nor entity_profile (a cross-source
|
|
2184
|
+
// meta-tool outside this pack, five unfiltered filings, three concepts) gave
|
|
2185
|
+
// a direct-tool caller that result from inside the pack.
|
|
2186
|
+
|
|
2187
|
+
/** 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. */
|
|
2188
|
+
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']);
|
|
2189
|
+
|
|
2190
|
+
async function companySnapshot(tickerOrCik: string, formType?: string, filingsLimit?: number) {
|
|
2191
|
+
const cik = await resolveCik(tickerOrCik);
|
|
2192
|
+
const paddedCik = padCik(cik);
|
|
2193
|
+
const count = Math.min(40, Math.max(1, filingsLimit ?? 10));
|
|
2194
|
+
const filter = (formType ?? '').trim();
|
|
2195
|
+
const keep = !filter
|
|
2196
|
+
? (form: string) => SNAPSHOT_DEFAULT_FORMS.has(form)
|
|
2197
|
+
: filter.toLowerCase() === 'all'
|
|
2198
|
+
? () => true
|
|
2199
|
+
: (form: string) => form === filter;
|
|
2200
|
+
|
|
2201
|
+
// Both legs are keyed on the same CIK and independent of each other, so run
|
|
2202
|
+
// them together — this call exists to save round-trips, not to serialize them.
|
|
2203
|
+
const [data, factsRes] = await Promise.all([
|
|
2204
|
+
fetchSubmissions(tickerOrCik, paddedCik),
|
|
2205
|
+
pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS }),
|
|
2206
|
+
]);
|
|
2207
|
+
|
|
2208
|
+
let filings = recentFilingRows(data, keep, count);
|
|
2209
|
+
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)';
|
|
2210
|
+
// The default set is tuned for operating companies. A fund, trust or ABS
|
|
2211
|
+
// issuer files N-CSR / 485BPOS / 10-D and none of the above, so the default
|
|
2212
|
+
// would hand back an empty list for a registrant with hundreds of filings —
|
|
2213
|
+
// a zero that reads as "nothing filed" (silent-zero policy). Fall back to
|
|
2214
|
+
// every form and SAY so in `filings_filter`, rather than return the empty set.
|
|
2215
|
+
if (!filter && filings.length === 0 && data.filings.recent.accessionNumber.length > 0) {
|
|
2216
|
+
filings = recentFilingRows(data, () => true, count);
|
|
2217
|
+
const forms = [...new Set(filings.map((f) => f.form))].join(', ');
|
|
2218
|
+
filingsFilter = `all — this filer's recent forms (${forms}) include none of the default set, so every form is listed`;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// The financials leg degrades on its own: a registrant with no XBRL facts
|
|
2222
|
+
// (funds, trusts, paper-form foreign filers) still has filings worth
|
|
2223
|
+
// returning. The gap is STATED — `financials_status` plus a reason — never
|
|
2224
|
+
// an empty object that reads as "reported nothing" (silent-zero policy).
|
|
2225
|
+
let financials: {
|
|
2226
|
+
financials_status: 'ok' | 'unavailable';
|
|
2227
|
+
financials_note?: string;
|
|
2228
|
+
latest_fiscal_year: number | null;
|
|
2229
|
+
latest_period_end: string | null;
|
|
2230
|
+
key_concepts: Record<string, unknown>;
|
|
2231
|
+
stale_concepts: string[];
|
|
2232
|
+
year_note?: string;
|
|
2233
|
+
};
|
|
2234
|
+
if (factsRes.ok) {
|
|
2235
|
+
const facts = (await factsRes.json()) as {
|
|
2236
|
+
facts: { 'us-gaap'?: Parameters<typeof summarizeAnnualFinancials>[0] };
|
|
2237
|
+
};
|
|
2238
|
+
const usGaap = facts.facts?.['us-gaap'] ?? {};
|
|
2239
|
+
const summary = summarizeAnnualFinancials(usGaap);
|
|
2240
|
+
const reported = Object.values(summary.latest_annual).some((v) => v !== null);
|
|
2241
|
+
financials = reported
|
|
2242
|
+
? {
|
|
2243
|
+
financials_status: 'ok',
|
|
2244
|
+
latest_fiscal_year: summary.latest_fiscal_year,
|
|
2245
|
+
latest_period_end: summary.latest_period_end,
|
|
2246
|
+
key_concepts: summary.latest_annual,
|
|
2247
|
+
stale_concepts: summary.stale_concepts,
|
|
2248
|
+
year_note: summary.year_note,
|
|
2249
|
+
}
|
|
2250
|
+
: {
|
|
2251
|
+
financials_status: 'unavailable',
|
|
2252
|
+
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.`,
|
|
2253
|
+
latest_fiscal_year: null,
|
|
2254
|
+
latest_period_end: null,
|
|
2255
|
+
key_concepts: {},
|
|
2256
|
+
stale_concepts: [],
|
|
2257
|
+
};
|
|
2258
|
+
} else {
|
|
2259
|
+
// Drain the body so the connection is released; the status is the fact.
|
|
2260
|
+
await factsRes.text().catch(() => undefined);
|
|
2261
|
+
financials = {
|
|
2262
|
+
financials_status: 'unavailable',
|
|
2263
|
+
financials_note:
|
|
2264
|
+
factsRes.status === 404
|
|
2265
|
+
? `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.`
|
|
2266
|
+
: `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.`,
|
|
2267
|
+
latest_fiscal_year: null,
|
|
2268
|
+
latest_period_end: null,
|
|
2269
|
+
key_concepts: {},
|
|
2270
|
+
stale_concepts: [],
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
return {
|
|
2275
|
+
cik: data.cik,
|
|
2276
|
+
cik_padded: paddedCik,
|
|
2277
|
+
company_name: data.name,
|
|
2278
|
+
tickers: data.tickers ?? [],
|
|
2279
|
+
sic: data.sic,
|
|
2280
|
+
sic_description: data.sicDescription,
|
|
2281
|
+
state_of_incorporation: data.stateOfIncorporation,
|
|
2282
|
+
fiscal_year_end: data.fiscalYearEnd,
|
|
2283
|
+
filings_filter: filingsFilter,
|
|
2284
|
+
filings_count: filings.length,
|
|
2285
|
+
filings,
|
|
2286
|
+
...financials,
|
|
2287
|
+
resources: {
|
|
2288
|
+
edgar_filings: `pipeworx://edgar/company/${paddedCik}/filings`,
|
|
2289
|
+
edgar_facts: `pipeworx://edgar/company/${paddedCik}/facts`,
|
|
2290
|
+
},
|
|
2291
|
+
next: {
|
|
2292
|
+
filing_text: 'edgar_filing_text({accession, section}) — the text of one filing above (section: "risk_factors", "mdna", ...).',
|
|
2293
|
+
concept_history: 'edgar_company_concept({cik, concept: "Revenues"}) — one metric across every reported period.',
|
|
2294
|
+
all_concepts: 'edgar_company_facts({cik}) — every XBRL concept the filer reports.',
|
|
2295
|
+
},
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
|
|
506
2299
|
// ── Filing documents (list docs inside one filing by accession) ─────
|
|
507
2300
|
|
|
508
2301
|
// Normalize an accession number (dashed or undashed) to both forms.
|
|
@@ -514,6 +2307,53 @@ function normalizeAccession(raw: string): { dashed: string; nodash: string } | n
|
|
|
514
2307
|
return { dashed, nodash: digits };
|
|
515
2308
|
}
|
|
516
2309
|
|
|
2310
|
+
// Read at most maxBytes of a filing document, then stop reading and cancel
|
|
2311
|
+
// the rest of the response body.
|
|
2312
|
+
//
|
|
2313
|
+
// Not a nicety: a SpaceX S-1 primary document is 11.8 MB of raw HTML (fleet
|
|
2314
|
+
// #440 — confirmed by fetching it directly), and SEC's archive server does
|
|
2315
|
+
// not honor Range requests (measured: a `Range: bytes=0-999` GET still comes
|
|
2316
|
+
// back 200 with the full 11.8 MB, not 206). materialising that and then
|
|
2317
|
+
// running htmlToText's regex passes over all of it inside one Worker
|
|
2318
|
+
// invocation is what hits the CPU/isolate ceiling and gets the whole request
|
|
2319
|
+
// killed with a bare Cloudflare 1102 — no JS exception, so a try/catch
|
|
2320
|
+
// around the fetch (see filingDocuments' includePrimaryText path) does NOT
|
|
2321
|
+
// protect against this failure mode. The cap has to be enforced on OUR side
|
|
2322
|
+
// of the socket. Mirrors mcps/legislation-uk/src/index.ts's getTextBounded,
|
|
2323
|
+
// which fixed the identical shape of bug for a 5.9 MB Act.
|
|
2324
|
+
async function fetchTextBounded(url: string, headers: Record<string, string>, maxBytes: number): Promise<{ text: string; truncated: boolean }> {
|
|
2325
|
+
const res = await pwFetch(url, { headers });
|
|
2326
|
+
if (!res.ok) throw new Error(`Failed to fetch primary document (HTTP ${res.status}): ${url}`);
|
|
2327
|
+
if (!res.body) {
|
|
2328
|
+
const whole = await res.text();
|
|
2329
|
+
return { text: whole.length > maxBytes ? whole.slice(0, maxBytes) : whole, truncated: whole.length > maxBytes };
|
|
2330
|
+
}
|
|
2331
|
+
const reader = res.body.getReader();
|
|
2332
|
+
const chunks: Uint8Array[] = [];
|
|
2333
|
+
let total = 0;
|
|
2334
|
+
let truncated = false;
|
|
2335
|
+
for (;;) {
|
|
2336
|
+
const { done, value } = await reader.read();
|
|
2337
|
+
if (done) break;
|
|
2338
|
+
if (value) {
|
|
2339
|
+
total += value.byteLength;
|
|
2340
|
+
chunks.push(value);
|
|
2341
|
+
if (total >= maxBytes) { truncated = true; await reader.cancel(); break; }
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
const buf = new Uint8Array(total);
|
|
2345
|
+
let off = 0;
|
|
2346
|
+
for (const c of chunks) { buf.set(c, off); off += c.byteLength; }
|
|
2347
|
+
return { text: new TextDecoder().decode(buf), truncated };
|
|
2348
|
+
}
|
|
2349
|
+
// Cap on RAW HTML bytes read, not plaintext chars — HTML markup overhead in
|
|
2350
|
+
// SEC filings (heavy inline styling/XBRL tagging) means this yields roughly
|
|
2351
|
+
// 1.5-3x fewer plaintext chars, comfortably above FILING_TEXT_CAP for the
|
|
2352
|
+
// front-loaded sections (Business, Risk Factors, MD&A) most questions ask
|
|
2353
|
+
// about. A doc capped here is flagged `raw_truncated: true` — an honest
|
|
2354
|
+
// stated limit, not a silent gap — rather than never answering at all.
|
|
2355
|
+
const FILING_DOC_MAX_BYTES = 4_000_000;
|
|
2356
|
+
|
|
517
2357
|
// Strip an HTML document to readable plaintext. Workers have no DOMParser;
|
|
518
2358
|
// drop script/style blocks, remove tags, decode entities, collapse whitespace.
|
|
519
2359
|
function htmlToText(html: string): string {
|
|
@@ -536,7 +2376,7 @@ async function filingDocuments(
|
|
|
536
2376
|
) {
|
|
537
2377
|
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
538
2378
|
throw new Error(
|
|
539
|
-
'A
|
|
2379
|
+
'A company is required: pass `ticker` ("AAPL"), `cik` ("320193") or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the latest 10-K).',
|
|
540
2380
|
);
|
|
541
2381
|
}
|
|
542
2382
|
const cik = await resolveCik(tickerOrCik);
|
|
@@ -552,7 +2392,7 @@ async function filingDocuments(
|
|
|
552
2392
|
}
|
|
553
2393
|
if (!acc) {
|
|
554
2394
|
const wantForm = wantFormTypeArg?.trim().toUpperCase();
|
|
555
|
-
const subRes = await
|
|
2395
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
|
|
556
2396
|
if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
|
|
557
2397
|
const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
|
|
558
2398
|
const r = sub.filings?.recent;
|
|
@@ -577,7 +2417,7 @@ async function filingDocuments(
|
|
|
577
2417
|
let primaryDocument: string | null = null;
|
|
578
2418
|
let primaryDocDescription: string | null = null;
|
|
579
2419
|
try {
|
|
580
|
-
const subRes = await
|
|
2420
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
581
2421
|
if (subRes.ok) {
|
|
582
2422
|
const sub = (await subRes.json()) as {
|
|
583
2423
|
name?: string;
|
|
@@ -608,13 +2448,13 @@ async function filingDocuments(
|
|
|
608
2448
|
}
|
|
609
2449
|
|
|
610
2450
|
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
|
|
611
|
-
const idxRes = await
|
|
2451
|
+
const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
|
|
612
2452
|
if (idxRes.status === 404) {
|
|
613
2453
|
throw new Error(
|
|
614
2454
|
`No filing found for accession ${acc.dashed} — that accession number does not exist in SEC EDGAR (double-check the digits; it may belong to a different filer). Use edgar_company_filings({ticker_or_cik: "${tickerOrCik}"}) to list this company's real accession numbers.`,
|
|
615
2455
|
);
|
|
616
2456
|
}
|
|
617
|
-
if (!idxRes.ok) throw
|
|
2457
|
+
if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
|
|
618
2458
|
const idx = (await idxRes.json()) as {
|
|
619
2459
|
directory?: { item?: { name: string; type?: string; size?: string | number; 'last-modified'?: string }[] };
|
|
620
2460
|
};
|
|
@@ -640,26 +2480,299 @@ async function filingDocuments(
|
|
|
640
2480
|
documents,
|
|
641
2481
|
};
|
|
642
2482
|
|
|
643
|
-
if (includePrimaryText && primaryDocument) {
|
|
644
|
-
try {
|
|
645
|
-
const docUrl = `${folder}/${primaryDocument}`;
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
2483
|
+
if (includePrimaryText && primaryDocument) {
|
|
2484
|
+
try {
|
|
2485
|
+
const docUrl = `${folder}/${primaryDocument}`;
|
|
2486
|
+
const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
|
|
2487
|
+
const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
|
|
2488
|
+
const text = isHtml ? htmlToText(body) : body;
|
|
2489
|
+
result.primary_text = text.length > 40000 ? text.slice(0, 40000) + '\n…[truncated]' : text;
|
|
2490
|
+
if (rawTruncated) result.primary_text_raw_truncated = true;
|
|
2491
|
+
} catch (e) {
|
|
2492
|
+
result.primary_text = null;
|
|
2493
|
+
result.primary_text_error = String(e);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
return result;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// Heading patterns for the `section` slice of edgar_filing_text. Each filing's
|
|
2501
|
+
// MD&A / notes carry these standard headings; we locate the LAST occurrence
|
|
2502
|
+
// (the body, not the table-of-contents entry) and return a window from there.
|
|
2503
|
+
// Each section: an anchor regex plus whether to take the FIRST match (the actual
|
|
2504
|
+
// disclosure body — used where the phrase is rare and meaningful) or the LAST
|
|
2505
|
+
// (used where the heading also appears in the table of contents, so the body is
|
|
2506
|
+
// the later occurrence). going_concern anchors on "substantial doubt" — the
|
|
2507
|
+
// ASC 205-40 trigger phrase — because "going concern" alone recurs in boilerplate.
|
|
2508
|
+
const FILING_SECTION_PATTERNS: Record<string, { anchor: RegExp; pick: 'first' | 'last' }> = {
|
|
2509
|
+
going_concern: { anchor: /substantial doubt|going concern/gi, pick: 'first' },
|
|
2510
|
+
liquidity: { anchor: /liquidity and capital resources|liquidity/gi, pick: 'last' },
|
|
2511
|
+
capital_resources: { anchor: /capital resources/gi, pick: 'last' },
|
|
2512
|
+
subsequent_events: { anchor: /subsequent events/gi, pick: 'last' },
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
const FILING_TEXT_DEFAULT_MAX = 50000;
|
|
2516
|
+
const FILING_TEXT_CAP = 100000;
|
|
2517
|
+
|
|
2518
|
+
// fleet #2446: a KPI question ("how many tests did $TICKER process") routed
|
|
2519
|
+
// correctly to edgar_filing_text but the fact sat ~100k characters into a
|
|
2520
|
+
// 10-Q's MD&A, which meant paging max_chars windows by hand to find it. This
|
|
2521
|
+
// is a plain case-insensitive substring scan of the ALREADY-EXTRACTED plain-
|
|
2522
|
+
// text document (not a second fetch) — cheap, and it runs before the normal
|
|
2523
|
+
// offset/max_chars windowing so it sees the whole doc (or the whole `section`
|
|
2524
|
+
// slice) rather than one page of it.
|
|
2525
|
+
const SEARCH_CONTEXT_CHARS = 400;
|
|
2526
|
+
const SEARCH_MAX_PASSAGES = 10;
|
|
2527
|
+
|
|
2528
|
+
// A KPI figure looks like "2,056,800" or "1,708,200" — thousands-grouped, or
|
|
2529
|
+
// otherwise a bare run of 4+ digits (a raw count with no separator).
|
|
2530
|
+
const NEARBY_NUMBER_RE = /\d{1,3}(?:,\d{3})+|\d{4,}/;
|
|
2531
|
+
|
|
2532
|
+
function findPassages(
|
|
2533
|
+
text: string,
|
|
2534
|
+
term: string,
|
|
2535
|
+
contextChars = SEARCH_CONTEXT_CHARS,
|
|
2536
|
+
maxPassages = SEARCH_MAX_PASSAGES,
|
|
2537
|
+
): { total: number; distinct_locations: number; passages: Array<{ offset: number; text: string }> } {
|
|
2538
|
+
const needle = term.toLowerCase();
|
|
2539
|
+
if (!needle) return { total: 0, distinct_locations: 0, passages: [] };
|
|
2540
|
+
const hay = text.toLowerCase();
|
|
2541
|
+
const offsets: number[] = [];
|
|
2542
|
+
let from = 0;
|
|
2543
|
+
for (;;) {
|
|
2544
|
+
const idx = hay.indexOf(needle, from);
|
|
2545
|
+
if (idx === -1) break;
|
|
2546
|
+
offsets.push(idx);
|
|
2547
|
+
from = idx + needle.length;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// Merge occurrences close enough that their context windows would overlap
|
|
2551
|
+
// into ONE passage — a KPI sentence ("we processed approximately X tests,
|
|
2552
|
+
// compared to Y tests processed") can repeat the search term 2-3 times a
|
|
2553
|
+
// few words apart, and without this a common word burns most of the
|
|
2554
|
+
// (capped) passage budget on one sentence instead of covering the document.
|
|
2555
|
+
const groups: Array<{ first: number; last: number }> = [];
|
|
2556
|
+
for (const idx of offsets) {
|
|
2557
|
+
const g = groups[groups.length - 1];
|
|
2558
|
+
if (g && idx - g.last <= contextChars * 2) g.last = idx;
|
|
2559
|
+
else groups.push({ first: idx, last: idx });
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// Rank groups whose window carries a nearby NUMBER first. A KPI question
|
|
2563
|
+
// ("how many tests/units/subscribers") is answered by a figure next to the
|
|
2564
|
+
// word, not by every mention of the word — measured live (fleet #2446):
|
|
2565
|
+
// searching "tests" in Natera's Q2 2026 10-Q hits 78 times, mostly
|
|
2566
|
+
// forward-looking-statements boilerplate ("expectations... for our
|
|
2567
|
+
// tests"), with the actual "processed approximately 2,056,800 tests" KPI
|
|
2568
|
+
// buried past the first ~80 of them by document order. Ranking by nearby
|
|
2569
|
+
// digits (no second fetch — same text already in hand) reliably surfaces
|
|
2570
|
+
// it inside a small `maxPassages` cap instead of requiring the caller to
|
|
2571
|
+
// already know the more distinctive phrase to search for. Groups without a
|
|
2572
|
+
// nearby number keep DOCUMENT ORDER among themselves (stable sort).
|
|
2573
|
+
const scored = groups.map((g) => {
|
|
2574
|
+
const start = Math.max(0, g.first - contextChars);
|
|
2575
|
+
const end = Math.min(text.length, g.last + needle.length + contextChars);
|
|
2576
|
+
return { start, end, hasNumber: NEARBY_NUMBER_RE.test(text.slice(start, end)) ? 1 : 0 };
|
|
2577
|
+
});
|
|
2578
|
+
scored.sort((a, b) => b.hasNumber - a.hasNumber);
|
|
2579
|
+
|
|
2580
|
+
const passages = scored.slice(0, maxPassages).map((s) => ({ offset: s.start, text: text.slice(s.start, s.end) }));
|
|
2581
|
+
return { total: offsets.length, distinct_locations: groups.length, passages };
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
|
|
2585
|
+
// out (rather than piggybacking edgar_filing_documents' include_primary_text,
|
|
2586
|
+
// which caps at 40k and can't page) lets the register pull deep disclosures —
|
|
2587
|
+
// going-concern language, ATM facilities, share caps — that live past 40k.
|
|
2588
|
+
async function filingText(
|
|
2589
|
+
accession: string | undefined,
|
|
2590
|
+
tickerOrCik: string,
|
|
2591
|
+
section?: string,
|
|
2592
|
+
maxChars?: number,
|
|
2593
|
+
offset?: number,
|
|
2594
|
+
wantFormTypeArg?: string,
|
|
2595
|
+
searchTerm?: string,
|
|
2596
|
+
) {
|
|
2597
|
+
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
2598
|
+
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"})).');
|
|
2599
|
+
}
|
|
2600
|
+
const cik = await resolveCik(tickerOrCik);
|
|
2601
|
+
const paddedCik = padCik(cik);
|
|
2602
|
+
const cikNoZeros = String(Number(cik.replace(/\D/g, '')));
|
|
2603
|
+
|
|
2604
|
+
let acc = accession ? normalizeAccession(accession) : null;
|
|
2605
|
+
if (accession && !acc) {
|
|
2606
|
+
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.`);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// One submissions fetch serves both auto-resolution and metadata enrichment.
|
|
2610
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
2611
|
+
if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) for ${tickerOrCik}.`);
|
|
2612
|
+
const sub = (await subRes.json()) as {
|
|
2613
|
+
name?: string;
|
|
2614
|
+
filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[]; primaryDocument?: string[] } };
|
|
2615
|
+
};
|
|
2616
|
+
const r = sub.filings?.recent;
|
|
2617
|
+
const accs = r?.accessionNumber ?? [];
|
|
2618
|
+
const forms = r?.form ?? [];
|
|
2619
|
+
|
|
2620
|
+
if (!acc) {
|
|
2621
|
+
const wantForm = wantFormTypeArg?.trim().toUpperCase();
|
|
2622
|
+
const idx = wantForm ? forms.findIndex((f) => f.toUpperCase() === wantForm) : 0;
|
|
2623
|
+
if (idx < 0 || !accs[idx]) {
|
|
2624
|
+
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.`);
|
|
2625
|
+
}
|
|
2626
|
+
acc = normalizeAccession(accs[idx]);
|
|
2627
|
+
if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
const i = accs.indexOf(acc.dashed);
|
|
2631
|
+
const formType = i >= 0 ? forms[i] ?? null : null;
|
|
2632
|
+
const filingDate = i >= 0 ? r?.filingDate?.[i] ?? null : null;
|
|
2633
|
+
let primaryDocument = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
|
|
2634
|
+
|
|
2635
|
+
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
|
|
2636
|
+
// If the accession wasn't in the recent index (older filing), fall back to the
|
|
2637
|
+
// folder index to find the primary document.
|
|
2638
|
+
if (!primaryDocument) {
|
|
2639
|
+
const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
|
|
2640
|
+
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.`);
|
|
2641
|
+
if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
|
|
2642
|
+
const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
|
|
2643
|
+
const docs = idxJson.directory?.item ?? [];
|
|
2644
|
+
// Prefer the main htm document that isn't an exhibit/XBRL artifact.
|
|
2645
|
+
primaryDocument =
|
|
2646
|
+
docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
|
|
2647
|
+
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.`);
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
const docUrl = `${folder}/${primaryDocument}`;
|
|
2651
|
+
const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
|
|
2652
|
+
const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
|
|
2653
|
+
let fullText = isHtml ? htmlToText(body) : body;
|
|
2654
|
+
|
|
2655
|
+
// Optional section slice: locate the section heading (last occurrence = the
|
|
2656
|
+
// real body, past any table-of-contents mention) and reframe the text to start
|
|
2657
|
+
// there. `section` is best-effort — an unmatched section returns the whole doc.
|
|
2658
|
+
let sectionApplied: string | null = null;
|
|
2659
|
+
let sectionFound: string | boolean | null = null;
|
|
2660
|
+
const sec = (section ?? '').trim().toLowerCase();
|
|
2661
|
+
if (sec) {
|
|
2662
|
+
const conf = FILING_SECTION_PATTERNS[sec];
|
|
2663
|
+
if (!conf) {
|
|
2664
|
+
sectionFound = `unknown_section:${sec}`;
|
|
2665
|
+
} else {
|
|
2666
|
+
const idxs: number[] = [];
|
|
2667
|
+
for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
|
|
2668
|
+
const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
|
|
2669
|
+
if (chosen >= 0) {
|
|
2670
|
+
// Back up a little so the section heading itself is included, not clipped mid-phrase.
|
|
2671
|
+
fullText = fullText.slice(Math.max(0, chosen - 120));
|
|
2672
|
+
sectionApplied = sec;
|
|
2673
|
+
sectionFound = true;
|
|
652
2674
|
} else {
|
|
653
|
-
|
|
654
|
-
result.primary_text_error = `fetch ${docRes.status}`;
|
|
2675
|
+
sectionFound = false;
|
|
655
2676
|
}
|
|
656
|
-
} catch (e) {
|
|
657
|
-
result.primary_text = null;
|
|
658
|
-
result.primary_text_error = String(e);
|
|
659
2677
|
}
|
|
660
2678
|
}
|
|
661
2679
|
|
|
662
|
-
|
|
2680
|
+
// `search` short-circuits the normal offset/max_chars windowing: it scans
|
|
2681
|
+
// the whole (possibly section-sliced) document and returns matching
|
|
2682
|
+
// passages with their own offsets, rather than one page of raw text the
|
|
2683
|
+
// caller has to eyeball for the fact they wanted (fleet #2446).
|
|
2684
|
+
const search = (searchTerm ?? '').trim();
|
|
2685
|
+
if (search) {
|
|
2686
|
+
const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, search);
|
|
2687
|
+
return {
|
|
2688
|
+
accession: acc.dashed,
|
|
2689
|
+
cik: cikNoZeros,
|
|
2690
|
+
company: sub.name ?? null,
|
|
2691
|
+
form: formType,
|
|
2692
|
+
filed: filingDate,
|
|
2693
|
+
document: primaryDocument,
|
|
2694
|
+
document_url: docUrl,
|
|
2695
|
+
section: sectionApplied,
|
|
2696
|
+
...(sectionFound !== null ? { section_found: sectionFound } : {}),
|
|
2697
|
+
total_chars: fullText.length,
|
|
2698
|
+
search: {
|
|
2699
|
+
term: search,
|
|
2700
|
+
total_matches: totalMatches,
|
|
2701
|
+
distinct_locations: distinctLocations,
|
|
2702
|
+
returned: passages.length,
|
|
2703
|
+
truncated: distinctLocations > passages.length,
|
|
2704
|
+
passages,
|
|
2705
|
+
note: totalMatches === 0
|
|
2706
|
+
? 'No case-insensitive match for this exact wording in the document (or section, if one was given). That is a real answer, not a failure — retry with a shorter phrase or drop `section` to search the whole document.'
|
|
2707
|
+
: `Passages carrying a nearby number (a likely KPI figure) are ranked first; the rest keep document order. Each passage's offset is a position in the document text — pass it as \`offset\` (without \`search\`) to read more surrounding text via the normal paged text field.`,
|
|
2708
|
+
},
|
|
2709
|
+
...(rawTruncated
|
|
2710
|
+
? {
|
|
2711
|
+
raw_truncated: true,
|
|
2712
|
+
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 the search above only scanned the portion read — NOT the whole filing. Treat total_matches:0 as inconclusive, not "absent", for a raw_truncated document.`,
|
|
2713
|
+
}
|
|
2714
|
+
: {}),
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
const total = fullText.length;
|
|
2719
|
+
const off = Math.max(0, Math.floor(Number(offset) || 0));
|
|
2720
|
+
const cap = Math.min(FILING_TEXT_CAP, Math.max(1000, Math.floor(Number(maxChars) || FILING_TEXT_DEFAULT_MAX)));
|
|
2721
|
+
const slice = fullText.slice(off, off + cap);
|
|
2722
|
+
const end = off + slice.length;
|
|
2723
|
+
const truncated = end < total;
|
|
2724
|
+
|
|
2725
|
+
return {
|
|
2726
|
+
accession: acc.dashed,
|
|
2727
|
+
cik: cikNoZeros,
|
|
2728
|
+
company: sub.name ?? null,
|
|
2729
|
+
form: formType,
|
|
2730
|
+
filed: filingDate,
|
|
2731
|
+
document: primaryDocument,
|
|
2732
|
+
document_url: docUrl,
|
|
2733
|
+
section: sectionApplied,
|
|
2734
|
+
...(sectionFound !== null ? { section_found: sectionFound } : {}),
|
|
2735
|
+
total_chars: total,
|
|
2736
|
+
offset: off,
|
|
2737
|
+
returned_chars: slice.length,
|
|
2738
|
+
truncated,
|
|
2739
|
+
// When truncated, pass this back as `offset` to page forward; do NOT spill the
|
|
2740
|
+
// whole doc to disk — request the next window instead.
|
|
2741
|
+
next_offset: truncated ? end : null,
|
|
2742
|
+
...(rawTruncated
|
|
2743
|
+
? {
|
|
2744
|
+
raw_truncated: true,
|
|
2745
|
+
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.`,
|
|
2746
|
+
}
|
|
2747
|
+
: {}),
|
|
2748
|
+
text: slice,
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// data.sec.gov answers a bare 404 both when no filer has the CIK at all and when
|
|
2753
|
+
// the filer exists but has never submitted XBRL financial data (investment funds,
|
|
2754
|
+
// trusts, individuals, most pre-2011 foreign private issuers). "SEC EDGAR company
|
|
2755
|
+
// facts error: 404" told the caller neither, and booked as `error` (the tier
|
|
2756
|
+
// meaning a Pipeworx defect) instead of the caller's CIK being the problem. The
|
|
2757
|
+
// sibling `sec` pack fixed this same shape (fleet #586); ported here — the two
|
|
2758
|
+
// packs cover the same upstream and had silently drifted. `not_found:` is the
|
|
2759
|
+
// classifier token: the gateway books it as user_error and strips it before the
|
|
2760
|
+
// caller sees it.
|
|
2761
|
+
async function companyFactsNotFound(input: string, paddedCik: string): Promise<Error> {
|
|
2762
|
+
const lookup = 'Resolve a real one with edgar_ticker_to_cik({ticker: "…"}) — it accepts a ticker or a company name.';
|
|
2763
|
+
const sub = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS }).catch(
|
|
2764
|
+
() => null,
|
|
2765
|
+
);
|
|
2766
|
+
if (sub?.ok) {
|
|
2767
|
+
const filer = (await sub.json().catch(() => null)) as { name?: string } | null;
|
|
2768
|
+
const name = filer?.name ? `"${filer.name}"` : 'a filer';
|
|
2769
|
+
return new Error(
|
|
2770
|
+
`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}`,
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
return new Error(
|
|
2774
|
+
`not_found: SEC EDGAR has no company at CIK ${paddedCik} (resolved from "${input}"). EDGAR covers companies registered with the US SEC only. ${lookup}`,
|
|
2775
|
+
);
|
|
663
2776
|
}
|
|
664
2777
|
|
|
665
2778
|
async function companyFacts(cikOrTicker: string) {
|
|
@@ -669,10 +2782,11 @@ async function companyFacts(cikOrTicker: string) {
|
|
|
669
2782
|
// resolveCik like every sibling: validates the arg and resolves tickers.
|
|
670
2783
|
const cik = await resolveCik(cikOrTicker);
|
|
671
2784
|
const paddedCik = padCik(cik);
|
|
672
|
-
const res = await
|
|
2785
|
+
const res = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
|
|
673
2786
|
headers: SEC_HEADERS,
|
|
674
2787
|
});
|
|
675
|
-
if (
|
|
2788
|
+
if (res.status === 404) throw await companyFactsNotFound(cikOrTicker, paddedCik);
|
|
2789
|
+
if (!res.ok) throw await httpError(res, 'SEC EDGAR company facts error');
|
|
676
2790
|
|
|
677
2791
|
const data = (await res.json()) as {
|
|
678
2792
|
cik: number;
|
|
@@ -692,52 +2806,22 @@ async function companyFacts(cikOrTicker: string) {
|
|
|
692
2806
|
};
|
|
693
2807
|
};
|
|
694
2808
|
|
|
2809
|
+
// Most-recent annual value per concept, with the filer's CURRENT concept
|
|
2810
|
+
// leading and retired concepts flagged stale — see shared/src/xbrl.ts for the
|
|
2811
|
+
// three traps this replaces (fy = filing year, frame on the last filer, and
|
|
2812
|
+
// Microsoft's FY2010 `Revenues` presented as "most recent"). Fleet #594.
|
|
695
2813
|
const usGaap = data.facts?.['us-gaap'] ?? {};
|
|
696
|
-
const
|
|
697
|
-
'Revenues',
|
|
698
|
-
'RevenueFromContractWithCustomerExcludingAssessedTax',
|
|
699
|
-
'NetIncomeLoss',
|
|
700
|
-
'Assets',
|
|
701
|
-
'Liabilities',
|
|
702
|
-
'StockholdersEquity',
|
|
703
|
-
'CashAndCashEquivalentsAtCarryingValue',
|
|
704
|
-
'EarningsPerShareBasic',
|
|
705
|
-
'EarningsPerShareDiluted',
|
|
706
|
-
'CommonStockSharesOutstanding',
|
|
707
|
-
'OperatingIncomeLoss',
|
|
708
|
-
'GrossProfit',
|
|
709
|
-
'ResearchAndDevelopmentExpense',
|
|
710
|
-
];
|
|
711
|
-
|
|
712
|
-
const metrics: Record<
|
|
713
|
-
string,
|
|
714
|
-
{ label: string; most_recent_annual: { year: number; value: number; filed: string } | null }
|
|
715
|
-
> = {};
|
|
716
|
-
|
|
717
|
-
for (const key of KEY_METRICS) {
|
|
718
|
-
const fact = usGaap[key];
|
|
719
|
-
if (!fact) continue;
|
|
720
|
-
|
|
721
|
-
const usdEntries = fact.units['USD'] ?? fact.units['shares'] ?? [];
|
|
722
|
-
// Same fix as companyConcept: don't gate on `frame !== undefined`;
|
|
723
|
-
// off-calendar filers (NVDA, AAPL) get their modern annuals dropped.
|
|
724
|
-
// Sort by period end (desc) to surface the actual most-recent 10-K.
|
|
725
|
-
const annual = usdEntries
|
|
726
|
-
.filter((e) => e.form === '10-K' || e.form === '10-K/A')
|
|
727
|
-
.sort((a, b) => (b.end ?? '').localeCompare(a.end ?? ''));
|
|
728
|
-
|
|
729
|
-
metrics[key] = {
|
|
730
|
-
label: fact.label,
|
|
731
|
-
most_recent_annual: annual[0]
|
|
732
|
-
? { year: annual[0].fy, value: annual[0].val, filed: annual[0].filed }
|
|
733
|
-
: null,
|
|
734
|
-
};
|
|
735
|
-
}
|
|
2814
|
+
const summary = summarizeAnnualFinancials(usGaap);
|
|
736
2815
|
|
|
737
2816
|
return {
|
|
738
2817
|
cik: String(data.cik),
|
|
739
2818
|
company_name: data.entityName,
|
|
740
|
-
|
|
2819
|
+
latest_fiscal_year: summary.latest_fiscal_year,
|
|
2820
|
+
latest_period_end: summary.latest_period_end,
|
|
2821
|
+
latest_annual: summary.latest_annual,
|
|
2822
|
+
key_financials: summary.key_financials,
|
|
2823
|
+
stale_concepts: summary.stale_concepts,
|
|
2824
|
+
year_note: summary.year_note,
|
|
741
2825
|
available_concepts: Object.keys(usGaap).length,
|
|
742
2826
|
};
|
|
743
2827
|
}
|
|
@@ -770,12 +2854,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
770
2854
|
revenues: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
|
|
771
2855
|
netincome: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
|
|
772
2856
|
netincomeloss: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
|
|
2857
|
+
// Without these, "shares outstanding" fell through to the fuzzy name scan,
|
|
2858
|
+
// which matched PreferredStockSharesOutstanding first — and most filers
|
|
2859
|
+
// report 0 preferred. Asking a plain question about Snowflake returned
|
|
2860
|
+
// "0 shares outstanding" for a company with ~330M. A confident zero about a
|
|
2861
|
+
// public company is the worst shape of wrong answer we produce, so the
|
|
2862
|
+
// common-stock tags are named explicitly and ordered.
|
|
2863
|
+
// EntityCommonStockSharesOutstanding is the dei cover-page tag, which is
|
|
2864
|
+
// often fresher than the us-gaap balance-sheet one.
|
|
2865
|
+
sharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
|
|
2866
|
+
commonsharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
|
|
2867
|
+
commonstocksharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding'],
|
|
773
2868
|
cash: [
|
|
774
2869
|
'CashAndCashEquivalentsAtCarryingValue',
|
|
775
2870
|
'Cash',
|
|
776
2871
|
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
|
|
777
2872
|
],
|
|
778
|
-
longtermdebt: [
|
|
2873
|
+
longtermdebt: [
|
|
2874
|
+
'LongTermDebt', 'LongTermDebtNoncurrent',
|
|
2875
|
+
'LongTermDebtAndCapitalLeaseObligations', 'DebtAndCapitalLeaseObligations',
|
|
2876
|
+
// Post-ASC-842 (2019+) filers and combined-debt filers use different tags.
|
|
2877
|
+
// Also adds debtlongterm* to the company-facts scan token set so the name
|
|
2878
|
+
// scan catches tags where "Debt" precedes "Longterm" (e.g. Ford CIK 37996).
|
|
2879
|
+
'LongTermDebtAndFinanceLeaseLiability',
|
|
2880
|
+
'DebtLongtermAndShorttermCombinedAmount',
|
|
2881
|
+
],
|
|
779
2882
|
// Common concepts an LLM asks for that were MISSING → 404 (net income was
|
|
780
2883
|
// covered but these weren't). Keys are bare-alphanumeric (the lookup strips
|
|
781
2884
|
// spaces/punctuation), so "total assets"/"earnings per share" etc. all match.
|
|
@@ -793,7 +2896,9 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
793
2896
|
operatingincome: ['OperatingIncomeLoss'],
|
|
794
2897
|
operatingincomeloss: ['OperatingIncomeLoss'],
|
|
795
2898
|
// ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
|
|
796
|
-
longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
|
|
2899
|
+
longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt', 'LongTermDebtAndFinanceLeaseLiability', 'DebtLongtermAndShorttermCombinedAmount'],
|
|
2900
|
+
longtermdebtandfinanceleaseliability: ['LongTermDebtAndFinanceLeaseLiability', 'LongTermDebt', 'LongTermDebtAndCapitalLeaseObligations'],
|
|
2901
|
+
debtlongtermandshorttermcombinedamount: ['DebtLongtermAndShorttermCombinedAmount', 'LongTermDebt', 'LongTermDebtNoncurrent'],
|
|
797
2902
|
salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
|
|
798
2903
|
revenuefromcontractwithcustomerexcludingassessedtax: [
|
|
799
2904
|
'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
|
|
@@ -825,6 +2930,19 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
825
2930
|
// only, which silently dropped every foreign filer's data even when the tag
|
|
826
2931
|
// existed → 404s / empty results.
|
|
827
2932
|
const ANNUAL_FORMS = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
|
|
2933
|
+
const QUARTERLY_FORMS = new Set(['10-Q', '10-Q/A']);
|
|
2934
|
+
const ALL_PERIODIC_FORMS = new Set([...ANNUAL_FORMS, ...QUARTERLY_FORMS]);
|
|
2935
|
+
|
|
2936
|
+
/** Forms to include for a requested reporting period. Default "all" surfaces
|
|
2937
|
+
* quarterly (10-Q) alongside annual — a metric like Cash moves every quarter,
|
|
2938
|
+
* and returning only the annual 10-K value hid the fresher 10-Q figure
|
|
2939
|
+
* (published an 8-month runway off a 10-K when the 10-Q implied ~5). */
|
|
2940
|
+
function formsForPeriod(period: string | undefined): Set<string> {
|
|
2941
|
+
const p = (period ?? 'all').trim().toLowerCase();
|
|
2942
|
+
if (p === 'annual' || p === 'yearly' || p === 'fy') return ANNUAL_FORMS;
|
|
2943
|
+
if (p === 'quarterly' || p === 'quarter' || p === 'q') return QUARTERLY_FORMS;
|
|
2944
|
+
return ALL_PERIODIC_FORMS;
|
|
2945
|
+
}
|
|
828
2946
|
|
|
829
2947
|
// ifrs-full taxonomy fallback for foreign private issuers (they report under
|
|
830
2948
|
// ifrs-full, not us-gaap). Keyed by the same normalized metric name; tried only
|
|
@@ -845,12 +2963,36 @@ const IFRS_FALLBACK: Record<string, string[]> = {
|
|
|
845
2963
|
equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
|
|
846
2964
|
};
|
|
847
2965
|
|
|
848
|
-
async function companyConcept(
|
|
2966
|
+
async function companyConcept(
|
|
2967
|
+
cikOrTicker: string,
|
|
2968
|
+
concept: string,
|
|
2969
|
+
period?: string,
|
|
2970
|
+
fiscalYearArg?: string | number,
|
|
2971
|
+
fiscalPeriodArg?: string,
|
|
2972
|
+
) {
|
|
2973
|
+
// Checked here rather than by the gateway's declared-`required` pre-flight,
|
|
2974
|
+
// because `cik` accepts three spellings and a flat required list cannot say
|
|
2975
|
+
// "one of these" (see the inputSchema note). Both messages say "is required"
|
|
2976
|
+
// so they classify as a caller error, not as our defect, and both name every
|
|
2977
|
+
// spelling that works — the old gateway refusal named only `cik`, which is
|
|
2978
|
+
// the word the caller had already decided not to use.
|
|
2979
|
+
if (typeof cikOrTicker !== 'string' || !cikOrTicker.trim()) {
|
|
2980
|
+
throw new Error(
|
|
2981
|
+
'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"}).',
|
|
2982
|
+
);
|
|
2983
|
+
}
|
|
2984
|
+
if (typeof concept !== 'string' || !concept.trim()) {
|
|
2985
|
+
throw new Error(
|
|
2986
|
+
'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"}).',
|
|
2987
|
+
);
|
|
2988
|
+
}
|
|
2989
|
+
const forms = formsForPeriod(period);
|
|
849
2990
|
// Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
|
|
850
2991
|
// Production analytics: 11% errors on edgar largely from LLMs passing
|
|
851
2992
|
// tickers as cik (then padCik strips letters → 0000000000 → 404).
|
|
852
2993
|
const cik = await resolveCik(cikOrTicker);
|
|
853
2994
|
const paddedCik = padCik(cik);
|
|
2995
|
+
let resolvedEntityName = '';
|
|
854
2996
|
|
|
855
2997
|
// Normalize the lookup key to bare alphanumerics so natural-language input
|
|
856
2998
|
// ("net income", "Long-Term Debt", "earnings per share") matches the no-space
|
|
@@ -870,6 +3012,24 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
870
3012
|
{ end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string }[]
|
|
871
3013
|
>;
|
|
872
3014
|
};
|
|
3015
|
+
type ConceptFact = ConceptDoc['units'][string][number];
|
|
3016
|
+
|
|
3017
|
+
// SEC's XBRL JSON is normally { units: { "USD": [ ...facts ] } }, but a small
|
|
3018
|
+
// fraction of documents carry a `units` bucket that is not an array (and a
|
|
3019
|
+
// handful omit `units` entirely). `for (const e of values)` on one of those
|
|
3020
|
+
// throws "values is not iterable" — an unguarded internal TypeError, so it
|
|
3021
|
+
// books as OUR defect rather than an upstream data problem, and the caller
|
|
3022
|
+
// gets a message that names a variable in our source and tells them nothing
|
|
3023
|
+
// they can act on. It was firing on roughly 1 call in 318 of
|
|
3024
|
+
// edgar_company_concept, the highest-volume tool on the platform, which is
|
|
3025
|
+
// exactly the kind of rate that never trips an alarm and never gets fixed.
|
|
3026
|
+
//
|
|
3027
|
+
// Skip the malformed bucket rather than failing the whole lookup: a company's
|
|
3028
|
+
// other units almost always still carry the answer, so a partial answer beats
|
|
3029
|
+
// a crash. If every bucket is malformed the caller falls through to the normal
|
|
3030
|
+
// no-data path, which already says something useful.
|
|
3031
|
+
const unitEntries = (units: ConceptDoc['units'] | undefined): [string, ConceptFact[]][] =>
|
|
3032
|
+
Object.entries(units ?? {}).filter((e): e is [string, ConceptFact[]] => Array.isArray(e[1]));
|
|
873
3033
|
|
|
874
3034
|
// Fetch EVERY candidate that exists, then pick the one with the most recent
|
|
875
3035
|
// 10-K data — not the first non-empty one. Filers switch revenue tags over
|
|
@@ -881,16 +3041,16 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
881
3041
|
const found: { doc: ConceptDoc; latestEnd: string }[] = [];
|
|
882
3042
|
const tryNamespace = async (ns: string, tags: string[]) => {
|
|
883
3043
|
for (const candidate of tags) {
|
|
884
|
-
const r = await
|
|
3044
|
+
const r = await pwFetch(
|
|
885
3045
|
`${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${ns}/${encodeURIComponent(candidate)}.json`,
|
|
886
3046
|
{ headers: SEC_HEADERS },
|
|
887
3047
|
);
|
|
888
3048
|
if (!r.ok) { lastStatus = r.status; continue; }
|
|
889
3049
|
const doc = (await r.json()) as ConceptDoc;
|
|
890
3050
|
let latestEnd = '';
|
|
891
|
-
for (const values of
|
|
3051
|
+
for (const [, values] of unitEntries(doc.units)) {
|
|
892
3052
|
for (const e of values) {
|
|
893
|
-
if (!
|
|
3053
|
+
if (!ALL_PERIODIC_FORMS.has(e.form)) continue;
|
|
894
3054
|
if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
|
|
895
3055
|
}
|
|
896
3056
|
}
|
|
@@ -910,19 +3070,26 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
910
3070
|
// LongTermDebtNoncurrent, many filers use LongTermDebtAndCapitalLease...). Pull
|
|
911
3071
|
// the full facts once and find the freshest us-gaap tag whose NAME matches the
|
|
912
3072
|
// concept keywords, preferring balance-sheet stock tags over cash-flow tags.
|
|
913
|
-
|
|
3073
|
+
// Also trigger when ALL found data is >3 years stale — filer changed tag.
|
|
3074
|
+
const THREE_YEARS_MS = 3 * 365 * 24 * 3600 * 1000;
|
|
3075
|
+
const bestFoundMs = found.length > 0 ? Math.max(...found.map(f => f.latestEnd ? new Date(f.latestEnd).getTime() : 0)) : 0;
|
|
3076
|
+
const foundIsStale = found.length > 0 && bestFoundMs > 0 && (Date.now() - bestFoundMs) > THREE_YEARS_MS;
|
|
3077
|
+
if (found.length === 0 || foundIsStale) {
|
|
914
3078
|
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
915
3079
|
const tokens = candidates.map(norm).filter((t) => t.length >= 3);
|
|
916
|
-
const factsRes = await
|
|
917
|
-
if (factsRes.ok
|
|
3080
|
+
const factsRes = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
3081
|
+
if (!factsRes.ok) {
|
|
3082
|
+
lastStatus = factsRes.status;
|
|
3083
|
+
} else if (tokens.length) {
|
|
918
3084
|
const facts = (await factsRes.json()) as { entityName?: string; facts?: { 'us-gaap'?: Record<string, { label?: string; description?: string; units: Record<string, ConceptDoc['units'][string]> }> } };
|
|
3085
|
+
resolvedEntityName = facts.entityName ?? '';
|
|
919
3086
|
const gaap = facts.facts?.['us-gaap'] ?? {};
|
|
920
3087
|
const FLOW = /^(ProceedsFrom|RepaymentsOf|PaymentsOf|IncreaseDecrease|AmortizationOf|Gain|Loss)/;
|
|
921
3088
|
const matches: { isFlow: boolean; latestEnd: string; doc: ConceptDoc }[] = [];
|
|
922
3089
|
for (const [tag, entry] of Object.entries(gaap)) {
|
|
923
3090
|
if (!tokens.some((t) => norm(tag).includes(t))) continue;
|
|
924
3091
|
let latestEnd = '';
|
|
925
|
-
for (const values of
|
|
3092
|
+
for (const [, values] of unitEntries(entry.units)) {
|
|
926
3093
|
for (const e of values) {
|
|
927
3094
|
if (e.form !== '10-K' && e.form !== '10-K/A') continue;
|
|
928
3095
|
if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
|
|
@@ -938,10 +3105,82 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
938
3105
|
if (matches.length) found.push({ doc: matches[0].doc, latestEnd: matches[0].latestEnd });
|
|
939
3106
|
}
|
|
940
3107
|
}
|
|
3108
|
+
// Still stale after the name scan — every tag we can see is >3y old and the
|
|
3109
|
+
// scan found nothing fresher. Don't hand back the old number. It is the worst
|
|
3110
|
+
// failure shape we ship: a well-formed, plausible, five-year-old answer with
|
|
3111
|
+
// no marker on it, so neither the caller nor any automatic check can tell it
|
|
3112
|
+
// apart from a current one.
|
|
3113
|
+
//
|
|
3114
|
+
// Ford is the reference case, measured 2026-08-01. companyconcept for
|
|
3115
|
+
// LongTermDebtNoncurrent serves 4 facts ending at end=2020-12-31 /
|
|
3116
|
+
// filed=2021-02-05, and LongTermDebt, LongTermDebtAndCapitalLeaseObligations
|
|
3117
|
+
// and DebtLongtermAndShorttermCombinedAmount all 404 — yet Ford filed a 10-Q
|
|
3118
|
+
// on 2026-07-29. The debt is right there in that filing, tagged
|
|
3119
|
+
// LongTermDebtAndCapitalLeaseObligations, but reported ONLY under
|
|
3120
|
+
// StatementBusinessSegmentsAxis (Company-excluding-Ford-Credit vs Ford
|
|
3121
|
+
// Credit) with no undimensioned consolidated total. companyfacts and
|
|
3122
|
+
// companyconcept expose dimensionless facts only, so the live value is
|
|
3123
|
+
// invisible to this API no matter which tag we ask for. Any filer that splits
|
|
3124
|
+
// a balance-sheet line across segments and never totals it has this hole; the
|
|
3125
|
+
// only real fix is a second ingestion path (inline XBRL / SEC's Financial
|
|
3126
|
+
// Statement Data Sets, whose num.txt carries the segments column).
|
|
3127
|
+
//
|
|
3128
|
+
// We still name the stale tag we found, so a caller who genuinely wants the
|
|
3129
|
+
// historical figure knows exactly where it is rather than being told nothing.
|
|
3130
|
+
if (found.length > 0) {
|
|
3131
|
+
const freshest = found.reduce((a, b) => ((b.latestEnd ?? '') > (a.latestEnd ?? '') ? b : a));
|
|
3132
|
+
const freshestMs = freshest.latestEnd ? new Date(freshest.latestEnd).getTime() : 0;
|
|
3133
|
+
if (freshestMs > 0 && Date.now() - freshestMs > THREE_YEARS_MS) {
|
|
3134
|
+
const years = Math.floor((Date.now() - freshestMs) / (365 * 24 * 3600 * 1000));
|
|
3135
|
+
return {
|
|
3136
|
+
cik: String(cik),
|
|
3137
|
+
company_name: resolvedEntityName || freshest.doc.entityName || null,
|
|
3138
|
+
concept,
|
|
3139
|
+
candidates_tried: candidates,
|
|
3140
|
+
period: (period ?? 'all').trim().toLowerCase(),
|
|
3141
|
+
reported: false,
|
|
3142
|
+
latest: null,
|
|
3143
|
+
values: [],
|
|
3144
|
+
stale_tag: { tag: freshest.doc.tag, latest_period_end: freshest.latestEnd, years_old: years },
|
|
3145
|
+
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}"}).`,
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
941
3149
|
if (found.length === 0) {
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
3150
|
+
// Distinguish a genuine "this filer doesn't report the concept" (every
|
|
3151
|
+
// lookup 404'd) from an upstream failure (SEC throttle / 5xx). Only the
|
|
3152
|
+
// latter should surface as an error the caller retries. A 404 after we've
|
|
3153
|
+
// exhausted us-gaap candidates + IFRS + a full company-facts name scan
|
|
3154
|
+
// means the concept is genuinely absent — for a balance-sheet item like
|
|
3155
|
+
// long-term debt on an equity-funded filer, that's the FACT "reports none",
|
|
3156
|
+
// not a failure. Return it as a clean not-reported result (usable answer,
|
|
3157
|
+
// and it stops mis-classifying as an error). Was the single largest edgar
|
|
3158
|
+
// error class (LongTermDebt on clinical-stage biotechs, 2026-07).
|
|
3159
|
+
if (lastStatus === 403 || lastStatus === 429 || lastStatus >= 500) {
|
|
3160
|
+
throw new Error(
|
|
3161
|
+
`SEC EDGAR company concept error: ${lastStatus} — SEC upstream ${lastStatus === 403 || lastStatus === 429 ? 'rate-limited' : 'unavailable'} while resolving "${concept}" for CIK ${paddedCik}. Retry shortly.`,
|
|
3162
|
+
);
|
|
3163
|
+
}
|
|
3164
|
+
// Nothing resolved the entity's NAME either, and companyfacts 404'd — so
|
|
3165
|
+
// this CIK is not a registrant, and the not-reported answer below would be
|
|
3166
|
+
// a statement about a company that does not exist ("this filer reports no
|
|
3167
|
+
// tag" implies a filer). Requires BOTH conditions: a real filer whose
|
|
3168
|
+
// companyfacts merely 404s still has resolvedEntityName from elsewhere and
|
|
3169
|
+
// keeps the deliberate not-reported behaviour.
|
|
3170
|
+
if (!resolvedEntityName && lastStatus === 404) {
|
|
3171
|
+
throw submissionsError(404, cikOrTicker, paddedCik);
|
|
3172
|
+
}
|
|
3173
|
+
return {
|
|
3174
|
+
cik: String(cik),
|
|
3175
|
+
company_name: resolvedEntityName || null,
|
|
3176
|
+
concept,
|
|
3177
|
+
candidates_tried: candidates,
|
|
3178
|
+
period: (period ?? 'all').trim().toLowerCase(),
|
|
3179
|
+
reported: false,
|
|
3180
|
+
latest: null,
|
|
3181
|
+
values: [],
|
|
3182
|
+
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.`,
|
|
3183
|
+
};
|
|
945
3184
|
}
|
|
946
3185
|
found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
|
|
947
3186
|
const data = found[0].doc;
|
|
@@ -959,14 +3198,29 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
959
3198
|
// caught this.
|
|
960
3199
|
// - DO dedupe: a 10-K and its 10-K/A amendments both report the same
|
|
961
3200
|
// (fy, fp, end) tuple. Keep the most-recently-filed version.
|
|
962
|
-
type Raw = { end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
|
|
3201
|
+
type Raw = { start?: string; end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
|
|
3202
|
+
const spanDays = (a?: string, b?: string) => (a && b) ? Math.round((Date.parse(b) - Date.parse(a)) / 86400000) : NaN;
|
|
3203
|
+
// Does this fact's DURATION match its period label? A 10-Q files BOTH the
|
|
3204
|
+
// discrete 3-month quarter AND the year-to-date figure under the same
|
|
3205
|
+
// (fy, fp, end) tuple, filed the same day — so "most recently filed" was a
|
|
3206
|
+
// coin toss between them, and the YTD often won: "AAPL revenue Q3" answered
|
|
3207
|
+
// with NINE MONTHS of revenue labeled Q3 (found via fleet #250). Prefer the
|
|
3208
|
+
// fact whose span matches the label; fall back to filing recency only
|
|
3209
|
+
// between facts of the same shape.
|
|
3210
|
+
const labelFits = (e: Raw): boolean => {
|
|
3211
|
+
const span = spanDays(e.start, e.end);
|
|
3212
|
+
if (Number.isNaN(span)) return false; // instant concepts have no start; nothing to prefer
|
|
3213
|
+
return /^Q[123]$/.test(e.fp ?? '') ? (span >= 80 && span <= 100) : (span >= 330 && span <= 400);
|
|
3214
|
+
};
|
|
963
3215
|
const dedup = new Map<string, { entry: Raw; unit: string }>();
|
|
964
|
-
for (const [unit, values] of
|
|
3216
|
+
for (const [unit, values] of unitEntries(data.units)) {
|
|
965
3217
|
for (const e of values) {
|
|
966
|
-
if (!
|
|
3218
|
+
if (!forms.has(e.form)) continue; // period-scoped: annual (10-K/20-F/40-F), quarterly (10-Q), or all
|
|
967
3219
|
const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
|
|
968
3220
|
const prior = dedup.get(key);
|
|
969
|
-
if (!prior
|
|
3221
|
+
if (!prior
|
|
3222
|
+
|| (labelFits(e) && !labelFits(prior.entry))
|
|
3223
|
+
|| (labelFits(e) === labelFits(prior.entry) && (e.filed ?? '') > (prior.entry.filed ?? ''))) {
|
|
970
3224
|
dedup.set(key, { entry: e, unit });
|
|
971
3225
|
}
|
|
972
3226
|
}
|
|
@@ -974,20 +3228,235 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
974
3228
|
const entries = [...dedup.values()]
|
|
975
3229
|
.map(({ entry, unit }) => ({
|
|
976
3230
|
fiscal_year: entry.fy,
|
|
3231
|
+
fiscal_period: entry.fp ?? 'FY', // FY (annual) | Q1 | Q2 | Q3
|
|
3232
|
+
period_start: entry.start,
|
|
977
3233
|
period_end: entry.end,
|
|
978
3234
|
value: entry.val,
|
|
3235
|
+
form: entry.form, // 10-K / 10-Q / 20-F / 40-F (+ /A amendments)
|
|
979
3236
|
filed: entry.filed,
|
|
980
3237
|
unit,
|
|
3238
|
+
derived: false as boolean | undefined,
|
|
3239
|
+
note: undefined as string | undefined,
|
|
981
3240
|
}))
|
|
982
3241
|
.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
983
3242
|
|
|
3243
|
+
// Derive standalone Q4 rows. SEC filers report Q1-Q3 in 10-Qs and the full
|
|
3244
|
+
// year in the 10-K — a standalone Q4 fact does not exist in XBRL, so a
|
|
3245
|
+
// question like "Apple revenue Q4 2024" was structurally unanswerable from
|
|
3246
|
+
// this payload, and callers reached for `latest` instead (a confidently
|
|
3247
|
+
// wrong period — fleet #250). Q4 = FY − Q1 − Q2 − Q3, computed only when
|
|
3248
|
+
// the arithmetic is actually valid:
|
|
3249
|
+
// - duration concepts only (rows carry period_start). Instant concepts
|
|
3250
|
+
// (cash, assets) are point-in-time — the FY value IS the year-end
|
|
3251
|
+
// reading and subtraction is meaningless.
|
|
3252
|
+
// - not per-share/ratio units ("USD/shares"): EPS does not sum across
|
|
3253
|
+
// quarters when the share count moves.
|
|
3254
|
+
// - the FY row must span 330-400 days. 10-K comparative periods are
|
|
3255
|
+
// reported under the FILING's fiscal year (a fy-2019 row can carry
|
|
3256
|
+
// fiscal 2017 dates — the known contamination trap), so trusting `fy`
|
|
3257
|
+
// alone subtracts quarters from the wrong year; the date window is
|
|
3258
|
+
// what makes the row the year it claims to be.
|
|
3259
|
+
// - exactly 3 distinct ~quarter-length (80-100d) windows inside the FY
|
|
3260
|
+
// window. Fewer means a quarter is missing; more means duplicate or
|
|
3261
|
+
// YTD rows slipped in, and either way the subtraction is garbage.
|
|
3262
|
+
const derivedQ4: typeof entries = [];
|
|
3263
|
+
for (const fyRow of entries) {
|
|
3264
|
+
if (fyRow.fiscal_period !== 'FY' || fyRow.unit.includes('/')) continue;
|
|
3265
|
+
const span = spanDays(fyRow.period_start, fyRow.period_end);
|
|
3266
|
+
if (!(span >= 330 && span <= 400)) continue;
|
|
3267
|
+
const quarters = new Map<string, typeof entries[number]>();
|
|
3268
|
+
for (const q of entries) {
|
|
3269
|
+
if (!/^Q[123]$/.test(q.fiscal_period) || q.unit !== fyRow.unit) continue;
|
|
3270
|
+
const qSpan = spanDays(q.period_start, q.period_end);
|
|
3271
|
+
if (!(qSpan >= 80 && qSpan <= 100)) continue;
|
|
3272
|
+
if (!q.period_start || !fyRow.period_start || q.period_start < fyRow.period_start || q.period_end > fyRow.period_end) continue;
|
|
3273
|
+
quarters.set(`${q.period_start}|${q.period_end}`, q);
|
|
3274
|
+
}
|
|
3275
|
+
if (quarters.size !== 3) continue;
|
|
3276
|
+
const qSum = [...quarters.values()].reduce((s, q) => s + q.value, 0);
|
|
3277
|
+
derivedQ4.push({
|
|
3278
|
+
fiscal_year: fyRow.fiscal_year,
|
|
3279
|
+
fiscal_period: 'Q4',
|
|
3280
|
+
period_start: undefined,
|
|
3281
|
+
period_end: fyRow.period_end,
|
|
3282
|
+
value: fyRow.value - qSum,
|
|
3283
|
+
form: 'derived',
|
|
3284
|
+
filed: fyRow.filed,
|
|
3285
|
+
unit: fyRow.unit,
|
|
3286
|
+
derived: true,
|
|
3287
|
+
note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
if (derivedQ4.length > 0) {
|
|
3291
|
+
// A fiscal year re-reported as a 10-K comparative period spans the same
|
|
3292
|
+
// dates as the original FY row and would mint a duplicate Q4 — keep one
|
|
3293
|
+
// derivation per (period_end, unit). Prefer the LOWEST fiscal_year label:
|
|
3294
|
+
// comparatives carry the FILING's fy over earlier dates (a fy-2026 row
|
|
3295
|
+
// spanning fiscal 2024), so the lowest label per window is the original
|
|
3296
|
+
// 10-K's, the one whose year actually names the window.
|
|
3297
|
+
const byWindow = new Map<string, typeof entries[number]>();
|
|
3298
|
+
for (const d of derivedQ4) {
|
|
3299
|
+
const k = `${d.period_end}|${d.unit}`;
|
|
3300
|
+
const prior = byWindow.get(k);
|
|
3301
|
+
if (!prior || (d.fiscal_year ?? Infinity) < (prior.fiscal_year ?? Infinity)) byWindow.set(k, d);
|
|
3302
|
+
}
|
|
3303
|
+
entries.push(...byWindow.values());
|
|
3304
|
+
entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
// PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
|
|
3308
|
+
// names of two fields on every row below, and callers asking a one-period
|
|
3309
|
+
// question ("net income fiscal 2024") send them as ARGUMENTS — measured on
|
|
3310
|
+
// every partial-outcome call this tool logged over three days. They used to
|
|
3311
|
+
// be undeclared, so the gateway dropped them and the caller got all 89-230
|
|
3312
|
+
// periods back with no indication its filter had been ignored: the
|
|
3313
|
+
// dropped-filter-returns-too-much shape, where whichever period the
|
|
3314
|
+
// synthesizer happens to read becomes the answer. Honor them instead.
|
|
3315
|
+
//
|
|
3316
|
+
// An unmatched filter returns NO rows and says which periods exist, rather
|
|
3317
|
+
// than falling back to every period — a filter that silently widens is how a
|
|
3318
|
+
// confidently-wrong year gets reported, and `latest` is deliberately null so
|
|
3319
|
+
// there is no plausible number sitting there to be mistaken for the one
|
|
3320
|
+
// asked for.
|
|
3321
|
+
const fyWanted = fiscalYearArg === undefined || fiscalYearArg === null || String(fiscalYearArg).trim() === ''
|
|
3322
|
+
? null
|
|
3323
|
+
: String(fiscalYearArg).trim();
|
|
3324
|
+
const fpWanted = typeof fiscalPeriodArg === 'string' && fiscalPeriodArg.trim()
|
|
3325
|
+
? fiscalPeriodArg.trim().toUpperCase()
|
|
3326
|
+
: null;
|
|
3327
|
+
let periodFilter: Record<string, unknown> | undefined;
|
|
3328
|
+
let rows = entries;
|
|
3329
|
+
if (fyWanted || fpWanted) {
|
|
3330
|
+
const matched = entries.filter((e) =>
|
|
3331
|
+
(!fyWanted || String(e.fiscal_year) === fyWanted)
|
|
3332
|
+
&& (!fpWanted || String(e.fiscal_period).toUpperCase() === fpWanted));
|
|
3333
|
+
const availableYears = [...new Set(entries.map((e) => e.fiscal_year).filter((y) => y != null))].sort((a, b) => Number(b) - Number(a));
|
|
3334
|
+
const availablePeriods = [...new Set(entries.map((e) => String(e.fiscal_period)))].sort();
|
|
3335
|
+
rows = matched;
|
|
3336
|
+
periodFilter = {
|
|
3337
|
+
fiscal_year: fyWanted,
|
|
3338
|
+
fiscal_period: fpWanted,
|
|
3339
|
+
matched: matched.length,
|
|
3340
|
+
of_periods_reported: entries.length,
|
|
3341
|
+
...(matched.length === 0
|
|
3342
|
+
? {
|
|
3343
|
+
available_fiscal_years: availableYears,
|
|
3344
|
+
available_fiscal_periods: availablePeriods,
|
|
3345
|
+
note:
|
|
3346
|
+
`No row matches ${[fyWanted ? `fiscal_year ${fyWanted}` : null, fpWanted ? `fiscal_period ${fpWanted}` : null].filter(Boolean).join(' + ')} for "${data.tag}". `
|
|
3347
|
+
+ `values is EMPTY and latest is null on purpose — this filer's reported periods are ${availableYears.length ? `fiscal years ${availableYears.join(', ')}` : '(none)'}`
|
|
3348
|
+
+ `${availablePeriods.length ? ` and periods ${availablePeriods.join(', ')}` : ''}, so no number here is the one you asked for. `
|
|
3349
|
+
+ `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.`,
|
|
3350
|
+
}
|
|
3351
|
+
: {}),
|
|
3352
|
+
// A matched filter is not automatically an unambiguous one. A 10-K tags
|
|
3353
|
+
// its COMPARATIVE periods under the filing's own fiscal year, so
|
|
3354
|
+
// fiscal_year 2023 on ITW returns three rows all labeled 2023 with
|
|
3355
|
+
// period_end 2023-12-31, 2022-12-31 and 2021-12-31 (verified live).
|
|
3356
|
+
// Every one is a real filed fact; only the first is the year the label
|
|
3357
|
+
// names. `values` is sorted period_end DESC and `latest` is already that
|
|
3358
|
+
// first row, so the right answer is what a caller gets by default — but
|
|
3359
|
+
// say so here rather than leaving three same-labeled numbers to be
|
|
3360
|
+
// picked between, which is the confidently-wrong-period failure this
|
|
3361
|
+
// filter exists to prevent.
|
|
3362
|
+
...((() => {
|
|
3363
|
+
if (matched.length < 2) return {};
|
|
3364
|
+
const ends = [...new Set(matched.map((e) => e.period_end))];
|
|
3365
|
+
if (ends.length < 2) return {};
|
|
3366
|
+
return {
|
|
3367
|
+
ambiguity_note:
|
|
3368
|
+
`${matched.length} rows carry the label fiscal_year ${matched[0].fiscal_year}, covering ${ends.length} different periods (${ends.join(', ')}). `
|
|
3369
|
+
+ `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. `
|
|
3370
|
+
+ `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.`,
|
|
3371
|
+
};
|
|
3372
|
+
})()),
|
|
3373
|
+
};
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
// SUBSTITUTION DISCLOSURE (fleet #219).
|
|
3377
|
+
//
|
|
3378
|
+
// The name-scan fallback above matches any us-gaap tag CONTAINING a candidate
|
|
3379
|
+
// token, which is what makes it useful — filers rename things constantly, and
|
|
3380
|
+
// finding LongTermDebtAndCapitalLeaseObligations when you asked for
|
|
3381
|
+
// LongTermDebt is the whole point. But containment cuts both ways: asking for
|
|
3382
|
+
// InterestExpense matches FinanceLeaseInterestExpense, and Delta then returns
|
|
3383
|
+
// $37M of finance-lease interest as though it were total interest expense —
|
|
3384
|
+
// off by an order of magnitude, well-formed, and with nothing on it to say so.
|
|
3385
|
+
// A credit analyst reads that number and misjudges the debt burden.
|
|
3386
|
+
//
|
|
3387
|
+
// `concept` already carries the tag actually served, so a careful caller
|
|
3388
|
+
// COULD notice. Nobody does: the synthesizing model reads a plausible number
|
|
3389
|
+
// under a plausible label and reports it. This file already refuses to hand
|
|
3390
|
+
// back a stale figure without a marker, for exactly the reason stated in the
|
|
3391
|
+
// comment above — "a well-formed, plausible answer with no marker on it, so
|
|
3392
|
+
// neither the caller nor any automatic check can tell it apart". A
|
|
3393
|
+
// substituted tag is the same shape, and gets the same treatment.
|
|
3394
|
+
const requested = concept.trim();
|
|
3395
|
+
const substituted = requested.length > 0
|
|
3396
|
+
&& data.tag.toLowerCase().replace(/[^a-z0-9]/g, '') !== requested.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
3397
|
+
|
|
3398
|
+
// Coverage-bound disclosure (fleet #693). A returned value can be genuinely
|
|
3399
|
+
// CURRENT for this exact XBRL tag while a newer periodic filing exists that
|
|
3400
|
+
// simply doesn't reuse the tag — verified live on Brown-Forman (CIK 14693):
|
|
3401
|
+
// its FY2026 10-K (filed 2026-06-12) tags dozens of concepts but never
|
|
3402
|
+
// us-gaap:CommonStockSharesOutstanding, and dei:EntityCommonStockSharesOutstanding
|
|
3403
|
+
// is 404 across BF's ENTIRE XBRL history (confirmed via companyfacts — not a
|
|
3404
|
+
// one-year gap, this filer has never used that dei tag). SEC's own
|
|
3405
|
+
// companyconcept API is correct as far as it goes; the gap is upstream, in
|
|
3406
|
+
// what the filer chose to tag. That's invisible to the 3-year stale-tag
|
|
3407
|
+
// guard above (BF is ~15mo, not ~3y) and reads exactly like a current
|
|
3408
|
+
// answer with nothing on it to say otherwise — the same failure shape that
|
|
3409
|
+
// guard exists to prevent, just under its threshold. Flag it whenever the
|
|
3410
|
+
// freshest fact is older than one normal filing cycle for its own form
|
|
3411
|
+
// type. No extra fetch: compare the fact's own `filed` date to today.
|
|
3412
|
+
// Filtered rows, not every row: with fiscal_year/fiscal_period set, `latest`
|
|
3413
|
+
// must be the freshest fact WITHIN the period asked for, or the one field a
|
|
3414
|
+
// synthesizer reaches for first would answer a different year than the
|
|
3415
|
+
// question (fleet #2058).
|
|
3416
|
+
const latestFact = rows.find((e) => !e.derived) ?? null;
|
|
3417
|
+
let coverageBound: string | undefined;
|
|
3418
|
+
if (latestFact?.filed) {
|
|
3419
|
+
const daysSinceFiled = (Date.now() - Date.parse(latestFact.filed)) / 86400000;
|
|
3420
|
+
const isAnnual = ANNUAL_FORMS.has(latestFact.form);
|
|
3421
|
+
const cycleDays = isAnnual ? 400 : 120; // ~13mo (annual cadence + filing-lag grace) / ~4mo (quarterly + grace)
|
|
3422
|
+
if (Number.isFinite(daysSinceFiled) && daysSinceFiled > cycleDays) {
|
|
3423
|
+
coverageBound =
|
|
3424
|
+
`This is the freshest value SEC's XBRL API has for "${data.tag}" — from a ${latestFact.form} filed ` +
|
|
3425
|
+
`${latestFact.filed}, ${Math.floor(daysSinceFiled)} days ago, past the normal ${isAnnual ? 'annual' : 'quarterly'} filing cycle. ` +
|
|
3426
|
+
`If ${data.entityName} has filed a newer ${isAnnual ? '10-K' : '10-Q'} since then, it may simply not tag this specific concept in it ` +
|
|
3427
|
+
`(confirmed on Brown-Forman's FY2026 10-K: it tags hundreds of other concepts, not this one, and never tags the dei cover-page ` +
|
|
3428
|
+
`share count either) — this is not necessarily a gap in our data. Check edgar_company_filings({ticker_or_cik:"${cikOrTicker}"}) ` +
|
|
3429
|
+
`for filings newer than ${latestFact.filed}, and edgar_company_facts({cik:"${String(data.cik)}"}) for every tag the filer currently uses.`;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
984
3433
|
return {
|
|
985
3434
|
cik: String(data.cik),
|
|
986
3435
|
company_name: data.entityName,
|
|
987
3436
|
concept: data.tag,
|
|
3437
|
+
...(substituted
|
|
3438
|
+
? {
|
|
3439
|
+
requested_concept: requested,
|
|
3440
|
+
concept_substituted: true,
|
|
3441
|
+
substitution_note:
|
|
3442
|
+
`You asked for "${requested}"; this filer does not report that exact tag, so the closest matching tag ` +
|
|
3443
|
+
`"${data.tag}" (${data.label}) is returned instead. These are NOT interchangeable — a narrower tag can be ` +
|
|
3444
|
+
`a small fraction of the concept you asked for. Do NOT present this as "${requested}" without saying which ` +
|
|
3445
|
+
`tag it came from. Call edgar_company_facts({cik:"${String(data.cik)}"}) to see every tag this filer uses.`,
|
|
3446
|
+
}
|
|
3447
|
+
: {}),
|
|
988
3448
|
label: data.label,
|
|
989
3449
|
description: data.description,
|
|
990
|
-
|
|
3450
|
+
period: (period ?? 'all').trim().toLowerCase(),
|
|
3451
|
+
// Freshest data point across the requested period — use THIS for
|
|
3452
|
+
// point-in-time metrics (cash, runway, debt); it is the latest 10-Q when
|
|
3453
|
+
// one is more recent than the last 10-K, so an annual figure never masks a
|
|
3454
|
+
// newer quarter. Always a REPORTED fact, never a derived Q4.
|
|
3455
|
+
latest: latestFact,
|
|
3456
|
+
...(coverageBound ? { coverage_bound: coverageBound } : {}),
|
|
3457
|
+
...(periodFilter ? { period_filter: periodFilter } : {}),
|
|
3458
|
+
values: rows,
|
|
3459
|
+
annual_values: rows, // deprecated alias; prefer `values` (now period-scoped)
|
|
991
3460
|
};
|
|
992
3461
|
}
|
|
993
3462
|
|
|
@@ -1019,9 +3488,13 @@ function xmlVal(block: string, tag: string): string | null {
|
|
|
1019
3488
|
return plain ? plain[1].trim() : null;
|
|
1020
3489
|
}
|
|
1021
3490
|
|
|
3491
|
+
// Tolerates an attribute-bearing opening tag (`<tr class="ro">`, `<Report
|
|
3492
|
+
// instance="...">`) as well as a bare one — a strict superset of matching
|
|
3493
|
+
// `<tag>` only, so every existing plain-XML caller (Form 4/N-PORT, no
|
|
3494
|
+
// attributes) still matches exactly the same blocks it always did.
|
|
1022
3495
|
function xmlBlocks(xml: string, tag: string): string[] {
|
|
1023
3496
|
const out: string[] = [];
|
|
1024
|
-
const re = new RegExp(`<${tag}
|
|
3497
|
+
const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, 'gi');
|
|
1025
3498
|
let m: RegExpExecArray | null;
|
|
1026
3499
|
while ((m = re.exec(xml)) !== null) out.push(m[1]);
|
|
1027
3500
|
return out;
|
|
@@ -1089,8 +3562,8 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
|
|
|
1089
3562
|
const paddedCik = padCik(cik);
|
|
1090
3563
|
const count = Math.min(25, Math.max(1, limit ?? 10));
|
|
1091
3564
|
|
|
1092
|
-
const res = await
|
|
1093
|
-
if (!res.ok) throw
|
|
3565
|
+
const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
3566
|
+
if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
|
|
1094
3567
|
const data = (await res.json()) as {
|
|
1095
3568
|
cik: string;
|
|
1096
3569
|
name: string;
|
|
@@ -1120,7 +3593,7 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
|
|
|
1120
3593
|
const rawDoc = t.doc.replace(/^xsl[^/]*\//i, '');
|
|
1121
3594
|
const url = `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accPath}/${rawDoc}`;
|
|
1122
3595
|
try {
|
|
1123
|
-
const r = await
|
|
3596
|
+
const r = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
1124
3597
|
if (!r.ok) return { accession_number: t.accession, filing_date: t.date, form: t.form, error: `fetch ${r.status}`, filing_url: url };
|
|
1125
3598
|
const xml = await r.text();
|
|
1126
3599
|
const parsed = parseForm4Transactions(xml, includeDerivatives ?? false);
|
|
@@ -1162,8 +3635,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1162
3635
|
const paddedCik = padCik(cik);
|
|
1163
3636
|
const topN = Math.min(100, Math.max(1, limit ?? 25));
|
|
1164
3637
|
|
|
1165
|
-
const subRes = await
|
|
1166
|
-
if (!subRes.ok) throw
|
|
3638
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
3639
|
+
if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
|
|
1167
3640
|
const sub = (await subRes.json()) as {
|
|
1168
3641
|
cik: string;
|
|
1169
3642
|
name: string;
|
|
@@ -1188,8 +3661,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1188
3661
|
// is neither primary_doc.xml (cover page) nor an xsl-rendered viewer copy.
|
|
1189
3662
|
const accPath = target.accession.replace(/-/g, '');
|
|
1190
3663
|
const folder = `https://www.sec.gov/Archives/edgar/data/${sub.cik}/${accPath}`;
|
|
1191
|
-
const idxRes = await
|
|
1192
|
-
if (!idxRes.ok) throw
|
|
3664
|
+
const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
|
|
3665
|
+
if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
|
|
1193
3666
|
const idx = (await idxRes.json()) as { directory: { item: { name: string }[] } };
|
|
1194
3667
|
const infoFile = idx.directory.item.find(
|
|
1195
3668
|
(it) => it.name.toLowerCase().endsWith('.xml') && it.name !== 'primary_doc.xml' && !/^xsl/i.test(it.name),
|
|
@@ -1198,8 +3671,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1198
3671
|
throw new Error(`13F information table not found in filing ${target.accession}.`);
|
|
1199
3672
|
}
|
|
1200
3673
|
|
|
1201
|
-
const tableRes = await
|
|
1202
|
-
if (!tableRes.ok) throw
|
|
3674
|
+
const tableRes = await pwFetch(`${folder}/${infoFile.name}`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
3675
|
+
if (!tableRes.ok) throw await httpError(tableRes, 'SEC EDGAR 13F table error');
|
|
1203
3676
|
const xml = stripNs(await tableRes.text());
|
|
1204
3677
|
|
|
1205
3678
|
// Aggregate rows by issuer+cusip+putCall (managers file multiple sub-portfolio
|
|
@@ -1224,15 +3697,44 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1224
3697
|
|
|
1225
3698
|
const all = [...byKey.values()].sort((a, b) => b.value_usd - a.value_usd);
|
|
1226
3699
|
const totalValue = all.reduce((s, h) => s + h.value_usd, 0);
|
|
3700
|
+
|
|
3701
|
+
// A 13F row for an OPTION is not ownership of the underlying, and a PUT is a
|
|
3702
|
+
// BEARISH position. Both facts are carried only by the `putCall` tag, which a
|
|
3703
|
+
// reader can easily skip — so a value-ranked list leads with "PALANTIR ...
|
|
3704
|
+
// 66.04%" for a manager who is in fact positioned AGAINST Palantir via puts.
|
|
3705
|
+
// Misreporting Scion's puts as top holdings is a well-known failure mode. We
|
|
3706
|
+
// already parse putCall; the job here is to make it impossible to miss —
|
|
3707
|
+
// label direction per row and total the legs separately so nothing sums
|
|
3708
|
+
// bullish and bearish notional into one "portfolio".
|
|
3709
|
+
const isPut = (pc: string | null) => (pc ?? '').toLowerCase() === 'put';
|
|
3710
|
+
const isCall = (pc: string | null) => (pc ?? '').toLowerCase() === 'call';
|
|
3711
|
+
const directionOf = (pc: string | null) =>
|
|
3712
|
+
isPut(pc) ? 'bearish (put option)' : isCall(pc) ? 'bullish (call option)' : 'long equity';
|
|
3713
|
+
|
|
3714
|
+
const sumWhere = (pred: (h: Agg) => boolean) =>
|
|
3715
|
+
all.filter(pred).reduce((s, h) => s + h.value_usd, 0);
|
|
3716
|
+
const longEquityValue = sumWhere((h) => !isPut(h.put_call) && !isCall(h.put_call));
|
|
3717
|
+
const putValue = sumWhere((h) => isPut(h.put_call));
|
|
3718
|
+
const callValue = sumWhere((h) => isCall(h.put_call));
|
|
3719
|
+
|
|
1227
3720
|
const holdings = all.slice(0, topN).map((h) => ({
|
|
1228
3721
|
issuer: h.issuer,
|
|
1229
3722
|
cusip: h.cusip,
|
|
1230
3723
|
put_call: h.put_call,
|
|
3724
|
+
direction: directionOf(h.put_call),
|
|
1231
3725
|
value_usd: h.value_usd,
|
|
1232
3726
|
shares: h.shares,
|
|
3727
|
+
// Retained for compatibility, but it divides by a total that mixes option
|
|
3728
|
+
// notional with equity; pct_of_long_equity is the one to rank holdings by.
|
|
1233
3729
|
pct_of_portfolio: totalValue > 0 ? Math.round((h.value_usd / totalValue) * 10000) / 100 : null,
|
|
3730
|
+
pct_of_long_equity:
|
|
3731
|
+
!isPut(h.put_call) && !isCall(h.put_call) && longEquityValue > 0
|
|
3732
|
+
? Math.round((h.value_usd / longEquityValue) * 10000) / 100
|
|
3733
|
+
: null,
|
|
1234
3734
|
}));
|
|
1235
3735
|
|
|
3736
|
+
const hasOptions = putValue > 0 || callValue > 0;
|
|
3737
|
+
|
|
1236
3738
|
return {
|
|
1237
3739
|
cik: sub.cik,
|
|
1238
3740
|
manager_name: sub.name,
|
|
@@ -1240,6 +3742,20 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1240
3742
|
report_period: target.period,
|
|
1241
3743
|
filed_date: target.filed,
|
|
1242
3744
|
total_portfolio_value_usd: totalValue,
|
|
3745
|
+
// Split the single "portfolio value" into legs that mean different things.
|
|
3746
|
+
// For options these are the NOTIONAL value of the underlying shares, not
|
|
3747
|
+
// premium paid — a $912M put line is not $912M of capital at risk.
|
|
3748
|
+
position_summary: {
|
|
3749
|
+
long_equity_value_usd: longEquityValue,
|
|
3750
|
+
put_option_notional_usd: putValue,
|
|
3751
|
+
call_option_notional_usd: callValue,
|
|
3752
|
+
long_equity_positions: all.filter((h) => !isPut(h.put_call) && !isCall(h.put_call)).length,
|
|
3753
|
+
put_positions: all.filter((h) => isPut(h.put_call)).length,
|
|
3754
|
+
call_positions: all.filter((h) => isCall(h.put_call)).length,
|
|
3755
|
+
},
|
|
3756
|
+
interpretation_note: hasOptions
|
|
3757
|
+
? '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.'
|
|
3758
|
+
: 'All positions are long equity (no options in this filing).',
|
|
1243
3759
|
total_positions: all.length,
|
|
1244
3760
|
holdings_returned: holdings.length,
|
|
1245
3761
|
holdings,
|
|
@@ -1251,6 +3767,9 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
1251
3767
|
function decodeEntities(s: string | null): string | null {
|
|
1252
3768
|
if (s == null) return null;
|
|
1253
3769
|
return s
|
|
3770
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
3771
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
3772
|
+
.replace(/ /gi, ' ')
|
|
1254
3773
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1255
3774
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'");
|
|
1256
3775
|
}
|
|
@@ -1260,8 +3779,8 @@ function decodeEntities(s: string | null): string | null {
|
|
|
1260
3779
|
let mfTickerCache: Map<string, { cik: string; seriesId: string }> | null = null;
|
|
1261
3780
|
async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesId: string } | null> {
|
|
1262
3781
|
if (!mfTickerCache) {
|
|
1263
|
-
const res = await
|
|
1264
|
-
if (!res.ok) throw
|
|
3782
|
+
const res = await pwFetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
|
|
3783
|
+
if (!res.ok) throw await httpError(res, 'SEC fund-ticker lookup error');
|
|
1265
3784
|
const data = (await res.json()) as { fields: string[]; data: Array<[number, string, string, string]> };
|
|
1266
3785
|
// fields: [cik, seriesId, classId, symbol]
|
|
1267
3786
|
mfTickerCache = new Map();
|
|
@@ -1274,7 +3793,11 @@ async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesI
|
|
|
1274
3793
|
|
|
1275
3794
|
async function fundHoldings(ticker: string, limit?: number) {
|
|
1276
3795
|
const t = String(ticker ?? '').trim();
|
|
1277
|
-
if (!t) throw new Error(
|
|
3796
|
+
if (!t) throw new Error(
|
|
3797
|
+
'A fund ticker is required: pass `ticker`, or its alias `ticker_or_cik` — an ETF or ' +
|
|
3798
|
+
'mutual-fund ticker like "ARKK", "QQQ" or "VOO". N-PORT funds are keyed by ticker, so a ' +
|
|
3799
|
+
'bare CIK has nothing to match here. Example: edgar_fund_holdings({ticker: "ARKK"}).'
|
|
3800
|
+
);
|
|
1278
3801
|
const topN = Math.min(100, Math.max(1, limit ?? 25));
|
|
1279
3802
|
|
|
1280
3803
|
const resolved = await resolveFundTicker(t);
|
|
@@ -1284,8 +3807,8 @@ async function fundHoldings(ticker: string, limit?: number) {
|
|
|
1284
3807
|
const { cik, seriesId } = resolved;
|
|
1285
3808
|
const paddedCik = padCik(cik);
|
|
1286
3809
|
|
|
1287
|
-
const subRes = await
|
|
1288
|
-
if (!subRes.ok) throw
|
|
3810
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
3811
|
+
if (!subRes.ok) throw submissionsError(subRes.status, t, paddedCik);
|
|
1289
3812
|
const sub = (await subRes.json()) as {
|
|
1290
3813
|
cik: string; name: string;
|
|
1291
3814
|
filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[] } };
|
|
@@ -1306,7 +3829,7 @@ async function fundHoldings(ticker: string, limit?: number) {
|
|
|
1306
3829
|
const accPath = f.accession.replace(/-/g, '');
|
|
1307
3830
|
const url = `https://www.sec.gov/Archives/edgar/data/${cik}/${accPath}/primary_doc.xml`;
|
|
1308
3831
|
try {
|
|
1309
|
-
const res = await
|
|
3832
|
+
const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
1310
3833
|
if (!res.ok) return null;
|
|
1311
3834
|
return { date: f.date, accession: f.accession, xml: stripNs(await res.text()) };
|
|
1312
3835
|
} catch {
|
|
@@ -1345,8 +3868,657 @@ async function fundHoldings(ticker: string, limit?: number) {
|
|
|
1345
3868
|
};
|
|
1346
3869
|
}
|
|
1347
3870
|
|
|
3871
|
+
// ── Companies by SIC (peer / competitor lookup) ─────────────────────
|
|
3872
|
+
//
|
|
3873
|
+
// EDGAR exposes an SIC (Standard Industrial Classification) code on every
|
|
3874
|
+
// filer's submissions record, but nothing in this pack (or the sibling `sec`
|
|
3875
|
+
// pack) could go the other direction — SIC -> list of filers. compare_entities
|
|
3876
|
+
// only compares companies already named; there was no way to FIND the peer
|
|
3877
|
+
// set. Fleet #689.
|
|
3878
|
+
//
|
|
3879
|
+
// Upstream: browse-edgar's `output=atom` company-search. Two upstream quirks,
|
|
3880
|
+
// both verified live (curl, 2026-08-30):
|
|
3881
|
+
// 1. Leaving `type` blank (or omitted) makes the endpoint hang/stall rather
|
|
3882
|
+
// than erroring — confirmed by a request that never returned inside 120s.
|
|
3883
|
+
// Always pass a non-empty `type`; default it to "10-K" (active reporters).
|
|
3884
|
+
// 2. The atom feed's <entry title> and <company-info name> are both the
|
|
3885
|
+
// literal string "ARRAY(0x...)" — a long-standing bug on SEC's side where
|
|
3886
|
+
// the feed serializes a PHP/Perl array ref instead of the company name.
|
|
3887
|
+
// Only <cik> is usable from the feed itself, so names/tickers are joined
|
|
3888
|
+
// in afterward from the company_tickers.json bulk file (already used by
|
|
3889
|
+
// sponsor_to_filer) — which only covers filers with a listed ticker.
|
|
3890
|
+
// Filers without one still appear, with ticker/company_name left null
|
|
3891
|
+
// rather than silently dropped (SEC registrants that aren't equity-listed
|
|
3892
|
+
// are still real peers for some questions, e.g. "who else files in SIC X").
|
|
3893
|
+
|
|
3894
|
+
interface SicPeer { cik: string; ticker: string | null; company_name: string | null }
|
|
3895
|
+
|
|
3896
|
+
async function resolveEntitySic(tickerOrCik: string): Promise<{ cik: string; name: string | null; sic: string | null; sic_description: string | null }> {
|
|
3897
|
+
const cik = await resolveCik(tickerOrCik);
|
|
3898
|
+
const paddedCik = padCik(cik);
|
|
3899
|
+
const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
3900
|
+
if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
|
|
3901
|
+
const data = (await res.json()) as { name?: string; sic?: string; sicDescription?: string };
|
|
3902
|
+
return {
|
|
3903
|
+
cik: String(Number(cik)),
|
|
3904
|
+
name: data.name ?? null,
|
|
3905
|
+
sic: data.sic && data.sic.trim() ? data.sic.trim() : null,
|
|
3906
|
+
sic_description: data.sicDescription ?? null,
|
|
3907
|
+
};
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
async function companiesBySic(
|
|
3911
|
+
sicArg: string | undefined,
|
|
3912
|
+
tickerOrCik: string | undefined,
|
|
3913
|
+
formTypeArg: string | undefined,
|
|
3914
|
+
excludeSelfArg: boolean | undefined,
|
|
3915
|
+
limitArg: number | undefined,
|
|
3916
|
+
): Promise<unknown> {
|
|
3917
|
+
let sic = sicArg?.trim();
|
|
3918
|
+
let sicDescription: string | null = null;
|
|
3919
|
+
let selfCik: string | null = null;
|
|
3920
|
+
let selfName: string | null = null;
|
|
3921
|
+
|
|
3922
|
+
if (!sic) {
|
|
3923
|
+
if (!tickerOrCik || !tickerOrCik.trim()) {
|
|
3924
|
+
return {
|
|
3925
|
+
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.',
|
|
3926
|
+
retry_hint: 'edgar_companies_by_sic({ticker_or_cik: "AAPL"}) or edgar_companies_by_sic({sic: "3571"}).',
|
|
3927
|
+
};
|
|
3928
|
+
}
|
|
3929
|
+
const resolved = await resolveEntitySic(tickerOrCik);
|
|
3930
|
+
if (!resolved.sic) {
|
|
3931
|
+
return {
|
|
3932
|
+
status: 'no_sic',
|
|
3933
|
+
cik: resolved.cik,
|
|
3934
|
+
company_name: resolved.name,
|
|
3935
|
+
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.`,
|
|
3936
|
+
};
|
|
3937
|
+
}
|
|
3938
|
+
sic = resolved.sic;
|
|
3939
|
+
sicDescription = resolved.sic_description;
|
|
3940
|
+
selfCik = resolved.cik;
|
|
3941
|
+
selfName = resolved.name;
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
if (!/^\d{1,4}$/.test(sic)) {
|
|
3945
|
+
return {
|
|
3946
|
+
error: `Invalid SIC code "${sic}" — SEC SIC codes are 1-4 digits (e.g. "3571", "2836", "6021").`,
|
|
3947
|
+
retry_hint: 'Pass a numeric SIC code, or ticker_or_cik to resolve one from a company.',
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
const formType = (formTypeArg?.trim() || '10-K').toUpperCase();
|
|
3952
|
+
const excludeSelf = excludeSelfArg !== false; // default true
|
|
3953
|
+
const limit = Math.min(100, Math.max(1, Math.floor(Number(limitArg) || 25)));
|
|
3954
|
+
|
|
3955
|
+
// count=100 is SEC's max per page for this endpoint; no pagination beyond
|
|
3956
|
+
// that yet — an SIC bucket with >100 active 10-K filers returns only the
|
|
3957
|
+
// first 100 (flagged via `possibly_more`, not silently).
|
|
3958
|
+
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`;
|
|
3959
|
+
const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
3960
|
+
if (!res.ok) throw await httpError(res, 'SEC EDGAR company-search error');
|
|
3961
|
+
const xml = await res.text();
|
|
3962
|
+
const ciks = [...new Set([...xml.matchAll(/<cik>(\d+)<\/cik>/gi)].map((m) => String(Number(m[1]))))];
|
|
3963
|
+
|
|
3964
|
+
if (ciks.length === 0) {
|
|
3965
|
+
return {
|
|
3966
|
+
sic,
|
|
3967
|
+
sic_description: sicDescription,
|
|
3968
|
+
form_type: formType,
|
|
3969
|
+
total_found: 0,
|
|
3970
|
+
peers: [],
|
|
3971
|
+
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.`,
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
const tickerRows = await fetchCompanyTickers().catch(() => [] as SecTickerRow[]);
|
|
3976
|
+
const byTickerCik = new Map<string, SecTickerRow>();
|
|
3977
|
+
for (const r of tickerRows) byTickerCik.set(String(r.cik_str), r);
|
|
3978
|
+
|
|
3979
|
+
// Best-effort sic_description when we didn't already have it (direct `sic`
|
|
3980
|
+
// input, not resolved via a company) — pull it off the first listed peer's
|
|
3981
|
+
// submissions record rather than leaving it null.
|
|
3982
|
+
if (!sicDescription) {
|
|
3983
|
+
const firstListed = ciks.find((c) => byTickerCik.has(c)) ?? ciks[0];
|
|
3984
|
+
const one = await resolveEntitySic(firstListed).catch(() => null);
|
|
3985
|
+
if (one?.sic === sic) sicDescription = one.sic_description;
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
let peerCiks = ciks;
|
|
3989
|
+
if (excludeSelf && selfCik) peerCiks = peerCiks.filter((c) => c !== selfCik);
|
|
3990
|
+
|
|
3991
|
+
// browse-edgar's own ordering mixes long-defunct/delisted filers in with
|
|
3992
|
+
// current ones (SIC covers everyone who EVER filed the form, not just
|
|
3993
|
+
// active reporters) — verified live on SIC 3571: 100 filers returned, only
|
|
3994
|
+
// 5 (AAPL, DELL, OMCL, OSS, SCKT) still carry a listed ticker. Sorting
|
|
3995
|
+
// ticker-matched filers first means `limit` actually returns the
|
|
3996
|
+
// currently-tradable peers a "who competes with X" question wants, instead
|
|
3997
|
+
// of truncating to whichever 25 happened to sort first upstream (mostly
|
|
3998
|
+
// nulls). Unlisted registrants are still real filers, just pushed after.
|
|
3999
|
+
const sortedCiks = [...peerCiks].sort((a, b) => Number(byTickerCik.has(b)) - Number(byTickerCik.has(a)));
|
|
4000
|
+
|
|
4001
|
+
const peers: SicPeer[] = sortedCiks.slice(0, limit).map((c) => {
|
|
4002
|
+
const t = byTickerCik.get(c);
|
|
4003
|
+
return { cik: c, ticker: t?.ticker ?? null, company_name: t?.title ?? null };
|
|
4004
|
+
});
|
|
4005
|
+
|
|
4006
|
+
return {
|
|
4007
|
+
sic,
|
|
4008
|
+
sic_description: sicDescription,
|
|
4009
|
+
...(selfCik ? { resolved_from: { ticker_or_cik: tickerOrCik, cik: selfCik, company_name: selfName } } : {}),
|
|
4010
|
+
form_type: formType,
|
|
4011
|
+
exclude_self: excludeSelf,
|
|
4012
|
+
total_found: peerCiks.length,
|
|
4013
|
+
returned: peers.length,
|
|
4014
|
+
possibly_more: ciks.length >= 100,
|
|
4015
|
+
source: 'SEC EDGAR company search (browse-edgar), joined with company_tickers.json for name/ticker',
|
|
4016
|
+
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.',
|
|
4017
|
+
peers,
|
|
4018
|
+
};
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
// ── Filer subsidiaries (Exhibit 21) ─────────────────────────────────
|
|
4022
|
+
// The reverse of sponsor_to_filer: given a PARENT filer, list its operating
|
|
4023
|
+
// subsidiaries as filed in Exhibit 21 (Item 601(b)(21)) of its most recent
|
|
4024
|
+
// 10-K. Fleet #690 — ZT was rebuilding this per-company via a chain of ~10
|
|
4025
|
+
// trial-registry-candidate + sponsor_to_filer confirmation calls; this
|
|
4026
|
+
// replaces that chain with a single authoritative list plus provenance
|
|
4027
|
+
// (which filing, which exhibit, straight from SEC).
|
|
4028
|
+
|
|
4029
|
+
interface Subsidiary { name: string; jurisdiction: string | null }
|
|
4030
|
+
|
|
4031
|
+
const EX21_TYPE_RE = /^EX-?21(\.\d+)?$/i;
|
|
4032
|
+
|
|
4033
|
+
// Given a filing's -index.html (the human filing-summary page, which —
|
|
4034
|
+
// unlike index.json — carries a "Type" column such as "EX-21"), find the
|
|
4035
|
+
// Exhibit 21 document's URL. index.json's `type` field is a generic
|
|
4036
|
+
// MIME-icon class (e.g. "text.gif" for every .htm file in the filing, exhibit
|
|
4037
|
+
// 21 included), not the SEC exhibit type, so it can't be used to identify
|
|
4038
|
+
// EX-21 among a filing's other .htm exhibits — verified live on Merck's
|
|
4039
|
+
// 2026-02-24 10-K (accession 0000310158-26-000063).
|
|
4040
|
+
function findExhibit21Url(indexHtml: string): string | null {
|
|
4041
|
+
for (const tr of xmlBlocks(indexHtml, 'tr')) {
|
|
4042
|
+
const href = /<a[^>]+href="([^"]+\.(?:htm|html|txt))"/i.exec(tr)?.[1];
|
|
4043
|
+
if (!href) continue;
|
|
4044
|
+
const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => (htmlToText(m[1]) ?? '').trim());
|
|
4045
|
+
if (cells.some((c) => EX21_TYPE_RE.test(c))) {
|
|
4046
|
+
return href.startsWith('http') ? href : `https://www.sec.gov${href}`;
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
return null;
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
// Parse the EX-21 document into structured rows. SEC's typical layout is a
|
|
4053
|
+
// 2-column HTML table (Name | Country or State of Incorporation) — verified
|
|
4054
|
+
// live on Merck. Not every filer's EX-21 uses a table (smaller filers
|
|
4055
|
+
// sometimes file a plain paragraph list), so this returns [] rather than
|
|
4056
|
+
// throwing when no table rows parse; the caller falls back to the raw text.
|
|
4057
|
+
function parseSubsidiaryTable(html: string): Subsidiary[] {
|
|
4058
|
+
const out: Subsidiary[] = [];
|
|
4059
|
+
for (const tr of xmlBlocks(html, 'tr')) {
|
|
4060
|
+
const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
|
|
4061
|
+
.map((m) => (decodeEntities(htmlToText(m[1]))?.trim()) ?? '')
|
|
4062
|
+
.filter((c) => c.length > 0);
|
|
4063
|
+
if (cells.length < 2) continue;
|
|
4064
|
+
const [name, jurisdiction] = cells;
|
|
4065
|
+
if (/^name$/i.test(name) || /^(country|state|jurisdiction)/i.test(jurisdiction)) continue; // header row
|
|
4066
|
+
if (name.length > 200) continue; // guard against a non-table paragraph blob matching by accident
|
|
4067
|
+
out.push({ name, jurisdiction: jurisdiction || null });
|
|
4068
|
+
}
|
|
4069
|
+
return out;
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
async function filerToSponsors(
|
|
4073
|
+
tickerOrCik: string | undefined,
|
|
4074
|
+
nameFilterArg: string | undefined,
|
|
4075
|
+
limitArg: number | undefined,
|
|
4076
|
+
): Promise<unknown> {
|
|
4077
|
+
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
4078
|
+
return {
|
|
4079
|
+
error: 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — all three take a ticker ("MRK") or a CIK ("310158").',
|
|
4080
|
+
retry_hint: 'filer_to_sponsors({ticker_or_cik: "MRK"}) or pass a CIK, e.g. "310158".',
|
|
4081
|
+
};
|
|
4082
|
+
}
|
|
4083
|
+
const cik = await resolveCik(tickerOrCik);
|
|
4084
|
+
const paddedCik = padCik(cik);
|
|
4085
|
+
const limit = Math.min(500, Math.max(1, Math.floor(Number(limitArg) || 200)));
|
|
4086
|
+
const nameFilter = nameFilterArg?.trim().toLowerCase();
|
|
4087
|
+
|
|
4088
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
4089
|
+
if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
|
|
4090
|
+
const sub = (await subRes.json()) as {
|
|
4091
|
+
name?: string;
|
|
4092
|
+
filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } };
|
|
4093
|
+
};
|
|
4094
|
+
const r = sub.filings?.recent;
|
|
4095
|
+
const forms = r?.form ?? [];
|
|
4096
|
+
const accs = r?.accessionNumber ?? [];
|
|
4097
|
+
const dates = r?.filingDate ?? [];
|
|
4098
|
+
const cikNoZeros = String(Number(cik));
|
|
4099
|
+
|
|
4100
|
+
// Walk the most recent annual filings looking for one that carries an
|
|
4101
|
+
// Exhibit 21 — almost always the latest 10-K, but Item 601(b)(21) only
|
|
4102
|
+
// requires listing SIGNIFICANT subsidiaries, so a small filer's most recent
|
|
4103
|
+
// 10-K can genuinely omit it even when an older one had it.
|
|
4104
|
+
const candidates: { idx: number; form: string }[] = [];
|
|
4105
|
+
for (let i = 0; i < forms.length && candidates.length < 5; i++) {
|
|
4106
|
+
if (forms[i] === '10-K' || forms[i] === '10-K/A') candidates.push({ idx: i, form: forms[i] });
|
|
4107
|
+
}
|
|
4108
|
+
if (candidates.length === 0) {
|
|
4109
|
+
return {
|
|
4110
|
+
cik: cikNoZeros,
|
|
4111
|
+
company_name: sub.name ?? null,
|
|
4112
|
+
status: 'no_10k',
|
|
4113
|
+
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.`,
|
|
4114
|
+
subsidiaries: [],
|
|
4115
|
+
};
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
for (const c of candidates) {
|
|
4119
|
+
const accession = accs[c.idx];
|
|
4120
|
+
const accessionPath = accession.replace(/-/g, '');
|
|
4121
|
+
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
|
|
4122
|
+
const idxRes = await pwFetch(`${folder}/${accession}-index.html`, { headers: SEC_HEADERS });
|
|
4123
|
+
if (!idxRes.ok) continue;
|
|
4124
|
+
const idxHtml = await idxRes.text();
|
|
4125
|
+
const exUrl = findExhibit21Url(idxHtml);
|
|
4126
|
+
if (!exUrl) continue;
|
|
4127
|
+
|
|
4128
|
+
const exRes = await pwFetch(exUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
4129
|
+
if (!exRes.ok) continue;
|
|
4130
|
+
const exHtml = await exRes.text();
|
|
4131
|
+
const parsed = parseSubsidiaryTable(exHtml);
|
|
4132
|
+
|
|
4133
|
+
const provenance = {
|
|
4134
|
+
source: 'SEC EDGAR Exhibit 21 (Item 601(b)(21))',
|
|
4135
|
+
cik: cikNoZeros,
|
|
4136
|
+
company_name: sub.name ?? null,
|
|
4137
|
+
form: c.form,
|
|
4138
|
+
accession,
|
|
4139
|
+
filing_date: dates[c.idx] ?? null,
|
|
4140
|
+
exhibit_url: exUrl,
|
|
4141
|
+
};
|
|
4142
|
+
|
|
4143
|
+
if (parsed.length === 0) {
|
|
4144
|
+
// Exhibit exists but didn't parse as a table — return the raw text so
|
|
4145
|
+
// the caller isn't left with nothing, flagged as unstructured rather
|
|
4146
|
+
// than silently empty.
|
|
4147
|
+
const raw = htmlToText(exHtml);
|
|
4148
|
+
return {
|
|
4149
|
+
...provenance,
|
|
4150
|
+
status: 'unstructured',
|
|
4151
|
+
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.',
|
|
4152
|
+
raw_text: raw.length > 20000 ? raw.slice(0, 20000) + '\n…[truncated]' : raw,
|
|
4153
|
+
subsidiaries: [],
|
|
4154
|
+
};
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
const filtered = nameFilter ? parsed.filter((s) => s.name.toLowerCase().includes(nameFilter)) : parsed;
|
|
4158
|
+
return {
|
|
4159
|
+
...provenance,
|
|
4160
|
+
status: 'ok',
|
|
4161
|
+
total_subsidiaries: parsed.length,
|
|
4162
|
+
returned: Math.min(filtered.length, limit),
|
|
4163
|
+
...(nameFilter ? { name_filter: nameFilter, name_filter_matches: filtered.length } : {}),
|
|
4164
|
+
subsidiaries: filtered.slice(0, limit),
|
|
4165
|
+
};
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
return {
|
|
4169
|
+
cik: cikNoZeros,
|
|
4170
|
+
company_name: sub.name ?? null,
|
|
4171
|
+
status: 'no_exhibit_21',
|
|
4172
|
+
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).`,
|
|
4173
|
+
checked_filings: candidates.map((c) => ({ form: c.form, accession: accs[c.idx], filing_date: dates[c.idx] ?? null })),
|
|
4174
|
+
subsidiaries: [],
|
|
4175
|
+
};
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
// ── Product-level / disaggregated revenue (10-K segment notes) ─────
|
|
4179
|
+
// Fleet #691. XBRL companyfacts/companyconcept expose only UNDIMENSIONED
|
|
4180
|
+
// facts (see the LongTermDebt/Ford note on companyConcept above) — product-
|
|
4181
|
+
// level revenue is almost always tagged WITH a dimension (a custom segment
|
|
4182
|
+
// axis with a "Keytruda [Member]", or srt:ProductOrServiceAxis), so it is
|
|
4183
|
+
// invisible to those APIs no matter which tag is requested. It IS visible in
|
|
4184
|
+
// SEC's own "Financial Report" R*.htm viewer pages, which render every
|
|
4185
|
+
// dimensional breakdown as a standardized Arelle-generated HTML table — the
|
|
4186
|
+
// SAME table markup across virtually every modern XBRL filer, because SEC's
|
|
4187
|
+
// own renderer produces it, not the filer. Verified live on Merck's FY2025
|
|
4188
|
+
// 10-K (accession 0000310158-26-000063): FilingSummary.xml names report
|
|
4189
|
+
// "9955598 - Disclosure - Segment Reporting - Schedule of Sales of Company's
|
|
4190
|
+
// Products (Details)" -> R119.htm -> a "Sales" row under the
|
|
4191
|
+
// "Operating Segments | Pharmaceutical | Keytruda" dimension = $31,641M.
|
|
4192
|
+
|
|
4193
|
+
interface DisaggRow { dimension: string | null; concept: string; values: { period: string; value: string }[] }
|
|
4194
|
+
|
|
4195
|
+
// Ordered by specificity — the first PATTERN that matches ANY report wins,
|
|
4196
|
+
// not the first report matching ANY pattern, so a precise "Sales of
|
|
4197
|
+
// Products" report outranks a looser "revenue by segment" one when a filer
|
|
4198
|
+
// has both. Geographic-only breakdowns are excluded explicitly: they use the
|
|
4199
|
+
// same report-naming conventions but answer a different question.
|
|
4200
|
+
// Report titles in FilingSummary.xml are structured `<num> - Disclosure - <Note>
|
|
4201
|
+
// - <Subject> (Details)`. Matching on the SUBJECT (the text after the last
|
|
4202
|
+
// " - ") rather than the whole string is what makes the geographic exclusion
|
|
4203
|
+
// safe: ABBV files its product table as "Segment and Geographic Area
|
|
4204
|
+
// Information - Disaggregation of Revenue (Details)", so a blanket
|
|
4205
|
+
// /geographic/ skip on the full title threw away the very report we wanted,
|
|
4206
|
+
// while PFE's genuinely geo-only "Revenues by Geographic Area (Detail)" must
|
|
4207
|
+
// still be skipped. Both are decided correctly on the subject.
|
|
4208
|
+
function reportSubject(longName: string): string {
|
|
4209
|
+
const parts = longName.split(' - ');
|
|
4210
|
+
return (parts.length > 1 ? parts[parts.length - 1] : longName).trim();
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
// Tier 1: the filer names a product/turnover breakdown outright.
|
|
4214
|
+
const REPORT_NAME_PATTERNS: RegExp[] = [
|
|
4215
|
+
/sales of .*compan.*products/i,
|
|
4216
|
+
/revenues? by [\w\s,&]{0,40}product/i,
|
|
4217
|
+
/net sales by product/i,
|
|
4218
|
+
/product (net )?(sales|revenues?)/i,
|
|
4219
|
+
/(net |gross )?(sales|revenues?)[\w\s,&]{0,30}by product/i,
|
|
4220
|
+
/turnover by product/i,
|
|
4221
|
+
/major product/i,
|
|
4222
|
+
/disaggregat(ed|ion) of (net )?revenues?/i,
|
|
4223
|
+
/revenue.*by segment and product/i,
|
|
4224
|
+
/\btop \d+\b/i, // Novartis titles its per-product table "… - Top 20 (Details 5)"
|
|
4225
|
+
];
|
|
4226
|
+
|
|
4227
|
+
// Tier 2: no product wording, but the report is still the revenue
|
|
4228
|
+
// disaggregation note — its XBRL dimensions carry the products (verified:
|
|
4229
|
+
// Novo Nordisk's "Business segments and geographical areas" rows are Ozempic /
|
|
4230
|
+
// Rybelsus; Sanofi's are Dupixent / Aubagio; Incyte's bare "Revenues
|
|
4231
|
+
// (Details)" is JAKAFI / OPZELURA). Only tried when tier 1 finds nothing.
|
|
4232
|
+
const REPORT_NAME_PATTERNS_FALLBACK: RegExp[] = [
|
|
4233
|
+
/\bnet sales\b/i,
|
|
4234
|
+
/sales by segment/i,
|
|
4235
|
+
/\brevenues?\s*\(detail/i,
|
|
4236
|
+
];
|
|
4237
|
+
|
|
4238
|
+
// Subjects that are about the mechanics of revenue (reserves, rebates,
|
|
4239
|
+
// concentration) rather than the revenue breakdown itself. Without this,
|
|
4240
|
+
// SRPT's "Reserves for Product Revenues" outranks its "Summary of Product
|
|
4241
|
+
// Revenues", and UTHR's "Revenue Recognition" would match tier 2.
|
|
4242
|
+
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;
|
|
4243
|
+
|
|
4244
|
+
const REPORT_GEO_ONLY = /\bgeograph/i;
|
|
4245
|
+
const REPORT_PRODUCT_SIGNAL = /product|segment|top \d+/i;
|
|
4246
|
+
|
|
4247
|
+
async function findRevenueReport(folder: string): Promise<{ url: string; report_name: string } | null> {
|
|
4248
|
+
const res = await pwFetch(`${folder}/FilingSummary.xml`, { headers: SEC_HEADERS });
|
|
4249
|
+
if (!res.ok) return null;
|
|
4250
|
+
const xml = await res.text();
|
|
4251
|
+
// FilingSummary.xml's <Report> elements carry an `instance="..."`
|
|
4252
|
+
// attribute (e.g. <Report instance="mrk-20251231.htm">) — xmlBlocks
|
|
4253
|
+
// tolerates that.
|
|
4254
|
+
const reports = xmlBlocks(xml, 'Report');
|
|
4255
|
+
|
|
4256
|
+
const candidates = reports
|
|
4257
|
+
.map((rep) => {
|
|
4258
|
+
const longName = xmlVal(rep, 'LongName') ?? '';
|
|
4259
|
+
const html = xmlVal(rep, 'HtmlFileName');
|
|
4260
|
+
return { longName, subject: reportSubject(longName), html };
|
|
4261
|
+
})
|
|
4262
|
+
.filter((c) => {
|
|
4263
|
+
if (!c.html || !c.longName) return false;
|
|
4264
|
+
if (REPORT_NOISE.test(c.subject)) return false;
|
|
4265
|
+
// Geographic-only reports are the wrong axis — but a subject that also
|
|
4266
|
+
// names products or segments is the right one wearing a geo label.
|
|
4267
|
+
if (REPORT_GEO_ONLY.test(c.subject) && !REPORT_PRODUCT_SIGNAL.test(c.subject)) return false;
|
|
4268
|
+
return true;
|
|
4269
|
+
});
|
|
4270
|
+
|
|
4271
|
+
// A filer publishes both the section-level report ("NET PRODUCT REVENUES")
|
|
4272
|
+
// and the rendered table ("… - Schedule of Net Product Revenues (Detail)").
|
|
4273
|
+
// Only the latter parses into rows, so it is tried first — ALNY and SRPT
|
|
4274
|
+
// both returned status "unparsed" because the section-level one won.
|
|
4275
|
+
const isDetails = (n: string) => /\(detail(s)?(\s+\d+)?\)\s*$/i.test(n.trim());
|
|
4276
|
+
for (const tier of [REPORT_NAME_PATTERNS, REPORT_NAME_PATTERNS_FALLBACK]) {
|
|
4277
|
+
for (const detailsOnly of [true, false]) {
|
|
4278
|
+
for (const pat of tier) {
|
|
4279
|
+
for (const c of candidates) {
|
|
4280
|
+
if (detailsOnly !== isDetails(c.longName)) continue;
|
|
4281
|
+
if (!pat.test(c.longName)) continue;
|
|
4282
|
+
return { url: `${folder}/${c.html}`, report_name: decodeEntities(c.longName) ?? c.longName };
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
return null;
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
// Parse an SEC Financial-Report R*.htm page into dimensional rows. Standard
|
|
4291
|
+
// Arelle-rendered structure (SEC's own viewer, consistent across filers):
|
|
4292
|
+
// - Header <tr>s carry <th class="th"><div>period label</div></th> cells
|
|
4293
|
+
// (e.g. "Dec. 31, 2025").
|
|
4294
|
+
// - A "context" row sets the dimension breadcrumb (e.g. "Operating
|
|
4295
|
+
// Segments | Pharmaceutical | Keytruda") for the data rows that follow
|
|
4296
|
+
// it, until the next context row — identified as a row whose concept
|
|
4297
|
+
// link references an XBRL Axis (`Axis=` in the href) and which carries
|
|
4298
|
+
// no numeric values itself.
|
|
4299
|
+
// - "[Line Items]" / "[Table]" / "[Abstract]" / "[Domain]" label rows are
|
|
4300
|
+
// structural noise (bolded headers with blank values) — skipped, never
|
|
4301
|
+
// treated as a context or a data row.
|
|
4302
|
+
// - A data row carries one value cell (`class="num"`/`"nump"`) per period
|
|
4303
|
+
// column alongside its own concept label (e.g. "Sales").
|
|
4304
|
+
// Best-effort: filers occasionally deviate from this shape (older filings,
|
|
4305
|
+
// non-Arelle renders) — an empty `rows` array means "didn't parse", not
|
|
4306
|
+
// "reports zero", and is flagged as such by the caller.
|
|
4307
|
+
function parseDisaggregatedRevenue(html: string): { periods: string[]; rows: DisaggRow[]; units_note: string | null } {
|
|
4308
|
+
const titleMatch = /<th[^>]*>\s*<div[^>]*>\s*<strong>([\s\S]*?)<\/strong>/i.exec(html);
|
|
4309
|
+
const titleText = titleMatch ? htmlToText(titleMatch[1]) : '';
|
|
4310
|
+
const unitsMatch = /\$\s*in\s*(Thousands|Millions|Billions)/i.exec(titleText);
|
|
4311
|
+
const unitsNote = unitsMatch ? `$ in ${unitsMatch[1]}` : null;
|
|
4312
|
+
|
|
4313
|
+
// Period columns come from two header rows: an optional duration row
|
|
4314
|
+
// (<th class="th" colspan="3">12 Months Ended</th>, carrying no <div>) and
|
|
4315
|
+
// the date row. ABBV writes its date row as
|
|
4316
|
+
// <th class="th">\n<div>Dec. 31, 2025 </div>\n<div>USD ($)</div>\n</th>
|
|
4317
|
+
// — a newline after the tag and several <div>s — which a single-line
|
|
4318
|
+
// "<th class=\"th\"><div>…</div></th>" pattern misses completely. That left
|
|
4319
|
+
// `periods` empty, so every row's `values` filtered down to nothing and the
|
|
4320
|
+
// tool returned status "ok" with 76 rows and not one dollar figure.
|
|
4321
|
+
const durations: string[] = [];
|
|
4322
|
+
const dateLabels: string[] = [];
|
|
4323
|
+
for (const m of html.matchAll(/<th class="th"([^>]*)>([\s\S]*?)<\/th>/gi)) {
|
|
4324
|
+
const divs = [...(m[2] ?? '').matchAll(/<div[^>]*>([\s\S]*?)<\/div>/gi)].map((d) => htmlToText(d[1]).trim());
|
|
4325
|
+
if (divs.length === 0) {
|
|
4326
|
+
const span = Math.max(1, Number(/colspan="(\d+)"/i.exec(m[1] ?? '')?.[1] ?? 1));
|
|
4327
|
+
const label = htmlToText(m[2] ?? '').trim();
|
|
4328
|
+
for (let i = 0; i < span; i++) durations.push(label);
|
|
4329
|
+
continue;
|
|
4330
|
+
}
|
|
4331
|
+
if (divs[0]) dateLabels.push(divs[0]); // later <div>s are the unit ("USD ($)") and axis qualifiers
|
|
4332
|
+
}
|
|
4333
|
+
// Two columns can carry the SAME date over different durations — ABBV reports
|
|
4334
|
+
// both "3 Months Ended Dec. 31, 2025" and "12 Months Ended Dec. 31, 2025", and
|
|
4335
|
+
// reading a quarter as the annual figure is the expensive mistake here. Qualify
|
|
4336
|
+
// only when the bare date is ambiguous, so single-duration filers keep the
|
|
4337
|
+
// period labels they have always returned.
|
|
4338
|
+
const ambiguous = new Set(dateLabels.filter((d, i) => dateLabels.indexOf(d) !== i));
|
|
4339
|
+
const periods = dateLabels.map((d, i) => (ambiguous.has(d) && durations[i] ? `${durations[i]} ${d}` : d));
|
|
4340
|
+
|
|
4341
|
+
const rows: DisaggRow[] = [];
|
|
4342
|
+
let context: string | null = null;
|
|
4343
|
+
for (const tr of xmlBlocks(html, 'tr')) {
|
|
4344
|
+
const labelMatch = /<td[^>]*class="pl[^"]*"[^>]*>([\s\S]*?)<\/td>/i.exec(tr);
|
|
4345
|
+
if (!labelMatch) continue; // header/period row, no row label
|
|
4346
|
+
const label = htmlToText(labelMatch[1]).trim();
|
|
4347
|
+
if (!label) continue;
|
|
4348
|
+
if (/\[(Line Items|Table|Abstract|Domain|Axis)\]/i.test(label)) continue; // structural noise
|
|
4349
|
+
|
|
4350
|
+
// Include the blank <td class="text"> cells: they hold a column's POSITION
|
|
4351
|
+
// when a row has no figure for that period. Collecting only the numeric
|
|
4352
|
+
// cells and zipping them against `periods` shifts every value left past a
|
|
4353
|
+
// gap, which silently files an annual figure under the quarterly column.
|
|
4354
|
+
const valueCells = [...tr.matchAll(/<td class="(num[a-z]*|text)"[^>]*>([\s\S]*?)<\/td>/gi)]
|
|
4355
|
+
.map((m) => (m[1].toLowerCase() === 'text' ? '' : htmlToText(m[2]).trim()));
|
|
4356
|
+
const hasValues = valueCells.some((v) => v.length > 0);
|
|
4357
|
+
const referencesAxis = /Axis=/.test(tr);
|
|
4358
|
+
|
|
4359
|
+
if (referencesAxis && !hasValues) {
|
|
4360
|
+
context = label;
|
|
4361
|
+
continue;
|
|
4362
|
+
}
|
|
4363
|
+
if (!hasValues) continue; // blank-value row that isn't a recognized axis header — skip
|
|
4364
|
+
|
|
4365
|
+
rows.push({
|
|
4366
|
+
dimension: context,
|
|
4367
|
+
concept: label,
|
|
4368
|
+
values: periods.map((p, i) => ({ period: p, value: valueCells[i] ?? '' })).filter((v) => v.value !== ''),
|
|
4369
|
+
});
|
|
4370
|
+
}
|
|
4371
|
+
return { periods, rows, units_note: unitsNote };
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
async function productRevenue(
|
|
4375
|
+
tickerOrCik: string | undefined,
|
|
4376
|
+
productFilterArg: string | undefined,
|
|
4377
|
+
formTypeArg: string | undefined,
|
|
4378
|
+
accessionArg: string | undefined,
|
|
4379
|
+
limitArg: number | undefined,
|
|
4380
|
+
): Promise<unknown> {
|
|
4381
|
+
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
4382
|
+
return {
|
|
4383
|
+
error: 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — all three take a ticker ("MRK") or a CIK ("310158").',
|
|
4384
|
+
retry_hint: 'edgar_product_revenue({ticker_or_cik: "MRK", product_filter: "Keytruda"}).',
|
|
4385
|
+
};
|
|
4386
|
+
}
|
|
4387
|
+
const cik = await resolveCik(tickerOrCik);
|
|
4388
|
+
const paddedCik = padCik(cik);
|
|
4389
|
+
const cikNoZeros = String(Number(cik));
|
|
4390
|
+
// Foreign private issuers file 20-F and Canadian MJDS filers 40-F instead of
|
|
4391
|
+
// a 10-K, and their rendered R*.htm reports exist just the same. With no
|
|
4392
|
+
// form_type from the caller, walk 10-K -> 20-F -> 40-F rather than reporting
|
|
4393
|
+
// "no filing" for every non-US-domestic pharma (NVS, AZN, GSK, SNY, NVO, TAK).
|
|
4394
|
+
const explicitForm = formTypeArg?.trim() ? formTypeArg.trim().toUpperCase() : null;
|
|
4395
|
+
const FORM_FALLBACK_ORDER = ['10-K', '20-F', '40-F'];
|
|
4396
|
+
const formCandidates = explicitForm ? [explicitForm] : FORM_FALLBACK_ORDER;
|
|
4397
|
+
const productFilter = productFilterArg?.trim().toLowerCase();
|
|
4398
|
+
const limit = Math.min(200, Math.max(1, Math.floor(Number(limitArg) || 100)));
|
|
4399
|
+
|
|
4400
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
4401
|
+
if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
|
|
4402
|
+
const sub = (await subRes.json()) as {
|
|
4403
|
+
name?: string;
|
|
4404
|
+
filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } };
|
|
4405
|
+
};
|
|
4406
|
+
const r = sub.filings?.recent;
|
|
4407
|
+
const forms = r?.form ?? [];
|
|
4408
|
+
const accs = r?.accessionNumber ?? [];
|
|
4409
|
+
const dates = r?.filingDate ?? [];
|
|
4410
|
+
|
|
4411
|
+
let acc: string | undefined;
|
|
4412
|
+
let filingDate: string | null = null;
|
|
4413
|
+
let formType: string;
|
|
4414
|
+
if (accessionArg) {
|
|
4415
|
+
const norm = normalizeAccession(accessionArg);
|
|
4416
|
+
if (!norm) throw new Error(`Invalid accession "${accessionArg}" — expected 18 digits shaped 10-2-6.`);
|
|
4417
|
+
acc = norm.dashed;
|
|
4418
|
+
const i = accs.indexOf(acc);
|
|
4419
|
+
filingDate = i >= 0 ? dates[i] ?? null : null;
|
|
4420
|
+
formType = (i >= 0 ? forms[i] : null) ?? explicitForm ?? 'unknown';
|
|
4421
|
+
} else {
|
|
4422
|
+
let idx = -1;
|
|
4423
|
+
let picked: string | null = null;
|
|
4424
|
+
for (const want of formCandidates) {
|
|
4425
|
+
idx = forms.findIndex((f) => f.toUpperCase() === want);
|
|
4426
|
+
if (idx >= 0) { picked = want; break; }
|
|
4427
|
+
}
|
|
4428
|
+
if (idx < 0 || !picked) {
|
|
4429
|
+
return {
|
|
4430
|
+
cik: cikNoZeros,
|
|
4431
|
+
company_name: sub.name ?? null,
|
|
4432
|
+
status: 'no_filing',
|
|
4433
|
+
resolved_form: null,
|
|
4434
|
+
forms_checked: formCandidates,
|
|
4435
|
+
message: `No ${formCandidates.join(' / ')} on file for CIK ${cikNoZeros} (${sub.name ?? tickerOrCik}) in the recent filings index.`,
|
|
4436
|
+
rows: [],
|
|
4437
|
+
};
|
|
4438
|
+
}
|
|
4439
|
+
formType = picked;
|
|
4440
|
+
acc = accs[idx];
|
|
4441
|
+
filingDate = dates[idx] ?? null;
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
const accessionPath = acc!.replace(/-/g, '');
|
|
4445
|
+
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
|
|
4446
|
+
const report = await findRevenueReport(folder);
|
|
4447
|
+
if (!report) {
|
|
4448
|
+
return {
|
|
4449
|
+
cik: cikNoZeros,
|
|
4450
|
+
company_name: sub.name ?? null,
|
|
4451
|
+
form: formType,
|
|
4452
|
+
resolved_form: formType,
|
|
4453
|
+
accession: acc,
|
|
4454
|
+
filing_date: filingDate,
|
|
4455
|
+
status: 'not_found',
|
|
4456
|
+
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.`,
|
|
4457
|
+
rows: [],
|
|
4458
|
+
};
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
const repRes = await pwFetch(report.url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
4462
|
+
if (!repRes.ok) throw await httpError(repRes, 'SEC EDGAR financial report error');
|
|
4463
|
+
const repHtml = await repRes.text();
|
|
4464
|
+
const parsed = parseDisaggregatedRevenue(repHtml);
|
|
4465
|
+
|
|
4466
|
+
const citation = {
|
|
4467
|
+
source: 'SEC EDGAR XBRL Financial Report (Arelle-rendered R.htm viewer)',
|
|
4468
|
+
cik: cikNoZeros,
|
|
4469
|
+
company_name: sub.name ?? null,
|
|
4470
|
+
form: formType,
|
|
4471
|
+
resolved_form: formType,
|
|
4472
|
+
accession: acc,
|
|
4473
|
+
filing_date: filingDate,
|
|
4474
|
+
report_name: report.report_name,
|
|
4475
|
+
report_url: report.url,
|
|
4476
|
+
};
|
|
4477
|
+
|
|
4478
|
+
if (parsed.rows.length === 0) {
|
|
4479
|
+
return {
|
|
4480
|
+
...citation,
|
|
4481
|
+
status: 'unparsed',
|
|
4482
|
+
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.',
|
|
4483
|
+
periods: parsed.periods,
|
|
4484
|
+
rows: [],
|
|
4485
|
+
};
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
const filtered = productFilter
|
|
4489
|
+
? parsed.rows.filter((row) => (row.dimension ?? row.concept).toLowerCase().includes(productFilter))
|
|
4490
|
+
: parsed.rows;
|
|
4491
|
+
|
|
4492
|
+
return {
|
|
4493
|
+
...citation,
|
|
4494
|
+
status: 'ok',
|
|
4495
|
+
units_note: parsed.units_note,
|
|
4496
|
+
periods: parsed.periods,
|
|
4497
|
+
total_rows: parsed.rows.length,
|
|
4498
|
+
returned: Math.min(filtered.length, limit),
|
|
4499
|
+
...(productFilter ? { product_filter: productFilter, product_filter_matches: filtered.length } : {}),
|
|
4500
|
+
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").',
|
|
4501
|
+
rows: filtered.slice(0, limit),
|
|
4502
|
+
};
|
|
4503
|
+
}
|
|
4504
|
+
|
|
1348
4505
|
// ── callTool router ─────────────────────────────────────────────────
|
|
1349
4506
|
|
|
4507
|
+
/**
|
|
4508
|
+
* The one company argument, under any of the three names this pack uses for it.
|
|
4509
|
+
* `ticker_or_cik`, `cik` and `ticker` all mean "a ticker OR a CIK" here —
|
|
4510
|
+
* resolveCik() accepts either — so which word a caller reached for is not
|
|
4511
|
+
* information, it is noise. Coalesce rather than branch, first non-empty wins.
|
|
4512
|
+
*/
|
|
4513
|
+
function companyArg(args: Record<string, unknown>): string {
|
|
4514
|
+
for (const key of ['ticker_or_cik', 'cik', 'ticker'] as const) {
|
|
4515
|
+
const v = args[key];
|
|
4516
|
+
if (typeof v === 'string' && v.trim()) return v;
|
|
4517
|
+
if (typeof v === 'number') return String(v);
|
|
4518
|
+
}
|
|
4519
|
+
return args.ticker_or_cik as string;
|
|
4520
|
+
}
|
|
4521
|
+
|
|
1350
4522
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
1351
4523
|
switch (name) {
|
|
1352
4524
|
case 'edgar_search_filings':
|
|
@@ -1357,32 +4529,57 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
1357
4529
|
args.end_date as string | undefined,
|
|
1358
4530
|
args.limit as number | undefined,
|
|
1359
4531
|
);
|
|
4532
|
+
// Every tool below takes the same thing — a company — and this pack spells
|
|
4533
|
+
// it three ways across its own tools (`ticker_or_cik`, `cik`, `ticker`), so
|
|
4534
|
+
// a model filling arguments from prose reaches for whichever word it read
|
|
4535
|
+
// last. All three are DECLARED in each inputSchema and coalesced here; an
|
|
4536
|
+
// undeclared argument is dropped today and would be rejected once
|
|
4537
|
+
// REJECT_UNKNOWN_ARGS is enforced (#2013), so declaring is what makes the
|
|
4538
|
+
// alias real. `required` is empty on those schemas for the same reason —
|
|
4539
|
+
// a flat list cannot say "one of these three" (fleet #2058, swept in #2069).
|
|
1360
4540
|
case 'edgar_company_filings':
|
|
1361
4541
|
return companyFilings(
|
|
1362
|
-
args
|
|
4542
|
+
companyArg(args),
|
|
1363
4543
|
args.form_type as string | undefined,
|
|
1364
4544
|
args.limit as number | undefined,
|
|
1365
4545
|
);
|
|
1366
4546
|
case 'edgar_company_facts':
|
|
1367
|
-
return companyFacts(args
|
|
4547
|
+
return companyFacts(companyArg(args));
|
|
4548
|
+
case 'edgar_company_snapshot':
|
|
4549
|
+
return companySnapshot(
|
|
4550
|
+
companyArg(args),
|
|
4551
|
+
args.form_type as string | undefined,
|
|
4552
|
+
args.filings_limit as number | undefined,
|
|
4553
|
+
);
|
|
1368
4554
|
case 'edgar_insider_transactions':
|
|
1369
4555
|
return insiderTransactions(
|
|
1370
|
-
args
|
|
4556
|
+
companyArg(args),
|
|
1371
4557
|
args.limit as number | undefined,
|
|
1372
4558
|
args.include_derivatives as boolean | undefined,
|
|
1373
4559
|
);
|
|
1374
4560
|
case 'edgar_institutional_holdings':
|
|
1375
4561
|
return institutionalHoldings(
|
|
1376
|
-
args
|
|
4562
|
+
companyArg(args),
|
|
1377
4563
|
args.limit as number | undefined,
|
|
1378
4564
|
);
|
|
1379
4565
|
case 'edgar_fund_holdings':
|
|
1380
|
-
return fundHoldings(args.ticker as string, args.limit as number | undefined);
|
|
4566
|
+
return fundHoldings((args.ticker ?? args.ticker_or_cik) as string, args.limit as number | undefined);
|
|
1381
4567
|
case 'edgar_company_concept':
|
|
1382
|
-
// Accept `
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
|
|
4568
|
+
// Accept `ticker` / `ticker_or_cik` / `metric` too. The canonical names
|
|
4569
|
+
// are `cik` (which takes a ticker OR a CIK) and `concept`, but this
|
|
4570
|
+
// pack's own sibling tools spell the company argument three different
|
|
4571
|
+
// ways and the description calls the metric a "metric" — so a caller
|
|
4572
|
+
// filling arguments from prose reaches for whichever word it just read.
|
|
4573
|
+
// Measured: every `partial` outcome recorded for this tool over three
|
|
4574
|
+
// days (12/12) sent `ticker`, and a third of those sent `metric` as
|
|
4575
|
+
// well; all were rejected before the pack ran (fleet #2058).
|
|
4576
|
+
return companyConcept(
|
|
4577
|
+
(args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
|
|
4578
|
+
(args.concept ?? args.metric) as string,
|
|
4579
|
+
args.period as string | undefined,
|
|
4580
|
+
args.fiscal_year as string | number | undefined,
|
|
4581
|
+
args.fiscal_period as string | undefined,
|
|
4582
|
+
);
|
|
1386
4583
|
case 'edgar_filing_documents':
|
|
1387
4584
|
return filingDocuments(
|
|
1388
4585
|
args.accession as string | undefined,
|
|
@@ -1390,10 +4587,44 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
1390
4587
|
args.include_primary_text as boolean | undefined,
|
|
1391
4588
|
args.form_type as string | undefined,
|
|
1392
4589
|
);
|
|
4590
|
+
case 'edgar_filing_text':
|
|
4591
|
+
return filingText(
|
|
4592
|
+
args.accession as string | undefined,
|
|
4593
|
+
(args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
|
|
4594
|
+
args.section as string | undefined,
|
|
4595
|
+
args.max_chars as number | undefined,
|
|
4596
|
+
args.offset as number | undefined,
|
|
4597
|
+
args.form_type as string | undefined,
|
|
4598
|
+
(args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
|
|
4599
|
+
);
|
|
1393
4600
|
case 'edgar_ticker_to_cik':
|
|
1394
|
-
return tickerToCik(args.ticker as string);
|
|
4601
|
+
return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
|
|
4602
|
+
case 'sponsor_to_filer':
|
|
4603
|
+
return sponsorToFiler((args.sponsor ?? args.name ?? args.ticker) as string);
|
|
4604
|
+
case 'filer_to_sponsors':
|
|
4605
|
+
return filerToSponsors(
|
|
4606
|
+
companyArg(args) as string | undefined,
|
|
4607
|
+
args.name_filter as string | undefined,
|
|
4608
|
+
args.limit as number | undefined,
|
|
4609
|
+
);
|
|
1395
4610
|
case 'edgar_xbrl_frames':
|
|
1396
4611
|
return xbrlFrames(args);
|
|
4612
|
+
case 'edgar_companies_by_sic':
|
|
4613
|
+
return companiesBySic(
|
|
4614
|
+
args.sic as string | undefined,
|
|
4615
|
+
companyArg(args) as string | undefined,
|
|
4616
|
+
args.form_type as string | undefined,
|
|
4617
|
+
args.exclude_self as boolean | undefined,
|
|
4618
|
+
args.limit as number | undefined,
|
|
4619
|
+
);
|
|
4620
|
+
case 'edgar_product_revenue':
|
|
4621
|
+
return productRevenue(
|
|
4622
|
+
companyArg(args) as string | undefined,
|
|
4623
|
+
args.product_filter as string | undefined,
|
|
4624
|
+
args.form_type as string | undefined,
|
|
4625
|
+
args.accession as string | undefined,
|
|
4626
|
+
args.limit as number | undefined,
|
|
4627
|
+
);
|
|
1397
4628
|
default:
|
|
1398
4629
|
throw new Error(`Unknown tool: ${name}`);
|
|
1399
4630
|
}
|
|
@@ -1414,11 +4645,11 @@ async function xbrlFrames(args: Record<string, unknown>) {
|
|
|
1414
4645
|
const limit = Math.min(200, Math.max(1, Number(args.limit) || 25));
|
|
1415
4646
|
|
|
1416
4647
|
const url = `${DATA_BASE}/api/xbrl/frames/${taxonomy}/${encodeURIComponent(concept)}/${encodeURIComponent(unit)}/${period}.json`;
|
|
1417
|
-
const res = await
|
|
4648
|
+
const res = await pwFetch(url, { headers: SEC_HEADERS });
|
|
1418
4649
|
if (res.status === 404) {
|
|
1419
4650
|
return { error: 'not_found', concept, period, unit, taxonomy, message: `No SEC frame for ${taxonomy}/${concept}/${unit}/${period}. Check the tag spelling/casing, unit (USD vs shares), and period type (add "I" for balance-sheet/instant items like Assets).` };
|
|
1420
4651
|
}
|
|
1421
|
-
if (!res.ok) throw
|
|
4652
|
+
if (!res.ok) throw await httpError(res, 'SEC frames error');
|
|
1422
4653
|
const data = (await res.json()) as { taxonomy?: string; tag?: string; uom?: string; label?: string; description?: string; data?: FrameDatum[] };
|
|
1423
4654
|
const rows = (data.data ?? []).slice().sort((a, b) => sortDesc ? (b.val ?? 0) - (a.val ?? 0) : (a.val ?? 0) - (b.val ?? 0));
|
|
1424
4655
|
|