@pipeworx/mcp-edgar 0.3.12 → 0.3.13
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/README.md +5 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +704 -12
- package/src/server.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The Securities and Exchange Commission's filings database. Every public company in the US files here, every filing is timestamped and immutable, and every disclosure (revenue, debt, risk factors, executive compensation, M&A activity, insider trading) is structured for machine retrieval. Free and authoritative.
|
|
4
4
|
|
|
5
|
-
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1855+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
6
6
|
|
|
7
7
|
## Why this matters for AI agents
|
|
8
8
|
|
|
@@ -22,6 +22,8 @@ The two key flows — and the one call that does both:
|
|
|
22
22
|
|
|
23
23
|
**5. Product-level revenue.** "How much revenue did Keytruda generate?" → `edgar_product_revenue({ticker_or_cik: "MRK", product_filter: "Keytruda"})`. `edgar_company_concept`/`edgar_company_facts` only expose undimensioned XBRL totals — a product/segment breakdown is tagged with an XBRL dimension (a "Keytruda [Member]"), invisible to those APIs. This reads SEC's own rendered Financial Report of that dimensional data straight out of the revenue-disaggregation note, with a citation (accession, filing date, report URL). With no `form_type` it resolves 10-K, then 20-F, then 40-F, so foreign private issuers work the same way (`edgar_product_revenue({ticker_or_cik: "NVS", product_filter: "Entresto"})` reads Novartis's 20-F); the form actually used comes back as `resolved_form`. Not every filer tags product-level revenue in XBRL — Neurocrine discloses revenue by major customer and United Therapeutics only its recognition policy, and both return an explicit `not_found` rather than an empty array.
|
|
24
24
|
|
|
25
|
+
**6. Operating KPIs that never reach XBRL (press releases).** "Tesla's Q3 2026 vehicle deliveries and production" / "how much energy storage did Tesla deploy." A company's quarterly production, delivery, and shipment/deployment KPIs are reported in its *press release* — the exhibit attached to an 8-K — not in the structured financial statements, so `edgar_company_concept`/`edgar_company_facts` correctly have nothing to return for them. `edgar_filing_exhibit_text({ticker_or_cik: "TSLA", filed: "2026-10"})` reads the exhibit text directly (the 8-K's OWN primary document typically just says "see the attached exhibit" and carries none of the numbers) and defaults to the EX-99.* attachment, the universal convention for an Item 2.02/7.01 earnings or KPI release. Pass `search` for a specific figure ("vehicle deliveries", "Megapack", "same-store sales") instead of reading the whole release. Generalizes to any US filer's 8-K press release, not just Tesla's.
|
|
26
|
+
|
|
25
27
|
Both flows are usually preceded by `edgar_ticker_to_cik` or `resolve_entity({type: "company", value: "AAPL"})` to get the canonical 10-digit CIK.
|
|
26
28
|
|
|
27
29
|
## Auth
|
|
@@ -63,6 +65,7 @@ For insider trades specifically, see the dedicated `insider-trading` pack — it
|
|
|
63
65
|
- **A filing you did not NAME is a filing we CHOSE, and the envelope now says which (fleet #3319).** `edgar_filing_text` and `edgar_filing_documents` auto-resolve the LATEST filing of `form_type` when `accession` is omitted, and every field then describes that filing as though it had been asked for. Measured live 2026-10-18: a grounded question about "Tesla's September 29 2026 8-K" reached `edgar_filing_text({ticker:"TSLA", form_type:"8-K", search:"credit facility"})`, resolved to the 2026-10-02 8-K — a different filing, 3,061 chars, zero matches — and refused `not_in_source` 5 tries out of 5, while the filing asked about sat one row further down the same index with all three credit tranches in it. So every response carries `accession_source` (`"caller"` | `"auto_selected"`) plus, when we chose, an `auto_selected` block naming what was requested, what was picked, what was read with its exact-match count, and the candidates that were not read. **Pass `filed`** ("2026-09-29", "2026-09", "September 29, 2026") whenever the question names a date; for an 8-K the newest filing is routinely not the one meant. When only a `search` is available, `edgar_filing_text` reads on past a candidate whose text contains no exact match (bounded to 4 documents, exact matches only), and if none match it falls back to the newest WITH a warning that a zero-match result there is inconclusive about the company rather than about that filing.
|
|
64
66
|
- **`edgar_company_filings` used to drop a named `accession` on the floor.** It declared neither `accession` nor `include_primary_text`, so a caller that named one filing got the same unnarrowed list as one that named nothing — HTTP 200, reads as a clean success. That is how the fleet #3319 question finally died: the grounded ladder's last attempt routed here with `{accession:"0001628280-26-063820", ticker:"TSLA", include_primary_text:true}` (AE `blob12` — the router had already resolved the right 8-K) and the extraction model received a generic 10-row filings list carrying no facility amounts. Both arguments are real now, plus `filed`; a one-row result says so in `narrowed_note`, and an accession the filer never filed is a loud `not_found:` error rather than an unfiltered list.
|
|
65
67
|
- **`fiscal_year` and `fiscal_period` are filters AND response fields.** They are the names of two fields on every row in `values`, and the description's advice to "match the requested fiscal_year and fiscal_period" used to make argument-fillers send them as arguments, where they were undeclared and silently dropped — so the caller got all 89-230 reported periods back with nothing saying its filter had been ignored. They are now real optional filters: pass either or both and `values`/`latest` come back scoped to it, with a `period_filter` block echoing what was applied. An unmatched filter returns NO rows plus the list of fiscal years and periods the filer actually reports, rather than falling back to every period — `fiscal_year` is the filer's OWN label (NVDA's FY2024 ended January 2024), so a caller thinking in calendar years needs to see the labels rather than be handed a neighbouring year's number.
|
|
68
|
+
- **`edgar_search_filings` ignores `size`/`limit` upstream, and silently drops a one-sided date filter, if you don't account for both (fleet #3251).** EDGAR's search index (`efts.sec.gov`) returns a FIXED page of up to 100 hits no matter what `size` is requested — verified live — so the tool now trims to the caller's `limit` itself and reports `total_hits` (the true match count) separately from `returned`; a `note` says so whenever the fixed page held more than was asked for. Separately, `dateRange=custom` with only one of `startdt`/`enddt` set is silently IGNORED by efts (the filter never applies and filings from 2001 onward appear) — passing only `start_date` or only `end_date` now defaults the other end (EDGAR full-text coverage's floor, 2001-01-01, or today) rather than losing the filter. Pass `entity` (ticker, company name, or CIK; aliases `ticker_or_cik`/`cik`/`ticker`) to scope the market-wide search to one filer — resolved the same way `edgar_ticker_to_cik` resolves a company, with the delisted/non-listed fallback for filers absent from `company_tickers.json`. Because efts's response cache is keyed on `q`/dates/`forms`/`from` but NOT on `ciks`/`entityName` (`reference_efts_cache_key_leak`, fleet #619 — a `ciks`-filtered request can be served a cached UNFILTERED result as a clean 200), an entity filter appends a negated cache-busting token to `q` and every row is re-checked client-side against the requested CIK; a dropped mismatch is reported as `entity_filter_guard_dropped` rather than silently vanishing. The tool returns no text snippet — EDGAR's index has none — so chain a result's `accession` into `edgar_filing_text` with `search` set to the query for the matching passage, instead of this tool fetching every hit's document on your behalf (SEC rate-limits the whole fleet by one shared egress IP).
|
|
66
69
|
|
|
67
70
|
## Quick Start
|
|
68
71
|
|
|
@@ -108,7 +111,7 @@ directly, instead of just this one's:
|
|
|
108
111
|
}
|
|
109
112
|
```
|
|
110
113
|
|
|
111
|
-
Both URLs reach the same gateway and the same
|
|
114
|
+
Both URLs reach the same gateway and the same 1855+ data sources. The
|
|
112
115
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
113
116
|
reaches all of them from either one.
|
|
114
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-edgar",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
27
|
},
|
|
28
28
|
"pipeworx": {
|
|
29
|
-
"sourceHash": "v1-
|
|
30
|
-
"sourceCommit": "
|
|
29
|
+
"sourceHash": "v1-8dcbabc71f11a133be24546b4546bf7ee8497587841c394b19f76a770375639f",
|
|
30
|
+
"sourceCommit": "5749f83c0ea115fab4c0c0ef04b66b05f2507d67"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/edgar",
|
|
4
4
|
"title": "Edgar",
|
|
5
5
|
"description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.13",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/edgar",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-edgar",
|
package/src/index.ts
CHANGED
|
@@ -758,6 +758,254 @@ function collapse(s: string): string {
|
|
|
758
758
|
return s.replace(/\s+/g, ' ').trim();
|
|
759
759
|
}
|
|
760
760
|
|
|
761
|
+
// ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
|
|
762
|
+
//
|
|
763
|
+
// `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
|
|
764
|
+
// multi-megabyte upstream body is held TWICE at peak: once as the raw string
|
|
765
|
+
// and once as the object graph (which for an array of small objects runs
|
|
766
|
+
// several times the byte size). Nothing in `workers/gateway` can see that
|
|
767
|
+
// peak — it happens inside the pack, before any gateway code holds a payload —
|
|
768
|
+
// which is why the gateway-side payload caps of #3293 and #3320 did not move
|
|
769
|
+
// the 503/1102 rate at all.
|
|
770
|
+
//
|
|
771
|
+
// Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
|
|
772
|
+
// statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
|
|
773
|
+
// gnomad_gene BRCA1 2,732,706 B
|
|
774
|
+
// Five of those in flight at the quality runner's width is ~40 MB of parse
|
|
775
|
+
// peak against a 128 MB isolate; Cloudflare kills the isolate and every
|
|
776
|
+
// request in flight returns 503/1102, which is why the victims look random.
|
|
777
|
+
//
|
|
778
|
+
// `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
|
|
779
|
+
// returns one enormous top-level JSON array with no server-side paging. It
|
|
780
|
+
// parses elements off the stream and DISCARDS the consumed bytes, so peak
|
|
781
|
+
// retained memory is one element plus one chunk rather than the whole body,
|
|
782
|
+
// and it cancels the body as soon as it has what it was asked for.
|
|
783
|
+
|
|
784
|
+
/** Default ceiling for a single upstream JSON body read into an isolate.
|
|
785
|
+
* Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
|
|
786
|
+
* below the point where a handful of concurrent reads exhausts 128 MB. */
|
|
787
|
+
const DEFAULT_MAX_JSON_BYTES = 1_500_000;
|
|
788
|
+
|
|
789
|
+
interface JsonArrayPrefix<T> {
|
|
790
|
+
/** The decoded elements, at most `maxItems` of them. */
|
|
791
|
+
items: T[];
|
|
792
|
+
/** True when the upstream array had more elements than we read. */
|
|
793
|
+
truncated: boolean;
|
|
794
|
+
/** Bytes actually pulled off the socket — the number that bounds memory. */
|
|
795
|
+
bytesRead: number;
|
|
796
|
+
/** `Content-Length` if the upstream declared one, else null. */
|
|
797
|
+
declaredBytes: number | null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Read the FIRST `maxItems` elements of a top-level JSON array off a response
|
|
802
|
+
* stream without ever materialising the whole body.
|
|
803
|
+
*
|
|
804
|
+
* Use this instead of `parseJson` when the upstream returns one big array and
|
|
805
|
+
* offers no `limit`/`offset` of its own. Peak retained memory is one element
|
|
806
|
+
* plus the current chunk; the body is cancelled as soon as `maxItems` is
|
|
807
|
+
* reached, so the remaining megabytes are never transferred at all.
|
|
808
|
+
*
|
|
809
|
+
* const { items, truncated } = await readJsonArrayPrefix<Cube>(
|
|
810
|
+
* res, 'StatCan WDS', { maxItems: limit + offset });
|
|
811
|
+
*
|
|
812
|
+
* `maxBytes` is a second, independent backstop for a pathological element (a
|
|
813
|
+
* single 50 MB object): hitting it stops the read and sets `truncated`, rather
|
|
814
|
+
* than throwing, because a partial answer plus an explicit truncated flag is
|
|
815
|
+
* more useful to a caller than an error.
|
|
816
|
+
*/
|
|
817
|
+
async function readJsonArrayPrefix<T>(
|
|
818
|
+
res: Response,
|
|
819
|
+
name: string,
|
|
820
|
+
opts: { maxItems: number; maxBytes?: number },
|
|
821
|
+
): Promise<JsonArrayPrefix<T>> {
|
|
822
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
823
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
824
|
+
const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
825
|
+
|
|
826
|
+
// No streaming body available (a mock, or an already-buffered response):
|
|
827
|
+
// fall back to the whole-text path so behaviour stays correct, just unbounded.
|
|
828
|
+
if (!res.body) {
|
|
829
|
+
const all = await parseJson<T[]>(res, name);
|
|
830
|
+
if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
|
|
831
|
+
return {
|
|
832
|
+
items: all.slice(0, opts.maxItems),
|
|
833
|
+
truncated: all.length > opts.maxItems,
|
|
834
|
+
bytesRead: declaredBytes ?? 0,
|
|
835
|
+
declaredBytes,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const reader = res.body.getReader();
|
|
840
|
+
const decoder = new TextDecoder();
|
|
841
|
+
const items: T[] = [];
|
|
842
|
+
|
|
843
|
+
let buf = '';
|
|
844
|
+
let scan = 0;
|
|
845
|
+
let bytesRead = 0;
|
|
846
|
+
let started = false; // consumed the opening '['
|
|
847
|
+
let arrayEnded = false;
|
|
848
|
+
let depth = 0;
|
|
849
|
+
let inStr = false;
|
|
850
|
+
let esc = false;
|
|
851
|
+
let elemStart = -1;
|
|
852
|
+
let truncated = false;
|
|
853
|
+
|
|
854
|
+
const push = (text: string) => {
|
|
855
|
+
const t = text.trim();
|
|
856
|
+
if (!t) return;
|
|
857
|
+
items.push(JSON.parse(t) as T);
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
outer: while (items.length < opts.maxItems && !arrayEnded) {
|
|
862
|
+
const { value, done } = await reader.read();
|
|
863
|
+
if (done) break;
|
|
864
|
+
bytesRead += value.byteLength;
|
|
865
|
+
buf += decoder.decode(value, { stream: true });
|
|
866
|
+
if (bytesRead > maxBytes) { truncated = true; break; }
|
|
867
|
+
|
|
868
|
+
for (; scan < buf.length; scan++) {
|
|
869
|
+
const ch = buf[scan];
|
|
870
|
+
|
|
871
|
+
if (inStr) {
|
|
872
|
+
if (esc) { esc = false; continue; }
|
|
873
|
+
if (ch === '\\') { esc = true; continue; }
|
|
874
|
+
if (ch === '"') inStr = false;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (!started) {
|
|
879
|
+
if (ch === '[') { started = true; continue; }
|
|
880
|
+
if (ch.trim() === '') continue;
|
|
881
|
+
throw new Error(
|
|
882
|
+
`upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
|
|
883
|
+
'This reader is only for upstreams that answer with one top-level array.',
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
|
|
888
|
+
|
|
889
|
+
if (ch === '{' || ch === '[') {
|
|
890
|
+
if (depth === 0 && elemStart < 0) elemStart = scan;
|
|
891
|
+
depth++;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (ch === '}' || ch === ']') {
|
|
896
|
+
if (depth > 0) {
|
|
897
|
+
depth--;
|
|
898
|
+
if (depth === 0 && elemStart >= 0) {
|
|
899
|
+
push(buf.slice(elemStart, scan + 1));
|
|
900
|
+
elemStart = -1;
|
|
901
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
902
|
+
}
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
// depth 0 and a closing bracket: the end of the top-level array.
|
|
906
|
+
if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
|
|
907
|
+
arrayEnded = true;
|
|
908
|
+
break outer;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (ch === ',' && depth === 0) {
|
|
912
|
+
if (elemStart >= 0) {
|
|
913
|
+
push(buf.slice(elemStart, scan));
|
|
914
|
+
elemStart = -1;
|
|
915
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
916
|
+
}
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// A bare scalar element (number / true / false / null).
|
|
921
|
+
if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Drop everything already consumed. THIS is what bounds memory: without
|
|
925
|
+
// it `buf` grows to the full body and the reader is streaming in name only.
|
|
926
|
+
if (elemStart >= 0) {
|
|
927
|
+
buf = buf.slice(elemStart);
|
|
928
|
+
scan -= elemStart;
|
|
929
|
+
elemStart = 0;
|
|
930
|
+
} else {
|
|
931
|
+
buf = buf.slice(scan);
|
|
932
|
+
scan = 0;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
} finally {
|
|
936
|
+
// Cancel rather than drain: the point is that the remaining megabytes are
|
|
937
|
+
// never transferred. A cancel on an already-finished body is a no-op.
|
|
938
|
+
await reader.cancel().catch(() => {});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return { items, truncated, bytesRead, declaredBytes };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* `parseJson`, with a hard ceiling on how many bytes are read.
|
|
946
|
+
*
|
|
947
|
+
* For an upstream whose body is not a single array (so `readJsonArrayPrefix`
|
|
948
|
+
* does not apply) but which can still answer with megabytes. Unlike the array
|
|
949
|
+
* reader this one THROWS when the cap is hit, because a truncated prefix of an
|
|
950
|
+
* arbitrary JSON document is not parseable and there is no partial answer to
|
|
951
|
+
* hand back. A declared `Content-Length` over the cap is refused before a
|
|
952
|
+
* single chunk is read.
|
|
953
|
+
*/
|
|
954
|
+
async function parseJsonBounded<T>(
|
|
955
|
+
res: Response,
|
|
956
|
+
name: string,
|
|
957
|
+
maxBytes: number = DEFAULT_MAX_JSON_BYTES,
|
|
958
|
+
): Promise<T> {
|
|
959
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
960
|
+
const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
961
|
+
|
|
962
|
+
const tooBig = (n: number) =>
|
|
963
|
+
new Error(
|
|
964
|
+
`upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
|
|
965
|
+
'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
|
|
966
|
+
'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
|
|
967
|
+
'a specific id, or a lower limit) and retry.',
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
if (declared !== null && declared > maxBytes) {
|
|
971
|
+
await res.body?.cancel().catch(() => {});
|
|
972
|
+
throw tooBig(declared);
|
|
973
|
+
}
|
|
974
|
+
if (!res.body) return parseJson<T>(res, name);
|
|
975
|
+
|
|
976
|
+
const reader = res.body.getReader();
|
|
977
|
+
const decoder = new TextDecoder();
|
|
978
|
+
let raw = '';
|
|
979
|
+
let bytesRead = 0;
|
|
980
|
+
try {
|
|
981
|
+
for (;;) {
|
|
982
|
+
const { value, done } = await reader.read();
|
|
983
|
+
if (done) break;
|
|
984
|
+
bytesRead += value.byteLength;
|
|
985
|
+
if (bytesRead > maxBytes) throw tooBig(bytesRead);
|
|
986
|
+
raw += decoder.decode(value, { stream: true });
|
|
987
|
+
}
|
|
988
|
+
raw += decoder.decode();
|
|
989
|
+
} finally {
|
|
990
|
+
await reader.cancel().catch(() => {});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (!raw.trim()) {
|
|
994
|
+
throw new Error(
|
|
995
|
+
`upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
|
|
996
|
+
'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
return JSON.parse(raw) as T;
|
|
1001
|
+
} catch {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
|
|
1004
|
+
`It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
761
1009
|
|
|
762
1010
|
// Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
|
|
763
1011
|
// imports — so publish-pack.sh can inline it into standalone pack builds the same
|
|
@@ -1223,6 +1471,8 @@ function stripConcept(v: AnnualValue): Omit<AnnualValue, 'concept'> {
|
|
|
1223
1471
|
* (the resolve -> filings -> concept chain, collapsed; fleet #2182)
|
|
1224
1472
|
* - edgar_filing_documents: list the documents inside one filing by accession number
|
|
1225
1473
|
* - edgar_filing_text: full/paged plaintext of a filing's primary document (by section optional)
|
|
1474
|
+
* - edgar_filing_exhibit_text: full/paged plaintext of an EXHIBIT attached to a filing
|
|
1475
|
+
* (e.g. an 8-K's EX-99.1 press release) — the primary document often just points at it
|
|
1226
1476
|
*
|
|
1227
1477
|
* Note: SEC requires a descriptive User-Agent header per their guidelines.
|
|
1228
1478
|
*/
|
|
@@ -1248,7 +1498,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1248
1498
|
{
|
|
1249
1499
|
name: 'edgar_search_filings',
|
|
1250
1500
|
description:
|
|
1251
|
-
'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
|
|
1501
|
+
'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), date range, and optionally one filer (`entity`). Returns `total_hits` FIRST — the true match count for your filters, independent of how many rows you asked for — then 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. **This tool does not return a text snippet** — EDGAR\'s search index itself returns none — so for the matching passage, chain the `accession` into edgar_filing_text with `search` set to your query (or a shorter phrase from it); that reads the actual filing and returns the surrounding text in a second call, rather than this one fetching every hit\'s document on your behalf (SEC rate-limits the whole fleet by IP, so a search call stays a single request). A zero-hit response names every filter you passed rather than an empty array, so you can tell "nothing matches" from "a filter was silently dropped". 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).',
|
|
1252
1502
|
summary: 'Full-text search across every SEC filing, using EDGAR\'s own search index.',
|
|
1253
1503
|
inputSchema: {
|
|
1254
1504
|
type: 'object' as const,
|
|
@@ -1258,8 +1508,15 @@ const tools: McpToolExport['tools'] = [
|
|
|
1258
1508
|
type: 'string',
|
|
1259
1509
|
description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K", "DEF 14A"). Omit for all types.',
|
|
1260
1510
|
},
|
|
1261
|
-
start_date: { type: 'string', description: 'Start date in YYYY-MM-DD format (e.g., "2024-01-01")' },
|
|
1262
|
-
end_date: { type: 'string', description: 'End date in YYYY-MM-DD format (e.g., "2024-12-31")' },
|
|
1511
|
+
start_date: { type: 'string', description: 'Start date in YYYY-MM-DD format (e.g., "2024-01-01"). If you pass this without `end_date`, `end_date` is defaulted to today rather than silently ignored.' },
|
|
1512
|
+
end_date: { type: 'string', description: 'End date in YYYY-MM-DD format (e.g., "2024-12-31"). If you pass this without `start_date`, `start_date` is defaulted to EDGAR full-text search\'s coverage floor (2001-01-01) rather than silently ignored.' },
|
|
1513
|
+
entity: {
|
|
1514
|
+
type: 'string',
|
|
1515
|
+
description: 'Narrow results to ONE filer, by ticker ("AAPL"), company name ("Apple Inc."), or CIK ("320193") — omit to search across every filer. Aliases `ticker_or_cik` / `cik` / `ticker` are also accepted, the spellings this pack\'s other tools use for the same thing.',
|
|
1516
|
+
},
|
|
1517
|
+
ticker_or_cik: { type: 'string', description: 'Alias for `entity`.' },
|
|
1518
|
+
cik: { type: 'string', description: 'Alias for `entity`.' },
|
|
1519
|
+
ticker: { type: 'string', description: 'Alias for `entity`.' },
|
|
1263
1520
|
limit: { type: 'number', description: 'Number of results to return (1-40, default 10)' },
|
|
1264
1521
|
},
|
|
1265
1522
|
required: ['query'],
|
|
@@ -1345,7 +1602,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1345
1602
|
{
|
|
1346
1603
|
name: 'edgar_company_concept',
|
|
1347
1604
|
description:
|
|
1348
|
-
'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
|
|
1605
|
+
'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 and form, newest first, PLUS a `latest` field holding the single freshest data point. Every row also carries `period_start`/`period_end`/`duration_days` so the window a value covers is never ambiguous. 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`. CASH-FLOW-STATEMENT concepts (OperatingCashFlow, Capex and siblings) are filed CUMULATIVELY by most filers — a 10-Q\'s six-month figure is reported under the SAME period label the discrete quarter would use. Those rows are labeled `fiscal_period` "H1" (six months) or "9M" (nine months) with `ytd:true`, never "Q2"/"Q3" bare, and a discrete-quarter sibling (`derived:true`, e.g. Q2 = H1 minus Q1) is added alongside so "capex Q2" is still answerable without presenting the six-month total as a quarter. Income-statement lines (Revenue, NetIncomeLoss) are usually filed quarter-only already and are unaffected. 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; for a YTD-shaped concept, `latest` prefers the discrete derived quarter over the as-filed cumulative figure, so it is never silently a 2-3x-too-large number. 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.',
|
|
1349
1606
|
summary: 'One financial metric for a US public company across every period it has reported, from SEC XBRL.',
|
|
1350
1607
|
inputSchema: {
|
|
1351
1608
|
type: 'object' as const,
|
|
@@ -1370,8 +1627,8 @@ const tools: McpToolExport['tools'] = [
|
|
|
1370
1627
|
},
|
|
1371
1628
|
fiscal_period: {
|
|
1372
1629
|
type: 'string',
|
|
1373
|
-
description: 'Optional filter: return only rows for this period within the fiscal year — "FY" (annual), "Q1", "Q2", "Q3",
|
|
1374
|
-
enum: ['FY', 'Q1', 'Q2', 'Q3', 'Q4'],
|
|
1630
|
+
description: 'Optional filter: return only rows for this period within the fiscal year — "FY" (annual), "Q1", "Q2", "Q3", "Q4" (derived: FY minus Q1-Q3), "H1" (six-month year-to-date, cash-flow-statement concepts only), or "9M" (nine-month year-to-date). Combine with fiscal_year for a single figure. Asking for "Q2"/"Q3" on a concept this filer reports cumulatively returns the DERIVED discrete quarter, not the H1/9M total.',
|
|
1631
|
+
enum: ['FY', 'Q1', 'Q2', 'Q3', 'Q4', 'H1', '9M'],
|
|
1375
1632
|
},
|
|
1376
1633
|
ticker: {
|
|
1377
1634
|
type: 'string',
|
|
@@ -1773,6 +2030,62 @@ const tools: McpToolExport['tools'] = [
|
|
|
1773
2030
|
required: [],
|
|
1774
2031
|
},
|
|
1775
2032
|
},
|
|
2033
|
+
{
|
|
2034
|
+
name: 'edgar_filing_exhibit_text',
|
|
2035
|
+
description:
|
|
2036
|
+
'Full text of an EXHIBIT attached to a filing — not the filing\'s own primary document (use edgar_filing_text for that). This is the only way to read an 8-K\'s Exhibit 99.1/99.2 press release — a quarterly production-and-delivery report, an earnings release, a KPI update — because the 8-K body itself routinely just says "see the attached exhibit" and carries none of the numbers; the figures live in the exhibit document. Pass the filer\'s `ticker` or `cik` plus either an `accession` (from edgar_company_filings) or a `form_type` to auto-resolve the latest matching filing (defaults to "8-K" when both accession and form_type are omitted, since that is overwhelmingly when an exhibit holds the operative content). Set `exhibit` to pick a specific attachment by its SEC-assigned type — "99.1", "99", "10.1" — read off the filing\'s own document index (case-insensitive, the "EX-" prefix optional); omit it to default to the first EX-99.* exhibit, which is the convention Item 2.02 (earnings) and Item 7.01 (Reg FD) 8-Ks use for a press release. When the requested exhibit is not found (or none exists), the response lists every exhibit actually in the filing under `available_exhibits` rather than failing silently. **For a specific number inside the exhibit, pass `search`** (a short phrase likely to appear verbatim, e.g. "vehicle deliveries", "Megapack", "energy storage") the same way edgar_filing_text does — it scans the whole exhibit and returns matching passages ranked by a nearby figure, with `|`-separated phrases for more than one fact in one call; a zero-match search is a real answer (try different wording) and falls back to a word-level match when the exact phrase misses. Without `search`, the exhibit text is returned paged (`max_chars`/`offset`, same convention as edgar_filing_text) since a press release can run several thousand words. Use for "Tesla\'s Q3 2026 vehicle deliveries and production", "what did $TICKER\'s earnings press release say about [KPI]", "read the exhibit attached to $TICKER\'s latest 8-K". Source: U.S. Securities and Exchange Commission, EDGAR filing exhibits (the same primary-source filings edgar_filing_text and edgar_filing_documents read).',
|
|
2037
|
+
summary: 'Full text of an exhibit (e.g. a press release) attached to an SEC filing, such as an 8-K\'s EX-99.1.',
|
|
2038
|
+
inputSchema: {
|
|
2039
|
+
type: 'object' as const,
|
|
2040
|
+
properties: {
|
|
2041
|
+
ticker: {
|
|
2042
|
+
type: 'string',
|
|
2043
|
+
description: 'The filer\'s ticker (e.g. "TSLA") or company name. Provide this OR cik. Tickers are auto-resolved to CIKs.',
|
|
2044
|
+
},
|
|
2045
|
+
cik: {
|
|
2046
|
+
type: 'string',
|
|
2047
|
+
description: 'The filer\'s CIK number (e.g. "1318605"). Provide this OR ticker.',
|
|
2048
|
+
},
|
|
2049
|
+
ticker_or_cik: {
|
|
2050
|
+
type: 'string',
|
|
2051
|
+
description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_filing_documents and edgar_filing_text use. Takes either a ticker or a CIK.',
|
|
2052
|
+
},
|
|
2053
|
+
accession: {
|
|
2054
|
+
type: 'string',
|
|
2055
|
+
description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0001628280-26-064366"). Omit it to auto-resolve the latest filing matching form_type (and filed, if given) — the response says `accession_source:"auto_selected"` when this happens.',
|
|
2056
|
+
},
|
|
2057
|
+
form_type: {
|
|
2058
|
+
type: 'string',
|
|
2059
|
+
description: 'When accession is omitted, the form type of the filing to resolve — "8-K", "10-K", "10-Q", etc., or a `|`-separated set. Defaults to "8-K" when both accession and form_type are omitted, since an exhibit holding the operative content (a press release) is overwhelmingly an 8-K attachment.',
|
|
2060
|
+
},
|
|
2061
|
+
filed: {
|
|
2062
|
+
type: 'string',
|
|
2063
|
+
description: 'Only consider filings filed on this date, in this month, or in this year — "2026-10-02", "2026-10", "2026" (US spellings accepted). Pass this whenever the question names a date or quarter ("Tesla\'s Q3 2026 deliveries report") — Tesla and most large filers submit TWO 8-Ks a quarter (a day-2 production-and-delivery release, then a day-~22 full earnings release), so without a date the newest one is used and it may not be the one the question means.',
|
|
2064
|
+
},
|
|
2065
|
+
filing_date: { type: 'string', description: 'Alias for `filed`.' },
|
|
2066
|
+
exhibit: {
|
|
2067
|
+
type: 'string',
|
|
2068
|
+
description: 'Which exhibit to read, by its SEC-assigned type — "99.1", "EX-99.1", "99", "10.1" (case-insensitive, "EX-" prefix optional). Omit to default to the first EX-99.* exhibit (the press-release convention). If nothing matches, the response lists every exhibit the filing actually has under `available_exhibits` instead of failing with no leads.',
|
|
2069
|
+
},
|
|
2070
|
+
search: {
|
|
2071
|
+
type: 'string',
|
|
2072
|
+
description: 'Find a specific fact instead of paging blind — same convention as edgar_filing_text\'s `search`. A short 2-4 word phrase likely to appear VERBATIM (e.g. "vehicle deliveries", "energy storage deployments"), case-insensitive, scanned over the WHOLE exhibit text before any paging. Falls back to a word-level match (reported as such) when the exact phrase has no match. Separate multiple facts with `|` (e.g. "vehicle deliveries|energy storage deployments") to get both from this one exhibit in one call, each reported independently under `searches[]`.',
|
|
2073
|
+
},
|
|
2074
|
+
contains: { type: 'string', description: 'Alias for `search`.' },
|
|
2075
|
+
find: { type: 'string', description: 'Alias for `search`.' },
|
|
2076
|
+
phrase: { type: 'string', description: 'Alias for `search`.' },
|
|
2077
|
+
max_chars: {
|
|
2078
|
+
type: 'number',
|
|
2079
|
+
description: 'Max characters to return in this page when `search` is not used (1000–100000, default 50000). Text past this is available via next_offset.',
|
|
2080
|
+
},
|
|
2081
|
+
offset: {
|
|
2082
|
+
type: 'number',
|
|
2083
|
+
description: 'Character offset to start from when `search` is not used (default 0). Pass the prior result\'s next_offset to page forward.',
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
required: [],
|
|
2087
|
+
},
|
|
2088
|
+
},
|
|
1776
2089
|
];
|
|
1777
2090
|
|
|
1778
2091
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
@@ -1787,21 +2100,93 @@ function isNumericCik(value: string): boolean {
|
|
|
1787
2100
|
|
|
1788
2101
|
// ── Tool implementations ────────────────────────────────────────────
|
|
1789
2102
|
|
|
2103
|
+
// efts.sec.gov sits behind an API Gateway response cache keyed on q, the date
|
|
2104
|
+
// window, forms and from/page — but NOT on ciks/sics/entityName (measured
|
|
2105
|
+
// 2026-08-28, fleet #619: a ciks-filtered request was served the unfiltered
|
|
2106
|
+
// 10,000-hit result cached seconds earlier for the same q+dates, and the
|
|
2107
|
+
// reverse leak too). Fix proven live in mcps/sec-contracts: append a negated
|
|
2108
|
+
// nonsense token derived from the filter to `q`; efts compiles it to a
|
|
2109
|
+
// must_not match_phrase that matches no document (totals verified identical
|
|
2110
|
+
// with/without it), which makes the cache key unique per filter set.
|
|
2111
|
+
function eftsCikCacheKeyToken(paddedCik: string): string {
|
|
2112
|
+
return 'zqfc' + paddedCik.replace(/\D/g, '');
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// EDGAR full-text search's own coverage floor — a startdt before this is
|
|
2116
|
+
// clamped by EFTS itself, so defaulting a missing end date there is honest
|
|
2117
|
+
// rather than arbitrary.
|
|
2118
|
+
const EFTS_FLOOR = '2001-01-01';
|
|
2119
|
+
|
|
1790
2120
|
async function searchFilings(
|
|
1791
2121
|
query: string,
|
|
1792
2122
|
formType?: string,
|
|
1793
2123
|
startDate?: string,
|
|
1794
2124
|
endDate?: string,
|
|
1795
2125
|
limit?: number,
|
|
2126
|
+
entity?: string,
|
|
1796
2127
|
) {
|
|
1797
2128
|
const count = Math.min(40, Math.max(1, limit ?? 10));
|
|
1798
2129
|
const params = new URLSearchParams({ q: query, from: '0', size: String(count) });
|
|
1799
2130
|
|
|
1800
2131
|
if (formType) params.set('forms', formType);
|
|
2132
|
+
// efts silently IGNORES dateRange=custom when only one of startdt/enddt is
|
|
2133
|
+
// given (the filter array comes back empty and every year's hits appear) —
|
|
2134
|
+
// reference_efts_cache_key_leak, fleet #619. Always send both ends once
|
|
2135
|
+
// either is requested, defaulting the missing one rather than dropping the
|
|
2136
|
+
// filter the caller asked for.
|
|
2137
|
+
let effStart = startDate;
|
|
2138
|
+
let effEnd = endDate;
|
|
1801
2139
|
if (startDate || endDate) {
|
|
2140
|
+
effStart = startDate ?? EFTS_FLOOR;
|
|
2141
|
+
effEnd = endDate ?? new Date().toISOString().slice(0, 10);
|
|
1802
2142
|
params.set('dateRange', 'custom');
|
|
1803
|
-
|
|
1804
|
-
|
|
2143
|
+
params.set('startdt', effStart);
|
|
2144
|
+
params.set('enddt', effEnd);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Resolve an optional entity filter to a CIK. Numeric input is taken as a
|
|
2148
|
+
// CIK directly; otherwise try the ticker/name resolver, and for a delisted
|
|
2149
|
+
// or never-listed filer (not in company_tickers.json) fall back to EDGAR's
|
|
2150
|
+
// own entity-typeahead the same way edgar_ticker_to_cik does.
|
|
2151
|
+
let entityFilter: { cik: string; cik_padded: string; company_name?: string; ticker?: string | null; matched_by: string } | undefined;
|
|
2152
|
+
let entityFilterError: string | undefined;
|
|
2153
|
+
if (entity && String(entity).trim()) {
|
|
2154
|
+
const raw = String(entity).trim();
|
|
2155
|
+
try {
|
|
2156
|
+
if (/^\d{1,10}$/.test(raw)) {
|
|
2157
|
+
entityFilter = { cik: String(Number(raw)), cik_padded: raw.padStart(10, '0'), matched_by: 'cik' };
|
|
2158
|
+
} else {
|
|
2159
|
+
try {
|
|
2160
|
+
const ent = await resolveSecEntity(raw, { headers: SEC_HEADERS });
|
|
2161
|
+
entityFilter = { cik: ent.cik, cik_padded: ent.cik_padded, company_name: ent.company_name, ticker: ent.ticker, matched_by: ent.matched_by };
|
|
2162
|
+
} catch (err) {
|
|
2163
|
+
if (!/No SEC company matches/.test(String((err as Error)?.message))) throw err;
|
|
2164
|
+
const match = await resolveAnyFilerByName(raw, err as Error);
|
|
2165
|
+
entityFilter = { cik: match.cik, cik_padded: match.cik_padded, company_name: match.company_name, ticker: null, matched_by: match.matched_by };
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
// An entity filter that cannot be resolved at all is reported, not
|
|
2170
|
+
// silently dropped — an unfiltered market-wide search under a caller's
|
|
2171
|
+
// believed-narrowed request is a worse failure than an explicit error.
|
|
2172
|
+
entityFilterError = (err as Error)?.message ?? String(err);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
if (entityFilterError) {
|
|
2176
|
+
return {
|
|
2177
|
+
query,
|
|
2178
|
+
entity_filter: entity,
|
|
2179
|
+
entity_filter_error: entityFilterError,
|
|
2180
|
+
total_hits: 0,
|
|
2181
|
+
results: [],
|
|
2182
|
+
no_match: `"${entity}" could not be resolved to a filer — no search was run. Filters tried: query="${query}", form_type=${formType ?? 'all'}, entity="${entity}" (unresolved), date_range=${effStart ?? 'none'}..${effEnd ?? 'none'}.`,
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
if (entityFilter) {
|
|
2186
|
+
params.set('ciks', entityFilter.cik_padded);
|
|
2187
|
+
// Cache-key fix (see header): append the negated token so this request's
|
|
2188
|
+
// cache key differs from an unfiltered one for the same q/dates/forms.
|
|
2189
|
+
params.set('q', `${query} -${eftsCikCacheKeyToken(entityFilter.cik_padded)}`);
|
|
1805
2190
|
}
|
|
1806
2191
|
|
|
1807
2192
|
const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
|
|
@@ -1835,7 +2220,26 @@ async function searchFilings(
|
|
|
1835
2220
|
// adsh (accession), and — for 8-Ks — items[] (e.g. "3.01"). The prior mapper
|
|
1836
2221
|
// read entity_name/form_type/biz_location, which do NOT exist in this payload,
|
|
1837
2222
|
// so every field but the date/id serialized as undefined and dropped.
|
|
1838
|
-
|
|
2223
|
+
let guardDropped = 0;
|
|
2224
|
+
const rawHits = data.hits?.hits ?? [];
|
|
2225
|
+
const kept = entityFilter
|
|
2226
|
+
? rawHits.filter((hit) => {
|
|
2227
|
+
// Re-verify the entity filter on every row rather than trusting efts —
|
|
2228
|
+
// the same cache class that leaks an unfiltered result for a ciks
|
|
2229
|
+
// query (fleet #619) means "the server said it filtered" is not proof.
|
|
2230
|
+
const ok = (hit._source.ciks ?? []).some((c) => String(parseInt(c, 10)) === entityFilter!.cik);
|
|
2231
|
+
if (!ok) guardDropped++;
|
|
2232
|
+
return ok;
|
|
2233
|
+
})
|
|
2234
|
+
: rawHits;
|
|
2235
|
+
// efts has a FIXED page size (verified live, fleet #3251: a `size=3` request
|
|
2236
|
+
// still returned 100 hits) — the `size` param above is not honored, so
|
|
2237
|
+
// `limit` has to be applied client-side or every call silently returns up
|
|
2238
|
+
// to 100 rows regardless of what the caller asked for.
|
|
2239
|
+
const eftsPageOversized = rawHits.length > count;
|
|
2240
|
+
const trimmed = kept.slice(0, count);
|
|
2241
|
+
|
|
2242
|
+
const results = trimmed.map((hit) => {
|
|
1839
2243
|
const s = hit._source;
|
|
1840
2244
|
const display = s.display_names?.[0] ?? '';
|
|
1841
2245
|
// display_names is "NAME (TICKER) (CIK 000...)" (double-space before each
|
|
@@ -1858,12 +2262,23 @@ async function searchFilings(
|
|
|
1858
2262
|
};
|
|
1859
2263
|
});
|
|
1860
2264
|
|
|
2265
|
+
const totalHits = data.hits?.total?.value ?? 0;
|
|
2266
|
+
const filtersDescription = `query="${query}", form_type=${formType ?? 'all'}, entity=${entityFilter ? `${entityFilter.company_name ?? entityFilter.cik} (CIK ${entityFilter.cik})` : 'all filers'}, date_range=${effStart ?? 'none'}..${effEnd ?? 'none'}`;
|
|
2267
|
+
|
|
1861
2268
|
return {
|
|
1862
2269
|
query,
|
|
1863
2270
|
form_type_filter: formType ?? 'all',
|
|
1864
|
-
date_range: { start:
|
|
1865
|
-
|
|
2271
|
+
date_range: { start: effStart ?? null, end: effEnd ?? null },
|
|
2272
|
+
entity_filter: entityFilter
|
|
2273
|
+
? { cik: entityFilter.cik, company_name: entityFilter.company_name ?? null, ticker: entityFilter.ticker ?? null, matched_by: entityFilter.matched_by }
|
|
2274
|
+
: null,
|
|
2275
|
+
total_hits: totalHits,
|
|
2276
|
+
returned: results.length,
|
|
1866
2277
|
results,
|
|
2278
|
+
...(totalHits === 0 ? { no_match: `No filings matched. Filters tried: ${filtersDescription}.` } : {}),
|
|
2279
|
+
...(guardDropped > 0 ? { entity_filter_guard_dropped: guardDropped } : {}),
|
|
2280
|
+
...(eftsPageOversized ? { note: `EDGAR's search index returns a fixed page of up to ${rawHits.length} matches regardless of the requested count; trimmed to the ${count} you asked for (total_hits is the true match count, independent of this page size).` } : {}),
|
|
2281
|
+
snippet_note: 'EDGAR\'s search index returns no text snippet. To see the matching passage, pass `accession` from a result into edgar_filing_text with `search` set to this query (or a shorter phrase from it).',
|
|
1867
2282
|
};
|
|
1868
2283
|
}
|
|
1869
2284
|
|
|
@@ -3529,6 +3944,219 @@ async function filingText(
|
|
|
3529
3944
|
};
|
|
3530
3945
|
}
|
|
3531
3946
|
|
|
3947
|
+
// The raw directory listing (`${folder}/index.json`, what edgar_filing_documents
|
|
3948
|
+
// reads) carries a MIME-icon `type` per file ("text.gif", "compressed.gif") —
|
|
3949
|
+
// NOT the SEC exhibit number. The exhibit number ("EX-99.1") only appears on the
|
|
3950
|
+
// filing's own human -index.html page, in its "Document Format Files" table
|
|
3951
|
+
// (seq, description, document link, type, size). This is the general,
|
|
3952
|
+
// filer-agnostic way to find "the EX-99.1 attached to this filing" for ANY
|
|
3953
|
+
// company, not just Tesla — verified live against TSLA's 2026-10-02 8-K, whose
|
|
3954
|
+
// exhibit filename ("exhibit991111111.htm") carries no hint of "99.1" at all.
|
|
3955
|
+
function parseFilingIndexExhibits(html: string): Array<{ seq: string; description: string; filename: string; type: string }> {
|
|
3956
|
+
const out: Array<{ seq: string; description: string; filename: string; type: string }> = [];
|
|
3957
|
+
// Only the "Document Format Files" table — the sibling "Data Files" table
|
|
3958
|
+
// (XBRL schema/linkbase files) uses the same row shape but isn't a document
|
|
3959
|
+
// a caller would ever mean by "exhibit".
|
|
3960
|
+
const tableMatch = html.match(/<table[^>]*class="tableFile"[^>]*summary="Document Format Files"[\s\S]*?<\/table>/i);
|
|
3961
|
+
if (!tableMatch) return out;
|
|
3962
|
+
const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
3963
|
+
let row: RegExpExecArray | null;
|
|
3964
|
+
while ((row = rowRe.exec(tableMatch[0])) !== null) {
|
|
3965
|
+
const cellRe = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
|
3966
|
+
const cells: string[] = [];
|
|
3967
|
+
let cell: RegExpExecArray | null;
|
|
3968
|
+
while ((cell = cellRe.exec(row[1])) !== null) cells.push(cell[1]);
|
|
3969
|
+
if (cells.length < 4) continue; // header row or malformed
|
|
3970
|
+
const seq = decodeHtml(cells[0].replace(/<[^>]+>/g, '').trim());
|
|
3971
|
+
const description = decodeHtml(cells[1].replace(/<[^>]+>/g, '').trim());
|
|
3972
|
+
const hrefMatch = cells[2].match(/href="([^"]+)"/i);
|
|
3973
|
+
if (!hrefMatch) continue; // "Complete submission text file" row and similar have no useful link here
|
|
3974
|
+
const filename = hrefMatch[1].split('/').pop()!.split(/[?#]/)[0];
|
|
3975
|
+
const type = decodeHtml(cells[3].replace(/<[^>]+>/g, '').trim());
|
|
3976
|
+
if (!filename || !seq || seq === ' ') continue; // skip the blank-seq "Complete submission" row
|
|
3977
|
+
out.push({ seq, description, filename, type });
|
|
3978
|
+
}
|
|
3979
|
+
return out;
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
// Normalize an exhibit identifier the way a caller would type it ("99.1",
|
|
3983
|
+
// "EX-99.1", "ex99.1") to compare against the TYPE column's "EX-99.1" shape.
|
|
3984
|
+
function normalizeExhibitId(raw: string): string {
|
|
3985
|
+
return raw.trim().toUpperCase().replace(/^EX-?/, '').replace(/\s+/g, '');
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
async function filingExhibitText(
|
|
3989
|
+
accession: string | undefined,
|
|
3990
|
+
tickerOrCik: string,
|
|
3991
|
+
exhibitArg: string | undefined,
|
|
3992
|
+
searchTerm?: string,
|
|
3993
|
+
maxChars?: number,
|
|
3994
|
+
offset?: number,
|
|
3995
|
+
wantFormTypeArg?: string,
|
|
3996
|
+
filedArg?: string,
|
|
3997
|
+
) {
|
|
3998
|
+
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
3999
|
+
throw new Error(
|
|
4000
|
+
'A company is required: pass `ticker` ("TSLA"), `cik` or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_exhibit_text({ticker:"TSLA", form_type:"8-K"})).',
|
|
4001
|
+
);
|
|
4002
|
+
}
|
|
4003
|
+
// Defaults to "8-K" (not "any type") when the caller named neither accession
|
|
4004
|
+
// nor form_type: an exhibit worth reading for its own text is overwhelmingly
|
|
4005
|
+
// the press release on an 8-K, and defaulting to "the single most recent
|
|
4006
|
+
// filing of any type" (edgar_filing_documents' own fallback) would routinely
|
|
4007
|
+
// land on an unrelated Form 4 or Form 144 between 8-Ks (the same trap
|
|
4008
|
+
// edgar_filing_text's form_type doc warns about, fleet #2450).
|
|
4009
|
+
const effectiveFormType = accession ? wantFormTypeArg : (wantFormTypeArg ?? '8-K');
|
|
4010
|
+
|
|
4011
|
+
const docsResult = (await filingDocuments(accession, tickerOrCik, false, effectiveFormType, filedArg)) as {
|
|
4012
|
+
accession: string;
|
|
4013
|
+
cik: string;
|
|
4014
|
+
company: string | null;
|
|
4015
|
+
form_type: string | null;
|
|
4016
|
+
filing_date: string | null;
|
|
4017
|
+
accession_source: 'caller' | 'auto_selected';
|
|
4018
|
+
auto_selected?: Record<string, unknown>;
|
|
4019
|
+
};
|
|
4020
|
+
|
|
4021
|
+
const acc = normalizeAccession(docsResult.accession)!;
|
|
4022
|
+
const folder = `https://www.sec.gov/Archives/edgar/data/${docsResult.cik}/${acc.nodash}`;
|
|
4023
|
+
|
|
4024
|
+
const idxHtmlRes = await pwFetch(`${folder}/${acc.dashed}-index.html`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
4025
|
+
if (!idxHtmlRes.ok) throw await httpError(idxHtmlRes, 'SEC EDGAR filing index page error');
|
|
4026
|
+
const idxHtml = await idxHtmlRes.text();
|
|
4027
|
+
const exhibits = parseFilingIndexExhibits(idxHtml);
|
|
4028
|
+
|
|
4029
|
+
if (!exhibits.length) {
|
|
4030
|
+
throw new Error(
|
|
4031
|
+
`Could not read the document table for accession ${acc.dashed}. Use edgar_filing_documents({accession:"${acc.dashed}", ticker_or_cik:"${tickerOrCik}"}) to list its files directly.`,
|
|
4032
|
+
);
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
const wantExhibit = (exhibitArg ?? '').trim();
|
|
4036
|
+
let matches: typeof exhibits;
|
|
4037
|
+
if (wantExhibit) {
|
|
4038
|
+
const norm = normalizeExhibitId(wantExhibit);
|
|
4039
|
+
matches = exhibits.filter((e) => normalizeExhibitId(e.type) === norm || normalizeExhibitId(e.type).startsWith(norm + '.'));
|
|
4040
|
+
} else {
|
|
4041
|
+
matches = exhibits.filter((e) => /^EX-?99/i.test(e.type));
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
if (!matches.length) {
|
|
4045
|
+
return {
|
|
4046
|
+
accession: acc.dashed,
|
|
4047
|
+
cik: docsResult.cik,
|
|
4048
|
+
company: docsResult.company,
|
|
4049
|
+
form_type: docsResult.form_type,
|
|
4050
|
+
filing_date: docsResult.filing_date,
|
|
4051
|
+
accession_source: docsResult.accession_source,
|
|
4052
|
+
...(docsResult.auto_selected ? { auto_selected: docsResult.auto_selected } : {}),
|
|
4053
|
+
exhibit_requested: wantExhibit || 'EX-99.* (default — no exhibit matches this type in this filing)',
|
|
4054
|
+
available_exhibits: exhibits.map((e) => ({ seq: e.seq, type: e.type || null, description: e.description || null, filename: e.filename })),
|
|
4055
|
+
error: wantExhibit
|
|
4056
|
+
? `No exhibit matching "${wantExhibit}" in this filing. See \`available_exhibits\` for what this filing actually contains, and pass one of those \`type\` values as \`exhibit\`.`
|
|
4057
|
+
: `This filing has no EX-99.* exhibit (the usual press-release attachment for Item 2.02/7.01 8-Ks). See \`available_exhibits\` and pass \`exhibit\` explicitly if one of those is the one wanted.`,
|
|
4058
|
+
};
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
const chosen = matches[0];
|
|
4062
|
+
const docUrl = `${folder}/${chosen.filename}`;
|
|
4063
|
+
const { text: rawFetched, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
|
|
4064
|
+
// An individual exhibit document (unlike the primary iXBRL document) is
|
|
4065
|
+
// routinely served with its SGML submission wrapper still attached --
|
|
4066
|
+
// <DOCUMENT><TYPE>EX-99.1<SEQUENCE>2<FILENAME>...<DESCRIPTION>...<TEXT> --
|
|
4067
|
+
// verified live on TSLA's 2026-10-02 EX-99.1. Untouched, htmlToText's tag
|
|
4068
|
+
// stripper reads each SGML tag as an HTML tag and turns it into stray leading
|
|
4069
|
+
// tokens ("EX-99.1", "2", the filename again, "Document") ahead of the real
|
|
4070
|
+
// content. Strip everything through the first <TEXT> tag before stripping
|
|
4071
|
+
// HTML, so a reader of the real content never sees SEC's filing-agent
|
|
4072
|
+
// bookkeeping. A document with no such wrapper (the common case) is untouched.
|
|
4073
|
+
const raw = rawFetched.replace(/^[\s\S]{0,400}?<text>\s*/i, (m) => (/^\s*<document>/i.test(m) ? '' : m));
|
|
4074
|
+
const isHtml = /\.x?html?$/i.test(chosen.filename) || /^\s*</.test(raw);
|
|
4075
|
+
const fullText = isHtml ? htmlToText(raw) : raw;
|
|
4076
|
+
const documentChars = fullText.length;
|
|
4077
|
+
|
|
4078
|
+
const base = {
|
|
4079
|
+
accession: acc.dashed,
|
|
4080
|
+
cik: docsResult.cik,
|
|
4081
|
+
company: docsResult.company,
|
|
4082
|
+
form_type: docsResult.form_type,
|
|
4083
|
+
filing_date: docsResult.filing_date,
|
|
4084
|
+
accession_source: docsResult.accession_source,
|
|
4085
|
+
...(docsResult.auto_selected ? { auto_selected: docsResult.auto_selected } : {}),
|
|
4086
|
+
exhibit: chosen.type || null,
|
|
4087
|
+
exhibit_description: chosen.description || null,
|
|
4088
|
+
document: chosen.filename,
|
|
4089
|
+
document_url: docUrl,
|
|
4090
|
+
source: 'U.S. Securities and Exchange Commission, EDGAR filing exhibits',
|
|
4091
|
+
...(exhibits.length > 1 ? { available_exhibits: exhibits.map((e) => ({ seq: e.seq, type: e.type || null, description: e.description || null, filename: e.filename })) } : {}),
|
|
4092
|
+
};
|
|
4093
|
+
|
|
4094
|
+
const searchPhrases = [...new Set((searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean))].slice(0, 5);
|
|
4095
|
+
|
|
4096
|
+
if (searchPhrases.length > 0) {
|
|
4097
|
+
const buildOne = (term: string) => {
|
|
4098
|
+
const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
|
|
4099
|
+
const wordHit = totalMatches === 0 ? findWordPassages(fullText, term) : null;
|
|
4100
|
+
return wordHit
|
|
4101
|
+
? {
|
|
4102
|
+
term,
|
|
4103
|
+
match_mode: 'words' as const,
|
|
4104
|
+
exact_matches: 0,
|
|
4105
|
+
words: wordHit.words,
|
|
4106
|
+
distinct_locations: wordHit.distinct_locations,
|
|
4107
|
+
returned: wordHit.passages.length,
|
|
4108
|
+
truncated: wordHit.distinct_locations > wordHit.passages.length,
|
|
4109
|
+
passages: wordHit.passages,
|
|
4110
|
+
note: `The exact phrase "${term}" does not appear in this exhibit, so these are passages holding its individual WORDS, ranked by how many of them each passage contains (then by a nearby figure). A word-level match is a lead, not a confirmation.`,
|
|
4111
|
+
}
|
|
4112
|
+
: {
|
|
4113
|
+
term,
|
|
4114
|
+
match_mode: 'exact' as const,
|
|
4115
|
+
total_matches: totalMatches,
|
|
4116
|
+
distinct_locations: distinctLocations,
|
|
4117
|
+
returned: passages.length,
|
|
4118
|
+
truncated: distinctLocations > passages.length,
|
|
4119
|
+
passages,
|
|
4120
|
+
note: totalMatches === 0
|
|
4121
|
+
? 'No case-insensitive match for this wording (nor any of its individual words) in this exhibit. That is a real answer, not a failure — retry with different wording, or this fact may be in a different exhibit (see `available_exhibits`).'
|
|
4122
|
+
: 'Passages carrying a nearby number (a likely KPI figure) are ranked first; the rest keep document order.',
|
|
4123
|
+
};
|
|
4124
|
+
};
|
|
4125
|
+
const results = searchPhrases.map(buildOne);
|
|
4126
|
+
const returnedChars = results.reduce((n, r) => n + r.passages.reduce((m, p) => m + p.text.length, 0), 0);
|
|
4127
|
+
const searchTruncated = returnedChars < documentChars;
|
|
4128
|
+
|
|
4129
|
+
return {
|
|
4130
|
+
...base,
|
|
4131
|
+
total_chars: documentChars,
|
|
4132
|
+
returned_chars: returnedChars,
|
|
4133
|
+
truncated: searchTruncated,
|
|
4134
|
+
...(searchTruncated
|
|
4135
|
+
? { truncated_note: `These are matched passages, not the exhibit: ${returnedChars} of ${documentChars} characters. Call again without \`search\` (or with a passage's \`offset\`) to read the surrounding text.` }
|
|
4136
|
+
: {}),
|
|
4137
|
+
...(searchPhrases.length === 1 ? { search: results[0] } : { searches: searchPhrases.map((phrase, i) => ({ phrase, ...results[i] })) }),
|
|
4138
|
+
...(rawTruncated ? { raw_truncated: true } : {}),
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
const cap = Math.min(FILING_TEXT_CAP, Math.max(1000, Math.floor(Number(maxChars) || FILING_TEXT_DEFAULT_MAX)));
|
|
4143
|
+
const off = Math.max(0, Math.floor(Number(offset) || 0));
|
|
4144
|
+
const slice = fullText.slice(off, off + cap);
|
|
4145
|
+
const end = off + slice.length;
|
|
4146
|
+
const truncated = end < documentChars;
|
|
4147
|
+
|
|
4148
|
+
return {
|
|
4149
|
+
...base,
|
|
4150
|
+
total_chars: documentChars,
|
|
4151
|
+
offset: off,
|
|
4152
|
+
returned_chars: slice.length,
|
|
4153
|
+
truncated,
|
|
4154
|
+
next_offset: truncated ? end : null,
|
|
4155
|
+
...(rawTruncated ? { raw_truncated: true } : {}),
|
|
4156
|
+
text: slice,
|
|
4157
|
+
};
|
|
4158
|
+
}
|
|
4159
|
+
|
|
3532
4160
|
// data.sec.gov answers a bare 404 both when no filer has the CIK at all and when
|
|
3533
4161
|
// the filer exists but has never submitted XBRL financial data (investment funds,
|
|
3534
4162
|
// trusts, individuals, most pre-2011 foreign private issuers). "SEC EDGAR company
|
|
@@ -4146,6 +4774,12 @@ async function companyConcept(
|
|
|
4146
4774
|
accession: entry.accn as string | undefined,
|
|
4147
4775
|
unit,
|
|
4148
4776
|
derived: false as boolean | undefined,
|
|
4777
|
+
// Duration this fact actually covers, in days — exposed so a caller (or
|
|
4778
|
+
// this file's own logic below) never has to trust the SEC-assigned
|
|
4779
|
+
// fiscal_period label to know the window. See the YTD-relabeling pass
|
|
4780
|
+
// below (fleet #3187): a ~180d "Q2" is six months, not a quarter.
|
|
4781
|
+
duration_days: entry.start ? spanDays(entry.start, entry.end) : undefined,
|
|
4782
|
+
ytd: false as boolean,
|
|
4149
4783
|
note: undefined as string | undefined,
|
|
4150
4784
|
total_liquidity: undefined as LiquidityBreakdown | undefined,
|
|
4151
4785
|
}))
|
|
@@ -4196,6 +4830,8 @@ async function companyConcept(
|
|
|
4196
4830
|
accession: undefined, // arithmetic over FY + Q1-Q3, not a fact one filing states
|
|
4197
4831
|
unit: fyRow.unit,
|
|
4198
4832
|
derived: true,
|
|
4833
|
+
duration_days: undefined, // no period_start to compute a span from
|
|
4834
|
+
ytd: false,
|
|
4199
4835
|
note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
|
|
4200
4836
|
total_liquidity: undefined,
|
|
4201
4837
|
});
|
|
@@ -4261,6 +4897,8 @@ async function companyConcept(
|
|
|
4261
4897
|
accession: undefined, // YTD(Q2) minus Q1 — spans two filings
|
|
4262
4898
|
unit: q2ytd.unit,
|
|
4263
4899
|
derived: true,
|
|
4900
|
+
duration_days: spanDays(q1.period_end, q2ytd.period_end),
|
|
4901
|
+
ytd: false, // this IS the discrete quarter — the YTD fact it was computed from keeps its own row, relabeled below
|
|
4264
4902
|
note: `Computed as the YTD figure filed for Q2 (${q2ytd.value}, covering ${q2ytd.period_start}..${q2ytd.period_end}) minus Q1 (${q1.value}) — this filer reports this concept cumulatively in its 10-Q (a cash-flow-statement concept, typically), not quarter-only.`,
|
|
4265
4903
|
total_liquidity: undefined,
|
|
4266
4904
|
});
|
|
@@ -4281,6 +4919,8 @@ async function companyConcept(
|
|
|
4281
4919
|
accession: undefined, // YTD(Q3) minus YTD(Q2) — spans two filings
|
|
4282
4920
|
unit: q3ytd.unit,
|
|
4283
4921
|
derived: true,
|
|
4922
|
+
duration_days: spanDays(q2ytd.period_end, q3ytd.period_end),
|
|
4923
|
+
ytd: false, // the discrete quarter; the YTD fact it was computed from keeps its own row, relabeled below
|
|
4284
4924
|
note: `Computed as the YTD figure filed for Q3 (${q3ytd.value}) minus the YTD figure filed for Q2 (${q2ytd.value}) — this filer reports this concept cumulatively in its 10-Q, not quarter-only.`,
|
|
4285
4925
|
total_liquidity: undefined,
|
|
4286
4926
|
});
|
|
@@ -4291,6 +4931,33 @@ async function companyConcept(
|
|
|
4291
4931
|
entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
4292
4932
|
}
|
|
4293
4933
|
|
|
4934
|
+
// RELABEL YTD-SHAPED AS-FILED FACTS (fleet #3187). The block above derives a
|
|
4935
|
+
// discrete quarter FROM the as-filed cumulative fact but leaves that original
|
|
4936
|
+
// fact in `entries` still labeled fiscal_period "Q2"/"Q3" — the SEC label for
|
|
4937
|
+
// the period the filing covers, not the duration of the fact itself. A 180-day
|
|
4938
|
+
// fact labeled "Q2" reads as a quarter to any caller that doesn't check
|
|
4939
|
+
// duration_days, and `edgar_company_concept({concept:'capex', fiscal_year:2026})`
|
|
4940
|
+
// on TSLA did exactly that: 8.282B (six months) presented as fiscal_period
|
|
4941
|
+
// "Q2" alongside a discrete-quarter sibling nobody was told to prefer.
|
|
4942
|
+
// Relabel BY DURATION (not by tag, so it covers any concept a filer reports
|
|
4943
|
+
// this way) to "H1"/"9M" and mark `ytd:true`, so "Q2"/"Q3" always means a real
|
|
4944
|
+
// quarter and the six/nine-month total is never presented as one. Runs AFTER
|
|
4945
|
+
// the Q4 and standalone-quarter derivations above, which rely on the original
|
|
4946
|
+
// Q2/Q3 labels to find their YTD legs, and BEFORE the period filter below, so
|
|
4947
|
+
// a caller asking fiscal_period:"Q2" gets the discrete quarter, not the H1 total.
|
|
4948
|
+
for (const e of entries) {
|
|
4949
|
+
if (e.derived || e.unit.includes('/')) continue; // derived rows are already correctly labeled; ratio/per-share units don't sum/subtract meaningfully
|
|
4950
|
+
if (e.fiscal_period === 'Q2' && e.duration_days != null && e.duration_days >= 170 && e.duration_days <= 190) {
|
|
4951
|
+
e.fiscal_period = 'H1';
|
|
4952
|
+
e.ytd = true;
|
|
4953
|
+
e.note = `Six-month (H1) year-to-date figure (${e.period_start}..${e.period_end}), as filed — this filer reports this concept cumulatively in its 10-Q, not quarter-only. Not a Q2 quarterly figure; see the derived Q2 row for that.`;
|
|
4954
|
+
} else if (e.fiscal_period === 'Q3' && e.duration_days != null && e.duration_days >= 260 && e.duration_days <= 290) {
|
|
4955
|
+
e.fiscal_period = '9M';
|
|
4956
|
+
e.ytd = true;
|
|
4957
|
+
e.note = `Nine-month (9M) year-to-date figure (${e.period_start}..${e.period_end}), as filed — this filer reports this concept cumulatively in its 10-Q, not quarter-only. Not a Q3 quarterly figure; see the derived Q3 row for that.`;
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4294
4961
|
// PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
|
|
4295
4962
|
// names of two fields on every row below, and callers asking a one-period
|
|
4296
4963
|
// question ("net income fiscal 2024") send them as ARGUMENTS — measured on
|
|
@@ -4439,7 +5106,20 @@ async function companyConcept(
|
|
|
4439
5106
|
}
|
|
4440
5107
|
}
|
|
4441
5108
|
|
|
4442
|
-
|
|
5109
|
+
// `latest` must never be a bare YTD figure presented without saying so. The
|
|
5110
|
+
// freshest row by period_end can now be an as-filed "H1"/"9M" fact (ytd:true,
|
|
5111
|
+
// relabeled above) — when that happens, prefer its DERIVED discrete-quarter
|
|
5112
|
+
// sibling at the same period_end instead (an exact subtraction within the
|
|
5113
|
+
// same fiscal year, so it's trustworthy even though derived:true), the same
|
|
5114
|
+
// way `values` already offers both. Q4 stays excluded from this preference:
|
|
5115
|
+
// FY-minus-9M is the one case where no better reported fact exists at all, so
|
|
5116
|
+
// it is never promoted to `latest` — a caller explicitly filtering
|
|
5117
|
+
// fiscal_period:"Q4" still gets it from `values`.
|
|
5118
|
+
const freshestEnd = rows[0]?.period_end;
|
|
5119
|
+
const latestFact =
|
|
5120
|
+
rows.find((e) => e.derived && e.fiscal_period !== 'Q4' && e.period_end === freshestEnd)
|
|
5121
|
+
?? rows.find((e) => !e.derived)
|
|
5122
|
+
?? null;
|
|
4443
5123
|
let coverageBound: string | undefined;
|
|
4444
5124
|
if (latestFact?.filed) {
|
|
4445
5125
|
const daysSinceFiled = (Date.now() - Date.parse(latestFact.filed)) / 86400000;
|
|
@@ -5667,6 +6347,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
5667
6347
|
args.start_date as string | undefined,
|
|
5668
6348
|
args.end_date as string | undefined,
|
|
5669
6349
|
args.limit as number | undefined,
|
|
6350
|
+
(args.entity ?? args.ticker_or_cik ?? args.cik ?? args.ticker) as string | undefined,
|
|
5670
6351
|
);
|
|
5671
6352
|
// Every tool below takes the same thing — a company — and this pack spells
|
|
5672
6353
|
// it three ways across its own tools (`ticker_or_cik`, `cik`, `ticker`), so
|
|
@@ -5741,6 +6422,17 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
5741
6422
|
(args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
|
|
5742
6423
|
(args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
|
|
5743
6424
|
);
|
|
6425
|
+
case 'edgar_filing_exhibit_text':
|
|
6426
|
+
return filingExhibitText(
|
|
6427
|
+
args.accession as string | undefined,
|
|
6428
|
+
(args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
|
|
6429
|
+
args.exhibit as string | undefined,
|
|
6430
|
+
(args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
|
|
6431
|
+
args.max_chars as number | undefined,
|
|
6432
|
+
args.offset as number | undefined,
|
|
6433
|
+
args.form_type as string | undefined,
|
|
6434
|
+
(args.filed ?? args.filing_date) as string | undefined,
|
|
6435
|
+
);
|
|
5744
6436
|
case 'edgar_ticker_to_cik':
|
|
5745
6437
|
return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
|
|
5746
6438
|
case 'sponsor_to_filer':
|