@pipeworx/mcp-edgar 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -382,12 +382,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
382
382
  // internal-host-class.ts; no-op for every third-party upstream, which is why
383
383
  // this touches 481 packs' error text and changes none of it.
384
384
  return markInternalOrigin(
385
- `${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
385
+ `${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
386
386
  res.url,
387
387
  res.status,
388
388
  );
389
389
  }
390
390
 
391
+ /**
392
+ * Facts about a FAILED response that live in its headers, not its body, appended
393
+ * so the gateway's classifier (which only ever sees this string) can tell a limit
394
+ * from a wall from a refusal (fleet #3477). Both are standard, both are absent on
395
+ * almost every response, and neither is invented: `Retry-After` is the server
396
+ * saying "come back later" (the one thing that makes a 403 a throttle), and
397
+ * Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
398
+ */
399
+ function responseHints(res: Response): string {
400
+ const hints: string[] = [];
401
+ const retryAfter = res.headers.get('retry-after');
402
+ if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
403
+ if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
404
+ return hints.length ? ` (${hints.join('; ')})` : '';
405
+ }
406
+
391
407
  /**
392
408
  * Just the upstream's own explanation — no name, no status.
393
409
  *
@@ -758,6 +774,254 @@ function collapse(s: string): string {
758
774
  return s.replace(/\s+/g, ' ').trim();
759
775
  }
760
776
 
777
+ // ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
778
+ //
779
+ // `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
780
+ // multi-megabyte upstream body is held TWICE at peak: once as the raw string
781
+ // and once as the object graph (which for an array of small objects runs
782
+ // several times the byte size). Nothing in `workers/gateway` can see that
783
+ // peak — it happens inside the pack, before any gateway code holds a payload —
784
+ // which is why the gateway-side payload caps of #3293 and #3320 did not move
785
+ // the 503/1102 rate at all.
786
+ //
787
+ // Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
788
+ // statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
789
+ // gnomad_gene BRCA1 2,732,706 B
790
+ // Five of those in flight at the quality runner's width is ~40 MB of parse
791
+ // peak against a 128 MB isolate; Cloudflare kills the isolate and every
792
+ // request in flight returns 503/1102, which is why the victims look random.
793
+ //
794
+ // `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
795
+ // returns one enormous top-level JSON array with no server-side paging. It
796
+ // parses elements off the stream and DISCARDS the consumed bytes, so peak
797
+ // retained memory is one element plus one chunk rather than the whole body,
798
+ // and it cancels the body as soon as it has what it was asked for.
799
+
800
+ /** Default ceiling for a single upstream JSON body read into an isolate.
801
+ * Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
802
+ * below the point where a handful of concurrent reads exhausts 128 MB. */
803
+ const DEFAULT_MAX_JSON_BYTES = 1_500_000;
804
+
805
+ interface JsonArrayPrefix<T> {
806
+ /** The decoded elements, at most `maxItems` of them. */
807
+ items: T[];
808
+ /** True when the upstream array had more elements than we read. */
809
+ truncated: boolean;
810
+ /** Bytes actually pulled off the socket — the number that bounds memory. */
811
+ bytesRead: number;
812
+ /** `Content-Length` if the upstream declared one, else null. */
813
+ declaredBytes: number | null;
814
+ }
815
+
816
+ /**
817
+ * Read the FIRST `maxItems` elements of a top-level JSON array off a response
818
+ * stream without ever materialising the whole body.
819
+ *
820
+ * Use this instead of `parseJson` when the upstream returns one big array and
821
+ * offers no `limit`/`offset` of its own. Peak retained memory is one element
822
+ * plus the current chunk; the body is cancelled as soon as `maxItems` is
823
+ * reached, so the remaining megabytes are never transferred at all.
824
+ *
825
+ * const { items, truncated } = await readJsonArrayPrefix<Cube>(
826
+ * res, 'StatCan WDS', { maxItems: limit + offset });
827
+ *
828
+ * `maxBytes` is a second, independent backstop for a pathological element (a
829
+ * single 50 MB object): hitting it stops the read and sets `truncated`, rather
830
+ * than throwing, because a partial answer plus an explicit truncated flag is
831
+ * more useful to a caller than an error.
832
+ */
833
+ async function readJsonArrayPrefix<T>(
834
+ res: Response,
835
+ name: string,
836
+ opts: { maxItems: number; maxBytes?: number },
837
+ ): Promise<JsonArrayPrefix<T>> {
838
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
839
+ const declaredRaw = Number(res.headers.get('content-length'));
840
+ const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
841
+
842
+ // No streaming body available (a mock, or an already-buffered response):
843
+ // fall back to the whole-text path so behaviour stays correct, just unbounded.
844
+ if (!res.body) {
845
+ const all = await parseJson<T[]>(res, name);
846
+ if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
847
+ return {
848
+ items: all.slice(0, opts.maxItems),
849
+ truncated: all.length > opts.maxItems,
850
+ bytesRead: declaredBytes ?? 0,
851
+ declaredBytes,
852
+ };
853
+ }
854
+
855
+ const reader = res.body.getReader();
856
+ const decoder = new TextDecoder();
857
+ const items: T[] = [];
858
+
859
+ let buf = '';
860
+ let scan = 0;
861
+ let bytesRead = 0;
862
+ let started = false; // consumed the opening '['
863
+ let arrayEnded = false;
864
+ let depth = 0;
865
+ let inStr = false;
866
+ let esc = false;
867
+ let elemStart = -1;
868
+ let truncated = false;
869
+
870
+ const push = (text: string) => {
871
+ const t = text.trim();
872
+ if (!t) return;
873
+ items.push(JSON.parse(t) as T);
874
+ };
875
+
876
+ try {
877
+ outer: while (items.length < opts.maxItems && !arrayEnded) {
878
+ const { value, done } = await reader.read();
879
+ if (done) break;
880
+ bytesRead += value.byteLength;
881
+ buf += decoder.decode(value, { stream: true });
882
+ if (bytesRead > maxBytes) { truncated = true; break; }
883
+
884
+ for (; scan < buf.length; scan++) {
885
+ const ch = buf[scan];
886
+
887
+ if (inStr) {
888
+ if (esc) { esc = false; continue; }
889
+ if (ch === '\\') { esc = true; continue; }
890
+ if (ch === '"') inStr = false;
891
+ continue;
892
+ }
893
+
894
+ if (!started) {
895
+ if (ch === '[') { started = true; continue; }
896
+ if (ch.trim() === '') continue;
897
+ throw new Error(
898
+ `upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
899
+ 'This reader is only for upstreams that answer with one top-level array.',
900
+ );
901
+ }
902
+
903
+ if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
904
+
905
+ if (ch === '{' || ch === '[') {
906
+ if (depth === 0 && elemStart < 0) elemStart = scan;
907
+ depth++;
908
+ continue;
909
+ }
910
+
911
+ if (ch === '}' || ch === ']') {
912
+ if (depth > 0) {
913
+ depth--;
914
+ if (depth === 0 && elemStart >= 0) {
915
+ push(buf.slice(elemStart, scan + 1));
916
+ elemStart = -1;
917
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
918
+ }
919
+ continue;
920
+ }
921
+ // depth 0 and a closing bracket: the end of the top-level array.
922
+ if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
923
+ arrayEnded = true;
924
+ break outer;
925
+ }
926
+
927
+ if (ch === ',' && depth === 0) {
928
+ if (elemStart >= 0) {
929
+ push(buf.slice(elemStart, scan));
930
+ elemStart = -1;
931
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
932
+ }
933
+ continue;
934
+ }
935
+
936
+ // A bare scalar element (number / true / false / null).
937
+ if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
938
+ }
939
+
940
+ // Drop everything already consumed. THIS is what bounds memory: without
941
+ // it `buf` grows to the full body and the reader is streaming in name only.
942
+ if (elemStart >= 0) {
943
+ buf = buf.slice(elemStart);
944
+ scan -= elemStart;
945
+ elemStart = 0;
946
+ } else {
947
+ buf = buf.slice(scan);
948
+ scan = 0;
949
+ }
950
+ }
951
+ } finally {
952
+ // Cancel rather than drain: the point is that the remaining megabytes are
953
+ // never transferred. A cancel on an already-finished body is a no-op.
954
+ await reader.cancel().catch(() => {});
955
+ }
956
+
957
+ return { items, truncated, bytesRead, declaredBytes };
958
+ }
959
+
960
+ /**
961
+ * `parseJson`, with a hard ceiling on how many bytes are read.
962
+ *
963
+ * For an upstream whose body is not a single array (so `readJsonArrayPrefix`
964
+ * does not apply) but which can still answer with megabytes. Unlike the array
965
+ * reader this one THROWS when the cap is hit, because a truncated prefix of an
966
+ * arbitrary JSON document is not parseable and there is no partial answer to
967
+ * hand back. A declared `Content-Length` over the cap is refused before a
968
+ * single chunk is read.
969
+ */
970
+ async function parseJsonBounded<T>(
971
+ res: Response,
972
+ name: string,
973
+ maxBytes: number = DEFAULT_MAX_JSON_BYTES,
974
+ ): Promise<T> {
975
+ const declaredRaw = Number(res.headers.get('content-length'));
976
+ const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
977
+
978
+ const tooBig = (n: number) =>
979
+ new Error(
980
+ `upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
981
+ 'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
982
+ 'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
983
+ 'a specific id, or a lower limit) and retry.',
984
+ );
985
+
986
+ if (declared !== null && declared > maxBytes) {
987
+ await res.body?.cancel().catch(() => {});
988
+ throw tooBig(declared);
989
+ }
990
+ if (!res.body) return parseJson<T>(res, name);
991
+
992
+ const reader = res.body.getReader();
993
+ const decoder = new TextDecoder();
994
+ let raw = '';
995
+ let bytesRead = 0;
996
+ try {
997
+ for (;;) {
998
+ const { value, done } = await reader.read();
999
+ if (done) break;
1000
+ bytesRead += value.byteLength;
1001
+ if (bytesRead > maxBytes) throw tooBig(bytesRead);
1002
+ raw += decoder.decode(value, { stream: true });
1003
+ }
1004
+ raw += decoder.decode();
1005
+ } finally {
1006
+ await reader.cancel().catch(() => {});
1007
+ }
1008
+
1009
+ if (!raw.trim()) {
1010
+ throw new Error(
1011
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
1012
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
1013
+ );
1014
+ }
1015
+ try {
1016
+ return JSON.parse(raw) as T;
1017
+ } catch {
1018
+ throw new Error(
1019
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
1020
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
1021
+ );
1022
+ }
1023
+ }
1024
+
761
1025
 
762
1026
  // Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
763
1027
  // imports — so publish-pack.sh can inline it into standalone pack builds the same
@@ -1223,6 +1487,8 @@ function stripConcept(v: AnnualValue): Omit<AnnualValue, 'concept'> {
1223
1487
  * (the resolve -> filings -> concept chain, collapsed; fleet #2182)
1224
1488
  * - edgar_filing_documents: list the documents inside one filing by accession number
1225
1489
  * - edgar_filing_text: full/paged plaintext of a filing's primary document (by section optional)
1490
+ * - edgar_filing_exhibit_text: full/paged plaintext of an EXHIBIT attached to a filing
1491
+ * (e.g. an 8-K's EX-99.1 press release) — the primary document often just points at it
1226
1492
  *
1227
1493
  * Note: SEC requires a descriptive User-Agent header per their guidelines.
1228
1494
  */
@@ -1248,7 +1514,7 @@ const tools: McpToolExport['tools'] = [
1248
1514
  {
1249
1515
  name: 'edgar_search_filings',
1250
1516
  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 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).',
1517
+ '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
1518
  summary: 'Full-text search across every SEC filing, using EDGAR\'s own search index.',
1253
1519
  inputSchema: {
1254
1520
  type: 'object' as const,
@@ -1258,8 +1524,15 @@ const tools: McpToolExport['tools'] = [
1258
1524
  type: 'string',
1259
1525
  description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K", "DEF 14A"). Omit for all types.',
1260
1526
  },
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")' },
1527
+ 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.' },
1528
+ 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.' },
1529
+ entity: {
1530
+ type: 'string',
1531
+ 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.',
1532
+ },
1533
+ ticker_or_cik: { type: 'string', description: 'Alias for `entity`.' },
1534
+ cik: { type: 'string', description: 'Alias for `entity`.' },
1535
+ ticker: { type: 'string', description: 'Alias for `entity`.' },
1263
1536
  limit: { type: 'number', description: 'Number of results to return (1-40, default 10)' },
1264
1537
  },
1265
1538
  required: ['query'],
@@ -1345,7 +1618,7 @@ const tools: McpToolExport['tools'] = [
1345
1618
  {
1346
1619
  name: 'edgar_company_concept',
1347
1620
  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 (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.',
1621
+ '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
1622
  summary: 'One financial metric for a US public company across every period it has reported, from SEC XBRL.',
1350
1623
  inputSchema: {
1351
1624
  type: 'object' as const,
@@ -1370,8 +1643,8 @@ const tools: McpToolExport['tools'] = [
1370
1643
  },
1371
1644
  fiscal_period: {
1372
1645
  type: 'string',
1373
- 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.',
1374
- enum: ['FY', 'Q1', 'Q2', 'Q3', 'Q4'],
1646
+ 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.',
1647
+ enum: ['FY', 'Q1', 'Q2', 'Q3', 'Q4', 'H1', '9M'],
1375
1648
  },
1376
1649
  ticker: {
1377
1650
  type: 'string',
@@ -1710,7 +1983,7 @@ const tools: McpToolExport['tools'] = [
1710
1983
  {
1711
1984
  name: 'edgar_filing_text',
1712
1985
  description:
1713
- '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. A `search` response carries ONLY the matched passages, so it reports `returned_chars` against `total_chars` and sets `truncated:true` whenever those differ — read a figure\'s absence from it as "the phrase did not match there", never as "the filing does not say it", and re-read without `search` (or at a passage `offset`) to confirm. (Do not read `search.truncated` as a completeness signal: it is about the SEARCH — false means every distinct match location was returned — and is routinely false on a response holding a third of the document.) **For a question asking two facts from the same filing** ("revenue and test volume in the latest 10-Q"), pass `search` as `|`-separated phrases (e.g. "revenue|tests processed") to get both in this one call under `searches[]` — and when a phrase names a financial total (revenue, net income, EPS, ...), the matching XBRL figure for this exact accession comes back under `xbrl` even if that total never appears as an absolute number in the prose (common: filings often state only the growth delta/percentage in text). 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.',
1986
+ '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` on a fully-read document (`status: "not_found"`) 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. On a `raw_truncated` document a zero is `status: "not_found_within_scanned_portion"`: the phrase was not found in the first `scanned_chars` characters, which is NOT evidence the filing lacks it. A `search` response carries ONLY the matched passages, so it reports `returned_chars` against `total_chars` and sets `truncated:true` whenever those differ — read a figure\'s absence from it as "the phrase did not match there", never as "the filing does not say it", and re-read without `search` (or at a passage `offset`) to confirm. (Do not read `search.truncated` as a completeness signal: it is about the SEARCH — false means every distinct match location was returned — and is routinely false on a response holding a third of the document.) **For a question asking two facts from the same filing** ("revenue and test volume in the latest 10-Q"), pass `search` as `|`-separated phrases (e.g. "revenue|tests processed") to get both in this one call under `searches[]` — and when a phrase names a financial total (revenue, net income, EPS, ...), the matching XBRL figure for this exact accession comes back under `xbrl` even if that total never appears as an absolute number in the prose (common: filings often state only the growth delta/percentage in text). Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events | risk_factors — 10-K Item 1A, bounded from its heading to the next Item heading, table-of-contents hits and cross-references skipped; the response carries `section_diagnostics` with its length and where it ended); `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.',
1714
1987
  summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1715
1988
  inputSchema: {
1716
1989
  type: 'object' as const,
@@ -1738,8 +2011,8 @@ const tools: McpToolExport['tools'] = [
1738
2011
  },
1739
2012
  section: {
1740
2013
  type: 'string',
1741
- enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events'],
1742
- description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false).',
2014
+ enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events', 'risk_factors'],
2015
+ description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false). risk_factors applies to 10-K text only and is the filer-written wording, untagged: it is not a classification of the risks.',
1743
2016
  },
1744
2017
  max_chars: {
1745
2018
  type: 'number',
@@ -1773,6 +2046,62 @@ const tools: McpToolExport['tools'] = [
1773
2046
  required: [],
1774
2047
  },
1775
2048
  },
2049
+ {
2050
+ name: 'edgar_filing_exhibit_text',
2051
+ description:
2052
+ '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 when `raw_truncated` is absent (try different wording); with `raw_truncated: true` it is `status: "not_found_within_scanned_portion"` and inconclusive 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).',
2053
+ 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.',
2054
+ inputSchema: {
2055
+ type: 'object' as const,
2056
+ properties: {
2057
+ ticker: {
2058
+ type: 'string',
2059
+ description: 'The filer\'s ticker (e.g. "TSLA") or company name. Provide this OR cik. Tickers are auto-resolved to CIKs.',
2060
+ },
2061
+ cik: {
2062
+ type: 'string',
2063
+ description: 'The filer\'s CIK number (e.g. "1318605"). Provide this OR ticker.',
2064
+ },
2065
+ ticker_or_cik: {
2066
+ type: 'string',
2067
+ description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_filing_documents and edgar_filing_text use. Takes either a ticker or a CIK.',
2068
+ },
2069
+ accession: {
2070
+ type: 'string',
2071
+ 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.',
2072
+ },
2073
+ form_type: {
2074
+ type: 'string',
2075
+ 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.',
2076
+ },
2077
+ filed: {
2078
+ type: 'string',
2079
+ 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.',
2080
+ },
2081
+ filing_date: { type: 'string', description: 'Alias for `filed`.' },
2082
+ exhibit: {
2083
+ type: 'string',
2084
+ 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.',
2085
+ },
2086
+ search: {
2087
+ type: 'string',
2088
+ 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[]`.',
2089
+ },
2090
+ contains: { type: 'string', description: 'Alias for `search`.' },
2091
+ find: { type: 'string', description: 'Alias for `search`.' },
2092
+ phrase: { type: 'string', description: 'Alias for `search`.' },
2093
+ max_chars: {
2094
+ type: 'number',
2095
+ 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.',
2096
+ },
2097
+ offset: {
2098
+ type: 'number',
2099
+ description: 'Character offset to start from when `search` is not used (default 0). Pass the prior result\'s next_offset to page forward.',
2100
+ },
2101
+ },
2102
+ required: [],
2103
+ },
2104
+ },
1776
2105
  ];
1777
2106
 
1778
2107
  // ── Helpers ─────────────────────────────────────────────────────────
@@ -1787,21 +2116,93 @@ function isNumericCik(value: string): boolean {
1787
2116
 
1788
2117
  // ── Tool implementations ────────────────────────────────────────────
1789
2118
 
2119
+ // efts.sec.gov sits behind an API Gateway response cache keyed on q, the date
2120
+ // window, forms and from/page — but NOT on ciks/sics/entityName (measured
2121
+ // 2026-08-28, fleet #619: a ciks-filtered request was served the unfiltered
2122
+ // 10,000-hit result cached seconds earlier for the same q+dates, and the
2123
+ // reverse leak too). Fix proven live in mcps/sec-contracts: append a negated
2124
+ // nonsense token derived from the filter to `q`; efts compiles it to a
2125
+ // must_not match_phrase that matches no document (totals verified identical
2126
+ // with/without it), which makes the cache key unique per filter set.
2127
+ function eftsCikCacheKeyToken(paddedCik: string): string {
2128
+ return 'zqfc' + paddedCik.replace(/\D/g, '');
2129
+ }
2130
+
2131
+ // EDGAR full-text search's own coverage floor — a startdt before this is
2132
+ // clamped by EFTS itself, so defaulting a missing end date there is honest
2133
+ // rather than arbitrary.
2134
+ const EFTS_FLOOR = '2001-01-01';
2135
+
1790
2136
  async function searchFilings(
1791
2137
  query: string,
1792
2138
  formType?: string,
1793
2139
  startDate?: string,
1794
2140
  endDate?: string,
1795
2141
  limit?: number,
2142
+ entity?: string,
1796
2143
  ) {
1797
2144
  const count = Math.min(40, Math.max(1, limit ?? 10));
1798
2145
  const params = new URLSearchParams({ q: query, from: '0', size: String(count) });
1799
2146
 
1800
2147
  if (formType) params.set('forms', formType);
2148
+ // efts silently IGNORES dateRange=custom when only one of startdt/enddt is
2149
+ // given (the filter array comes back empty and every year's hits appear) —
2150
+ // reference_efts_cache_key_leak, fleet #619. Always send both ends once
2151
+ // either is requested, defaulting the missing one rather than dropping the
2152
+ // filter the caller asked for.
2153
+ let effStart = startDate;
2154
+ let effEnd = endDate;
1801
2155
  if (startDate || endDate) {
2156
+ effStart = startDate ?? EFTS_FLOOR;
2157
+ effEnd = endDate ?? new Date().toISOString().slice(0, 10);
1802
2158
  params.set('dateRange', 'custom');
1803
- if (startDate) params.set('startdt', startDate);
1804
- if (endDate) params.set('enddt', endDate);
2159
+ params.set('startdt', effStart);
2160
+ params.set('enddt', effEnd);
2161
+ }
2162
+
2163
+ // Resolve an optional entity filter to a CIK. Numeric input is taken as a
2164
+ // CIK directly; otherwise try the ticker/name resolver, and for a delisted
2165
+ // or never-listed filer (not in company_tickers.json) fall back to EDGAR's
2166
+ // own entity-typeahead the same way edgar_ticker_to_cik does.
2167
+ let entityFilter: { cik: string; cik_padded: string; company_name?: string; ticker?: string | null; matched_by: string } | undefined;
2168
+ let entityFilterError: string | undefined;
2169
+ if (entity && String(entity).trim()) {
2170
+ const raw = String(entity).trim();
2171
+ try {
2172
+ if (/^\d{1,10}$/.test(raw)) {
2173
+ entityFilter = { cik: String(Number(raw)), cik_padded: raw.padStart(10, '0'), matched_by: 'cik' };
2174
+ } else {
2175
+ try {
2176
+ const ent = await resolveSecEntity(raw, { headers: SEC_HEADERS });
2177
+ entityFilter = { cik: ent.cik, cik_padded: ent.cik_padded, company_name: ent.company_name, ticker: ent.ticker, matched_by: ent.matched_by };
2178
+ } catch (err) {
2179
+ if (!/No SEC company matches/.test(String((err as Error)?.message))) throw err;
2180
+ const match = await resolveAnyFilerByName(raw, err as Error);
2181
+ entityFilter = { cik: match.cik, cik_padded: match.cik_padded, company_name: match.company_name, ticker: null, matched_by: match.matched_by };
2182
+ }
2183
+ }
2184
+ } catch (err) {
2185
+ // An entity filter that cannot be resolved at all is reported, not
2186
+ // silently dropped — an unfiltered market-wide search under a caller's
2187
+ // believed-narrowed request is a worse failure than an explicit error.
2188
+ entityFilterError = (err as Error)?.message ?? String(err);
2189
+ }
2190
+ }
2191
+ if (entityFilterError) {
2192
+ return {
2193
+ query,
2194
+ entity_filter: entity,
2195
+ entity_filter_error: entityFilterError,
2196
+ total_hits: 0,
2197
+ results: [],
2198
+ 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'}.`,
2199
+ };
2200
+ }
2201
+ if (entityFilter) {
2202
+ params.set('ciks', entityFilter.cik_padded);
2203
+ // Cache-key fix (see header): append the negated token so this request's
2204
+ // cache key differs from an unfiltered one for the same q/dates/forms.
2205
+ params.set('q', `${query} -${eftsCikCacheKeyToken(entityFilter.cik_padded)}`);
1805
2206
  }
1806
2207
 
1807
2208
  const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
@@ -1835,7 +2236,26 @@ async function searchFilings(
1835
2236
  // adsh (accession), and — for 8-Ks — items[] (e.g. "3.01"). The prior mapper
1836
2237
  // read entity_name/form_type/biz_location, which do NOT exist in this payload,
1837
2238
  // so every field but the date/id serialized as undefined and dropped.
1838
- const results = (data.hits?.hits ?? []).map((hit) => {
2239
+ let guardDropped = 0;
2240
+ const rawHits = data.hits?.hits ?? [];
2241
+ const kept = entityFilter
2242
+ ? rawHits.filter((hit) => {
2243
+ // Re-verify the entity filter on every row rather than trusting efts —
2244
+ // the same cache class that leaks an unfiltered result for a ciks
2245
+ // query (fleet #619) means "the server said it filtered" is not proof.
2246
+ const ok = (hit._source.ciks ?? []).some((c) => String(parseInt(c, 10)) === entityFilter!.cik);
2247
+ if (!ok) guardDropped++;
2248
+ return ok;
2249
+ })
2250
+ : rawHits;
2251
+ // efts has a FIXED page size (verified live, fleet #3251: a `size=3` request
2252
+ // still returned 100 hits) — the `size` param above is not honored, so
2253
+ // `limit` has to be applied client-side or every call silently returns up
2254
+ // to 100 rows regardless of what the caller asked for.
2255
+ const eftsPageOversized = rawHits.length > count;
2256
+ const trimmed = kept.slice(0, count);
2257
+
2258
+ const results = trimmed.map((hit) => {
1839
2259
  const s = hit._source;
1840
2260
  const display = s.display_names?.[0] ?? '';
1841
2261
  // display_names is "NAME (TICKER) (CIK 000...)" (double-space before each
@@ -1858,12 +2278,23 @@ async function searchFilings(
1858
2278
  };
1859
2279
  });
1860
2280
 
2281
+ const totalHits = data.hits?.total?.value ?? 0;
2282
+ 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'}`;
2283
+
1861
2284
  return {
1862
2285
  query,
1863
2286
  form_type_filter: formType ?? 'all',
1864
- date_range: { start: startDate ?? null, end: endDate ?? null },
1865
- total_hits: data.hits?.total?.value ?? 0,
2287
+ date_range: { start: effStart ?? null, end: effEnd ?? null },
2288
+ entity_filter: entityFilter
2289
+ ? { cik: entityFilter.cik, company_name: entityFilter.company_name ?? null, ticker: entityFilter.ticker ?? null, matched_by: entityFilter.matched_by }
2290
+ : null,
2291
+ total_hits: totalHits,
2292
+ returned: results.length,
1866
2293
  results,
2294
+ ...(totalHits === 0 ? { no_match: `No filings matched. Filters tried: ${filtersDescription}.` } : {}),
2295
+ ...(guardDropped > 0 ? { entity_filter_guard_dropped: guardDropped } : {}),
2296
+ ...(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).` } : {}),
2297
+ 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
2298
  };
1868
2299
  }
1869
2300
 
@@ -2718,6 +3149,30 @@ async function fetchTextBounded(url: string, headers: Record<string, string>, ma
2718
3149
  // stated limit, not a silent gap — rather than never answering at all.
2719
3150
  const FILING_DOC_MAX_BYTES = 4_000_000;
2720
3151
 
3152
+ /**
3153
+ * What a search result MEANS given how much of the document was read (fleet #3562).
3154
+ * A zero-match search over a document whose raw HTML hit the read cap
3155
+ * (`raw_truncated`) only says the phrase is absent from the portion scanned.
3156
+ * Before this, such a zero carried the same `status`-less empty array and a note
3157
+ * calling it "a real answer", so a model read "no going-concern language" off a
3158
+ * scan that never reached the notes. `status` is the distinct, checkable field:
3159
+ * found | not_found | not_found_within_scanned_portion (zero over a capped read)
3160
+ * | word_lead_only | word_lead_only_within_scanned_portion.
3161
+ */
3162
+ function searchStatus(
3163
+ found: boolean,
3164
+ wordMode: boolean,
3165
+ rawTruncated: boolean,
3166
+ ): { status: string; scan_note?: string } {
3167
+ if (found) return { status: 'found' };
3168
+ if (!rawTruncated) return { status: wordMode ? 'word_lead_only' : 'not_found' };
3169
+ return {
3170
+ status: wordMode ? 'word_lead_only_within_scanned_portion' : 'not_found_within_scanned_portion',
3171
+ scan_note:
3172
+ 'INCONCLUSIVE: the document was only PARTLY read (raw_truncated: true) — see `scanned_chars` on the response for how far this scan reached. The phrase was not found WITHIN that portion; it may well appear in the part that was never read. Do NOT conclude the filing omits this wording. Retry with a different phrase, use `section` (it reads from the same capped text), or open the filing at document_url.',
3173
+ };
3174
+ }
3175
+
2721
3176
  // Strip an HTML document to readable plaintext. Workers have no DOMParser;
2722
3177
  // drop script/style blocks, remove tags, decode entities, collapse whitespace.
2723
3178
  function htmlToText(html: string): string {
@@ -2843,6 +3298,117 @@ export function findFormIndices(
2843
3298
  const AUTO_SELECT_SCAN = 4;
2844
3299
  const AUTO_SELECT_LIST = 8;
2845
3300
 
3301
+ /**
3302
+ * Item 1A (Risk Factors) of a 10-K, bounded at BOTH ends. Unlike the other
3303
+ * sections (anchor to end-of-document), a risk-factors slice must stop at the
3304
+ * next Item heading, or "Item 1A text" would silently be 1A + the rest of the
3305
+ * filing.
3306
+ *
3307
+ * The failure this is built around is returning a plausible-looking wrong
3308
+ * chunk: the table-of-contents line ("Item 1A. Risk Factors 5 Item 1B ..."),
3309
+ * a cross-reference ("see Item 1A. Risk Factors"), a running page header
3310
+ * ("Item 1A. Risk Factors (Continued)"), or a section that stops early at one
3311
+ * of those. So: collect every heading-shaped start, drop the ones that read
3312
+ * as references or running headers, pair each start with the next Item
3313
+ * 1B/1C/2 heading, and keep the start whose slice is LONGEST. A TOC entry's
3314
+ * slice runs to the next TOC line (tens of characters); the real section is
3315
+ * thousands. Pure text, deterministic, no model.
3316
+ */
3317
+ // Some filers' HTML-to-text leaves ONE stray space inside a heading word
3318
+ // ("RIS K FACTORS" Microsoft, "R isk Factors" Oracle, "Item 1 A" Prdo): the
3319
+ // literal heading then never matches and only the contents line is found.
3320
+ // tol(word) accepts the word whole or with a single space at any inner
3321
+ // position. One space per word, never a wildcard run, so it cannot widen into
3322
+ // unrelated text; the RF_MIN_PLAUSIBLE_CHARS floor below stays the backstop.
3323
+ function tol(word: string): string {
3324
+ const alts = [word];
3325
+ for (let i = 1; i < word.length; i++) alts.push(`${word.slice(0, i)}\\s${word.slice(i)}`);
3326
+ return `(?:${alts.join('|')})`;
3327
+ }
3328
+ const RF_PUNCT = '[.:\\-\\u2013\\u2014]?';
3329
+ const RF_START = new RegExp(`\\b${tol('item')}\\s*${tol('1a')}\\s*${RF_PUNCT}\\s*${tol('risk')}\\s+${tol('factors')}`, 'gi');
3330
+ const RF_END = new RegExp(
3331
+ `\\b${tol('item')}\\s*(?:${tol('1b')}\\s*${RF_PUNCT}\\s*${tol('unresolved')}\\s+${tol('staff')}|${tol('1c')}\\s*${RF_PUNCT}\\s*${tol('cybersecurity')}|2\\s*${RF_PUNCT}\\s*(?:${tol('description')}\\s+${tol('of')}\\s+)?${tol('propert')})`,
3332
+ 'gi',
3333
+ );
3334
+ const RF_REF_WORDS = /(?:\b(?:see|in|under|and|to|of|at|with|within|also|by|from|the|this|our|its|entitled|captioned|heading)\s*|[“"'‘(])$/i;
3335
+
3336
+ function isHeadingShaped(text: string, idx: number, len: number): boolean {
3337
+ const before = text.slice(Math.max(0, idx - 24), idx).replace(/[\s\u00a0]+$/, '');
3338
+ if (RF_REF_WORDS.test(before)) return false;
3339
+ const after = text.slice(idx + len, idx + len + 24).replace(/^[\s\u00a0.]+/, '');
3340
+ if (/^[”"’')]/.test(after)) return false; // ...“Item 1A. Risk Factors” cross-reference
3341
+ if (/^\(?\s*continued\b/i.test(after)) return false; // running page header
3342
+ if (/^(?:of|in|above|below|under|for|and|section|herein|generally)\b/.test(after)) return false;
3343
+ return true;
3344
+ }
3345
+
3346
+ // Below this a slice is not a risk-factors section, it is an index/cross-reference
3347
+ // line that won the longest-slice rule unopposed (Intel FY2025: 32 chars, a
3348
+ // "Pages 37 - 48" index entry, one heading candidate). Chosen from the
3349
+ // measured gap: the failure is tens of characters and the smallest genuine
3350
+ // section measured is 31,270 (Intrusion FY2025); 1,000 sits ~30x above the
3351
+ // failure and ~30x below the smallest true section. The smaller-reporting-
3352
+ // company "not required" statement is exempt: it is legitimately tiny and has
3353
+ // its own not_required flag.
3354
+ export const RF_MIN_PLAUSIBLE_CHARS = 1000;
3355
+
3356
+ export function extractRiskFactors(
3357
+ fullText: string,
3358
+ ): { text: string; implausible: boolean; diagnostics: Record<string, unknown> } | null {
3359
+ const starts: number[] = [];
3360
+ let skippedRefs = 0;
3361
+ for (const m of fullText.matchAll(RF_START)) {
3362
+ if (m.index === undefined) continue;
3363
+ if (isHeadingShaped(fullText, m.index, m[0].length)) starts.push(m.index);
3364
+ else skippedRefs++;
3365
+ }
3366
+ if (starts.length === 0) return null;
3367
+ const ends: Array<{ idx: number; label: string }> = [];
3368
+ for (const m of fullText.matchAll(RF_END)) {
3369
+ if (m.index === undefined) continue;
3370
+ if (!isHeadingShaped(fullText, m.index, m[0].length)) continue;
3371
+ const t = m[0].toLowerCase().replace(/\s+/g, '');
3372
+ ends.push({ idx: m.index, label: t.includes('1b') ? 'Item 1B' : t.includes('1c') ? 'Item 1C' : 'Item 2' });
3373
+ }
3374
+ let best: { start: number; end: number; label: string | null } | null = null;
3375
+ for (const st of starts) {
3376
+ const e = ends.find((x) => x.idx > st);
3377
+ const end = e ? e.idx : fullText.length;
3378
+ if (!best || end - st > best.end - best.start) best = { start: st, end, label: e ? e.label : null };
3379
+ }
3380
+ const b = best!;
3381
+ // Drop the page number the stripped HTML leaves glued to the section's last line ("... retention. 32").
3382
+ const text = fullText.slice(b.start, b.end).trim().replace(/\s+\d{1,3}$/, '');
3383
+ // A real Item 1A is thousands of characters; the exceptions are filers who
3384
+ // say "not required for smaller reporting companies" — report that as what
3385
+ // it is rather than a bare short string that reads like a failed parse.
3386
+ const notRequired = text.length < 2500 && /not\s+required|smaller\s+reporting\s+compan/i.test(text);
3387
+ const implausible = !notRequired && text.length < RF_MIN_PLAUSIBLE_CHARS;
3388
+ return {
3389
+ text,
3390
+ implausible,
3391
+ diagnostics: {
3392
+ start_offset: b.start,
3393
+ end_offset: b.end,
3394
+ section_chars: text.length,
3395
+ ended_at: b.label ?? 'end_of_document',
3396
+ heading_candidates: starts.length,
3397
+ references_skipped: skippedRefs,
3398
+ ...(b.label === null
3399
+ ? { warning: 'No Item 1B/1C/2 heading followed the start, so this runs to the end of the text read: the section may be longer than the real Item 1A.' }
3400
+ : {}),
3401
+ ...(notRequired
3402
+ ? { not_required: true, note: 'This filer states Item 1A is not required (smaller reporting company); the text is that statement, not omitted risk factors.' }
3403
+ : implausible
3404
+ ? { warning: `Only ${text.length} characters, which is an index or cross-reference line rather than a risk-factors section (some filers place Item 1A in a cross-reference index and write the risks under an unnumbered heading). Not returned as the section: section_found is false and the whole document is returned.` }
3405
+ : text.length < 2500
3406
+ ? { warning: `Only ${text.length} characters — shorter than a real risk-factors section normally is. Treat as suspect (possible table-of-contents hit or a filer that incorporates risk factors by reference).` }
3407
+ : {}),
3408
+ },
3409
+ };
3410
+ }
3411
+
2846
3412
  /**
2847
3413
  * The `section` slice of a filing's text, as a function rather than inline, so
2848
3414
  * the auto-selection walk below can score a candidate against the SAME text
@@ -2852,9 +3418,22 @@ const AUTO_SELECT_LIST = 8;
2852
3418
  export function sliceFilingSection(
2853
3419
  fullText: string,
2854
3420
  section: string | undefined,
2855
- ): { text: string; applied: string | null; found: string | boolean | null } {
2856
- const sec = (section ?? '').trim().toLowerCase();
3421
+ ): { text: string; applied: string | null; found: string | boolean | null; diagnostics?: Record<string, unknown> } {
3422
+ const sec = (section ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
2857
3423
  if (!sec) return { text: fullText, applied: null, found: null };
3424
+ if (sec === 'risk_factors' || sec === 'item_1a') {
3425
+ const r = extractRiskFactors(fullText);
3426
+ if (!r) return { text: fullText, applied: null, found: false };
3427
+ if (r.implausible) {
3428
+ return {
3429
+ text: fullText,
3430
+ applied: null,
3431
+ found: false,
3432
+ diagnostics: { ...r.diagnostics, rejected_slice_chars: r.text.length, rejected_slice_text: r.text.slice(0, 120) },
3433
+ };
3434
+ }
3435
+ return { text: r.text, applied: 'risk_factors', found: true, diagnostics: r.diagnostics };
3436
+ }
2858
3437
  const conf = FILING_SECTION_PATTERNS[sec];
2859
3438
  if (!conf) return { text: fullText, applied: null, found: `unknown_section:${sec}` };
2860
3439
  const idxs: number[] = [];
@@ -3177,6 +3756,82 @@ export function findWordPassages(
3177
3756
  };
3178
3757
  }
3179
3758
 
3759
+ // --- holding-company reorganisations (fleet #3563) -------------------------
3760
+ // SEC's ticker map gives each ticker ONE cik. When a company reorganises under
3761
+ // a new holding company the ticker moves to the new registrant (XOM ->
3762
+ // ExxonMobil Holdings Corp, cik 2115436, proxy material only) while the 10-K
3763
+ // history stays under the old one (EXXON MOBIL CORP, cik 34088). By ticker the
3764
+ // caller then got "no 10-K found" about one of the largest filers in the
3765
+ // market. A sibling is accepted ONLY when its name, stripped of corporate
3766
+ // boilerplate and spacing, EQUALS the resolved registrant's AND it really holds
3767
+ // the requested form: "ExxonMobil Holdings Corp" and "EXXON MOBIL CORP" both
3768
+ // reduce to "EXXONMOBIL"; "ExxonMobil Renewables LLC" does not.
3769
+ const REGISTRANT_BOILERPLATE = new Set([
3770
+ 'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED', 'LLC', 'LP', 'PLC',
3771
+ 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA', 'GROUP', 'HOLDINGS', 'HOLDING', 'AND', 'OF',
3772
+ 'US', 'USA',
3773
+ ]);
3774
+ // Words like INTERNATIONAL / GLOBAL / NEW / TRUST / FUND are deliberately NOT
3775
+ // stripped: "Eastern International Ltd" and "The Eastern Company" are different
3776
+ // registrants (measured in the fleet #3563 survey), and a wrong redirect is worse
3777
+ // than the not-found it replaces. SIC is compared as a second guard.
3778
+
3779
+ const SIBLING_MAX_AGE_DAYS = 800;
3780
+
3781
+ export function registrantCore(name: string): string {
3782
+ return name
3783
+ .replace(/\([^)]*\)/g, ' ')
3784
+ .toUpperCase()
3785
+ .replace(/[^A-Z0-9 ]/g, ' ')
3786
+ .split(/\s+/)
3787
+ .filter((t) => t && !REGISTRANT_BOILERPLATE.has(t))
3788
+ .join('');
3789
+ }
3790
+
3791
+ async function findSiblingFilers(
3792
+ ownName: string,
3793
+ ownCik: string,
3794
+ wantFormTypeArg: string,
3795
+ ownSic?: string,
3796
+ ): Promise<{ cik: string; name: string; accession: string; filed: string | null }[]> {
3797
+ const core = registrantCore(ownName);
3798
+ if (core.length < 5) return [];
3799
+ const res = await pwFetch(
3800
+ `https://efts.sec.gov/LATEST/search-index?keysTyped=${encodeURIComponent(core.slice(0, 5).toLowerCase())}`,
3801
+ { headers: SEC_HEADERS },
3802
+ ).catch(() => null);
3803
+ if (!res || !res.ok) return [];
3804
+ const data = (await res.json().catch(() => null)) as
3805
+ | { hits?: { hits?: { _id: string; _source?: { entity?: string } }[] } }
3806
+ | null;
3807
+ const hits = (data?.hits?.hits ?? [])
3808
+ .filter((h) => /^\d+$/.test(h._id) && h._source?.entity)
3809
+ .map((h) => ({ cik: String(Number(h._id)), name: h._source!.entity! }))
3810
+ .filter((h) => h.cik !== ownCik && registrantCore(h.name) === core)
3811
+ .slice(0, 4);
3812
+ const found: { cik: string; name: string; accession: string; filed: string | null }[] = [];
3813
+ for (const h of hits) {
3814
+ const r = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(h.cik)}.json`, { headers: SEC_HEADERS }).catch(() => null);
3815
+ if (!r || !r.ok) continue;
3816
+ const sub = (await r.json().catch(() => null)) as
3817
+ | { sic?: string; filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } } }
3818
+ | null;
3819
+ const rec = sub?.filings?.recent;
3820
+ if (!rec) continue;
3821
+ if (ownSic && sub?.sic && sub.sic !== ownSic) continue; // same name, different industry: not the same company
3822
+ const idx = findFormIndices(rec.form ?? [], wantFormTypeArg, rec.filingDate ?? [], null, 1)[0];
3823
+ // A registrant that last filed this form years ago is a dead namesake
3824
+ // (measured: DMRC's same-named cik last filed a 10-K in 2008), not the
3825
+ // predecessor of a fresh reorganisation.
3826
+ const filed = rec.filingDate?.[idx ?? -1] ?? '';
3827
+ if (!filed || Date.parse(filed) < Date.now() - SIBLING_MAX_AGE_DAYS * 86_400_000) continue;
3828
+ if (idx !== undefined && rec.accessionNumber?.[idx]) {
3829
+ found.push({ cik: h.cik, name: h.name, accession: rec.accessionNumber[idx], filed: rec.filingDate?.[idx] ?? null });
3830
+ }
3831
+ }
3832
+ return found;
3833
+ }
3834
+
3180
3835
  // Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
3181
3836
  // out (rather than piggybacking edgar_filing_documents' include_primary_text,
3182
3837
  // which caps at 40k and can't page) lets the register pull deep disclosures —
@@ -3190,7 +3845,8 @@ async function filingText(
3190
3845
  wantFormTypeArg?: string,
3191
3846
  searchTerm?: string,
3192
3847
  filedArg?: string,
3193
- ) {
3848
+ siblingOf?: string,
3849
+ ): Promise<any> {
3194
3850
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
3195
3851
  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"})).');
3196
3852
  }
@@ -3208,6 +3864,7 @@ async function filingText(
3208
3864
  if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) for ${tickerOrCik}.`);
3209
3865
  const sub = (await subRes.json()) as {
3210
3866
  name?: string;
3867
+ sic?: string;
3211
3868
  filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[]; primaryDocument?: string[] } };
3212
3869
  };
3213
3870
  const r = sub.filings?.recent;
@@ -3299,6 +3956,26 @@ async function filingText(
3299
3956
  }
3300
3957
  const candidateIdx = findFormIndices(forms, wantFormTypeArg, filingDates, filedPrefix, AUTO_SELECT_LIST)
3301
3958
  .filter((idx) => !!accs[idx] && !!normalizeAccession(accs[idx]));
3959
+ if (!candidateIdx.length && wantForm && !siblingOf && !isNumericCik(tickerOrCik) && sub.name) {
3960
+ // Ticker resolved to a registrant that has never filed this form. Look
3961
+ // for the sibling registrant that carries the history (holdco reorg).
3962
+ const sibs = await findSiblingFilers(sub.name, cikNoZeros, wantForm, sub.sic);
3963
+ if (sibs.length === 1) {
3964
+ const redirected = await filingText(undefined, sibs[0].cik, section, maxChars, offset, wantFormTypeArg, searchTerm, filedArg, cikNoZeros);
3965
+ return {
3966
+ ...redirected,
3967
+ registrant_redirect: {
3968
+ asked_for: tickerOrCik,
3969
+ resolved_to: { cik: cikNoZeros, company: sub.name, note: `has no ${wantForm} on file` },
3970
+ read_instead: { cik: sibs[0].cik, company: sibs[0].name },
3971
+ why: `"${tickerOrCik}" resolves to ${sub.name} (CIK ${cikNoZeros}), which has filed no ${wantForm}. ${sibs[0].name} (CIK ${sibs[0].cik}) has the same name and does file one — typical after a holding-company reorganisation, where the ticker moves to the new registrant and the filing history stays with the old one. This response reads ${sibs[0].name}'s filing.`,
3972
+ },
3973
+ };
3974
+ }
3975
+ if (sibs.length > 1) {
3976
+ throw new Error(`No ${wantForm} filing found for ${tickerOrCik}: it resolves to ${sub.name} (CIK ${cikNoZeros}), which has filed none. Same-named registrants that do file one: ${sibs.map((x) => `${x.name} (CIK ${x.cik})`).join('; ')}. Ask again with one of those CIKs as ticker_or_cik.`);
3977
+ }
3978
+ }
3302
3979
  if (!candidateIdx.length) {
3303
3980
  const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
3304
3981
  throw new Error(`No ${wantForm ?? 'recent'} filing${dateClause} found for ${tickerOrCik}. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list filings, then pass an accession.`);
@@ -3375,6 +4052,7 @@ async function filingText(
3375
4052
  fullText = sliced.text;
3376
4053
  const sectionApplied = sliced.applied;
3377
4054
  const sectionFound = sliced.found;
4055
+ const sectionDiagnostics = sliced.diagnostics;
3378
4056
 
3379
4057
  // `search` short-circuits the normal offset/max_chars windowing: it scans
3380
4058
  // the whole (possibly section-sliced) document and returns matching
@@ -3420,7 +4098,13 @@ async function filingText(
3420
4098
  ? 'No case-insensitive match for this wording (nor, for a multi-word phrase, for any of its individual words) in the document (or section, if one was given). That is a real answer, not a failure — retry with different wording or drop `section` to search the whole document.'
3421
4099
  : `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.`,
3422
4100
  };
3423
- return xbrl ? { ...base, xbrl } : base;
4101
+ const st = searchStatus(totalMatches > 0, wordHit !== null, rawTruncated);
4102
+ const out = {
4103
+ ...base,
4104
+ status: st.status,
4105
+ ...(st.scan_note ? { note: st.scan_note + (wordHit ? ' ' + base.note : ''), scanned_chars: documentChars } : {}),
4106
+ };
4107
+ return xbrl ? { ...out, xbrl } : out;
3424
4108
  };
3425
4109
 
3426
4110
  const results = await Promise.all(searchPhrases.map(buildOne));
@@ -3466,6 +4150,7 @@ async function filingText(
3466
4150
  document_url: docUrl,
3467
4151
  section: sectionApplied,
3468
4152
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
4153
+ ...(sectionDiagnostics ? { section_diagnostics: sectionDiagnostics } : {}),
3469
4154
  total_chars: fullText.length,
3470
4155
  ...(fullText.length !== documentChars ? { document_chars: documentChars } : {}),
3471
4156
  returned_chars: returnedChars,
@@ -3484,7 +4169,8 @@ async function filingText(
3484
4169
  ...(rawTruncated
3485
4170
  ? {
3486
4171
  raw_truncated: true,
3487
- 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.`,
4172
+ scanned_chars: documentChars,
4173
+ 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 first ${documentChars} characters of text — NOT the whole filing. A phrase with status "not_found_within_scanned_portion" is inconclusive, not "absent".`,
3488
4174
  }
3489
4175
  : {}),
3490
4176
  };
@@ -3509,6 +4195,7 @@ async function filingText(
3509
4195
  document_url: docUrl,
3510
4196
  section: sectionApplied,
3511
4197
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
4198
+ ...(sectionDiagnostics ? { section_diagnostics: sectionDiagnostics } : {}),
3512
4199
  total_chars: total,
3513
4200
  // A section slice reframed `total_chars` to the slice (fleet #3316): say so,
3514
4201
  // or a 2,000-char section of a 400,000-char 10-K reads as a whole document.
@@ -3522,13 +4209,232 @@ async function filingText(
3522
4209
  ...(rawTruncated
3523
4210
  ? {
3524
4211
  raw_truncated: true,
3525
- 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.`,
4212
+ 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; risk_factors instead runs to the end of the text read, with a warning) 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.`,
3526
4213
  }
3527
4214
  : {}),
3528
4215
  text: slice,
3529
4216
  };
3530
4217
  }
3531
4218
 
4219
+ // The raw directory listing (`${folder}/index.json`, what edgar_filing_documents
4220
+ // reads) carries a MIME-icon `type` per file ("text.gif", "compressed.gif") —
4221
+ // NOT the SEC exhibit number. The exhibit number ("EX-99.1") only appears on the
4222
+ // filing's own human -index.html page, in its "Document Format Files" table
4223
+ // (seq, description, document link, type, size). This is the general,
4224
+ // filer-agnostic way to find "the EX-99.1 attached to this filing" for ANY
4225
+ // company, not just Tesla — verified live against TSLA's 2026-10-02 8-K, whose
4226
+ // exhibit filename ("exhibit991111111.htm") carries no hint of "99.1" at all.
4227
+ function parseFilingIndexExhibits(html: string): Array<{ seq: string; description: string; filename: string; type: string }> {
4228
+ const out: Array<{ seq: string; description: string; filename: string; type: string }> = [];
4229
+ // Only the "Document Format Files" table — the sibling "Data Files" table
4230
+ // (XBRL schema/linkbase files) uses the same row shape but isn't a document
4231
+ // a caller would ever mean by "exhibit".
4232
+ const tableMatch = html.match(/<table[^>]*class="tableFile"[^>]*summary="Document Format Files"[\s\S]*?<\/table>/i);
4233
+ if (!tableMatch) return out;
4234
+ const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
4235
+ let row: RegExpExecArray | null;
4236
+ while ((row = rowRe.exec(tableMatch[0])) !== null) {
4237
+ const cellRe = /<td[^>]*>([\s\S]*?)<\/td>/gi;
4238
+ const cells: string[] = [];
4239
+ let cell: RegExpExecArray | null;
4240
+ while ((cell = cellRe.exec(row[1])) !== null) cells.push(cell[1]);
4241
+ if (cells.length < 4) continue; // header row or malformed
4242
+ const seq = decodeHtml(cells[0].replace(/<[^>]+>/g, '').trim());
4243
+ const description = decodeHtml(cells[1].replace(/<[^>]+>/g, '').trim());
4244
+ const hrefMatch = cells[2].match(/href="([^"]+)"/i);
4245
+ if (!hrefMatch) continue; // "Complete submission text file" row and similar have no useful link here
4246
+ const filename = hrefMatch[1].split('/').pop()!.split(/[?#]/)[0];
4247
+ const type = decodeHtml(cells[3].replace(/<[^>]+>/g, '').trim());
4248
+ if (!filename || !seq || seq === ' ') continue; // skip the blank-seq "Complete submission" row
4249
+ out.push({ seq, description, filename, type });
4250
+ }
4251
+ return out;
4252
+ }
4253
+
4254
+ // Normalize an exhibit identifier the way a caller would type it ("99.1",
4255
+ // "EX-99.1", "ex99.1") to compare against the TYPE column's "EX-99.1" shape.
4256
+ function normalizeExhibitId(raw: string): string {
4257
+ return raw.trim().toUpperCase().replace(/^EX-?/, '').replace(/\s+/g, '');
4258
+ }
4259
+
4260
+ async function filingExhibitText(
4261
+ accession: string | undefined,
4262
+ tickerOrCik: string,
4263
+ exhibitArg: string | undefined,
4264
+ searchTerm?: string,
4265
+ maxChars?: number,
4266
+ offset?: number,
4267
+ wantFormTypeArg?: string,
4268
+ filedArg?: string,
4269
+ ) {
4270
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
4271
+ throw new Error(
4272
+ '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"})).',
4273
+ );
4274
+ }
4275
+ // Defaults to "8-K" (not "any type") when the caller named neither accession
4276
+ // nor form_type: an exhibit worth reading for its own text is overwhelmingly
4277
+ // the press release on an 8-K, and defaulting to "the single most recent
4278
+ // filing of any type" (edgar_filing_documents' own fallback) would routinely
4279
+ // land on an unrelated Form 4 or Form 144 between 8-Ks (the same trap
4280
+ // edgar_filing_text's form_type doc warns about, fleet #2450).
4281
+ const effectiveFormType = accession ? wantFormTypeArg : (wantFormTypeArg ?? '8-K');
4282
+
4283
+ const docsResult = (await filingDocuments(accession, tickerOrCik, false, effectiveFormType, filedArg)) as {
4284
+ accession: string;
4285
+ cik: string;
4286
+ company: string | null;
4287
+ form_type: string | null;
4288
+ filing_date: string | null;
4289
+ accession_source: 'caller' | 'auto_selected';
4290
+ auto_selected?: Record<string, unknown>;
4291
+ };
4292
+
4293
+ const acc = normalizeAccession(docsResult.accession)!;
4294
+ const folder = `https://www.sec.gov/Archives/edgar/data/${docsResult.cik}/${acc.nodash}`;
4295
+
4296
+ const idxHtmlRes = await pwFetch(`${folder}/${acc.dashed}-index.html`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
4297
+ if (!idxHtmlRes.ok) throw await httpError(idxHtmlRes, 'SEC EDGAR filing index page error');
4298
+ const idxHtml = await idxHtmlRes.text();
4299
+ const exhibits = parseFilingIndexExhibits(idxHtml);
4300
+
4301
+ if (!exhibits.length) {
4302
+ throw new Error(
4303
+ `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.`,
4304
+ );
4305
+ }
4306
+
4307
+ const wantExhibit = (exhibitArg ?? '').trim();
4308
+ let matches: typeof exhibits;
4309
+ if (wantExhibit) {
4310
+ const norm = normalizeExhibitId(wantExhibit);
4311
+ matches = exhibits.filter((e) => normalizeExhibitId(e.type) === norm || normalizeExhibitId(e.type).startsWith(norm + '.'));
4312
+ } else {
4313
+ matches = exhibits.filter((e) => /^EX-?99/i.test(e.type));
4314
+ }
4315
+
4316
+ if (!matches.length) {
4317
+ return {
4318
+ accession: acc.dashed,
4319
+ cik: docsResult.cik,
4320
+ company: docsResult.company,
4321
+ form_type: docsResult.form_type,
4322
+ filing_date: docsResult.filing_date,
4323
+ accession_source: docsResult.accession_source,
4324
+ ...(docsResult.auto_selected ? { auto_selected: docsResult.auto_selected } : {}),
4325
+ exhibit_requested: wantExhibit || 'EX-99.* (default — no exhibit matches this type in this filing)',
4326
+ available_exhibits: exhibits.map((e) => ({ seq: e.seq, type: e.type || null, description: e.description || null, filename: e.filename })),
4327
+ error: wantExhibit
4328
+ ? `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\`.`
4329
+ : `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.`,
4330
+ };
4331
+ }
4332
+
4333
+ const chosen = matches[0];
4334
+ const docUrl = `${folder}/${chosen.filename}`;
4335
+ const { text: rawFetched, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
4336
+ // An individual exhibit document (unlike the primary iXBRL document) is
4337
+ // routinely served with its SGML submission wrapper still attached --
4338
+ // <DOCUMENT><TYPE>EX-99.1<SEQUENCE>2<FILENAME>...<DESCRIPTION>...<TEXT> --
4339
+ // verified live on TSLA's 2026-10-02 EX-99.1. Untouched, htmlToText's tag
4340
+ // stripper reads each SGML tag as an HTML tag and turns it into stray leading
4341
+ // tokens ("EX-99.1", "2", the filename again, "Document") ahead of the real
4342
+ // content. Strip everything through the first <TEXT> tag before stripping
4343
+ // HTML, so a reader of the real content never sees SEC's filing-agent
4344
+ // bookkeeping. A document with no such wrapper (the common case) is untouched.
4345
+ const raw = rawFetched.replace(/^[\s\S]{0,400}?<text>\s*/i, (m) => (/^\s*<document>/i.test(m) ? '' : m));
4346
+ const isHtml = /\.x?html?$/i.test(chosen.filename) || /^\s*</.test(raw);
4347
+ const fullText = isHtml ? htmlToText(raw) : raw;
4348
+ const documentChars = fullText.length;
4349
+
4350
+ const base = {
4351
+ accession: acc.dashed,
4352
+ cik: docsResult.cik,
4353
+ company: docsResult.company,
4354
+ form_type: docsResult.form_type,
4355
+ filing_date: docsResult.filing_date,
4356
+ accession_source: docsResult.accession_source,
4357
+ ...(docsResult.auto_selected ? { auto_selected: docsResult.auto_selected } : {}),
4358
+ exhibit: chosen.type || null,
4359
+ exhibit_description: chosen.description || null,
4360
+ document: chosen.filename,
4361
+ document_url: docUrl,
4362
+ source: 'U.S. Securities and Exchange Commission, EDGAR filing exhibits',
4363
+ ...(exhibits.length > 1 ? { available_exhibits: exhibits.map((e) => ({ seq: e.seq, type: e.type || null, description: e.description || null, filename: e.filename })) } : {}),
4364
+ };
4365
+
4366
+ const searchPhrases = [...new Set((searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean))].slice(0, 5);
4367
+
4368
+ if (searchPhrases.length > 0) {
4369
+ const buildOne = (term: string) => {
4370
+ const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
4371
+ const wordHit = totalMatches === 0 ? findWordPassages(fullText, term) : null;
4372
+ const st = searchStatus(totalMatches > 0, wordHit !== null, rawTruncated);
4373
+ const r = wordHit
4374
+ ? {
4375
+ term,
4376
+ match_mode: 'words' as const,
4377
+ exact_matches: 0,
4378
+ words: wordHit.words,
4379
+ distinct_locations: wordHit.distinct_locations,
4380
+ returned: wordHit.passages.length,
4381
+ truncated: wordHit.distinct_locations > wordHit.passages.length,
4382
+ passages: wordHit.passages,
4383
+ 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.`,
4384
+ }
4385
+ : {
4386
+ term,
4387
+ match_mode: 'exact' as const,
4388
+ total_matches: totalMatches,
4389
+ distinct_locations: distinctLocations,
4390
+ returned: passages.length,
4391
+ truncated: distinctLocations > passages.length,
4392
+ passages,
4393
+ note: totalMatches === 0
4394
+ ? '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`).'
4395
+ : 'Passages carrying a nearby number (a likely KPI figure) are ranked first; the rest keep document order.',
4396
+ };
4397
+ return {
4398
+ ...r,
4399
+ status: st.status,
4400
+ ...(st.scan_note ? { note: st.scan_note + (wordHit ? ' ' + r.note : ''), scanned_chars: documentChars } : {}),
4401
+ };
4402
+ };
4403
+ const results = searchPhrases.map(buildOne);
4404
+ const returnedChars = results.reduce((n, r) => n + r.passages.reduce((m, p) => m + p.text.length, 0), 0);
4405
+ const searchTruncated = returnedChars < documentChars;
4406
+
4407
+ return {
4408
+ ...base,
4409
+ total_chars: documentChars,
4410
+ returned_chars: returnedChars,
4411
+ truncated: searchTruncated,
4412
+ ...(searchTruncated
4413
+ ? { 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.` }
4414
+ : {}),
4415
+ ...(searchPhrases.length === 1 ? { search: results[0] } : { searches: searchPhrases.map((phrase, i) => ({ phrase, ...results[i] })) }),
4416
+ ...(rawTruncated ? { raw_truncated: true, scanned_chars: documentChars } : {}),
4417
+ };
4418
+ }
4419
+
4420
+ const cap = Math.min(FILING_TEXT_CAP, Math.max(1000, Math.floor(Number(maxChars) || FILING_TEXT_DEFAULT_MAX)));
4421
+ const off = Math.max(0, Math.floor(Number(offset) || 0));
4422
+ const slice = fullText.slice(off, off + cap);
4423
+ const end = off + slice.length;
4424
+ const truncated = end < documentChars;
4425
+
4426
+ return {
4427
+ ...base,
4428
+ total_chars: documentChars,
4429
+ offset: off,
4430
+ returned_chars: slice.length,
4431
+ truncated,
4432
+ next_offset: truncated ? end : null,
4433
+ ...(rawTruncated ? { raw_truncated: true } : {}),
4434
+ text: slice,
4435
+ };
4436
+ }
4437
+
3532
4438
  // data.sec.gov answers a bare 404 both when no filer has the CIK at all and when
3533
4439
  // the filer exists but has never submitted XBRL financial data (investment funds,
3534
4440
  // trusts, individuals, most pre-2011 foreign private issuers). "SEC EDGAR company
@@ -4099,6 +5005,13 @@ async function companyConcept(
4099
5005
  // (fy, fp, end) tuple. Keep the most-recently-filed version.
4100
5006
  type Raw = { start?: string; end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
4101
5007
  const spanDays = (a?: string, b?: string) => (a && b) ? Math.round((Date.parse(b) - Date.parse(a)) / 86400000) : NaN;
5008
+ /** Distinct accession numbers of the facts a derived row was computed from
5009
+ * (fleet #3179), in the order given; undefined when none carries one. */
5010
+ const uniqueAccessions = (rows: Array<{ accession?: string }>): string[] | undefined => {
5011
+ const out: string[] = [];
5012
+ for (const r of rows) if (r.accession && !out.includes(r.accession)) out.push(r.accession);
5013
+ return out.length > 0 ? out : undefined;
5014
+ };
4102
5015
  // Does this fact's DURATION match its period label? A 10-Q files BOTH the
4103
5016
  // discrete 3-month quarter AND the year-to-date figure under the same
4104
5017
  // (fy, fp, end) tuple, filed the same day — so "most recently filed" was a
@@ -4146,8 +5059,18 @@ async function companyConcept(
4146
5059
  accession: entry.accn as string | undefined,
4147
5060
  unit,
4148
5061
  derived: false as boolean | undefined,
5062
+ // Duration this fact actually covers, in days — exposed so a caller (or
5063
+ // this file's own logic below) never has to trust the SEC-assigned
5064
+ // fiscal_period label to know the window. See the YTD-relabeling pass
5065
+ // below (fleet #3187): a ~180d "Q2" is six months, not a quarter.
5066
+ duration_days: entry.start ? spanDays(entry.start, entry.end) : undefined,
5067
+ ytd: false as boolean,
4149
5068
  note: undefined as string | undefined,
4150
5069
  total_liquidity: undefined as LiquidityBreakdown | undefined,
5070
+ // Derived rows only (fleet #3179): the filings the arithmetic read, so a
5071
+ // computed Q2/Q3/Q4 figure is still traceable to documents. `accession`
5072
+ // stays undefined on those rows -- no ONE filing states the number.
5073
+ derived_from_accessions: undefined as string[] | undefined,
4151
5074
  }))
4152
5075
  .sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
4153
5076
 
@@ -4194,8 +5117,11 @@ async function companyConcept(
4194
5117
  form: 'derived',
4195
5118
  filed: fyRow.filed,
4196
5119
  accession: undefined, // arithmetic over FY + Q1-Q3, not a fact one filing states
5120
+ derived_from_accessions: uniqueAccessions([fyRow, ...quarters.values()]),
4197
5121
  unit: fyRow.unit,
4198
5122
  derived: true,
5123
+ duration_days: undefined, // no period_start to compute a span from
5124
+ ytd: false,
4199
5125
  note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
4200
5126
  total_liquidity: undefined,
4201
5127
  });
@@ -4259,8 +5185,11 @@ async function companyConcept(
4259
5185
  form: 'derived',
4260
5186
  filed: q2ytd.filed,
4261
5187
  accession: undefined, // YTD(Q2) minus Q1 — spans two filings
5188
+ derived_from_accessions: uniqueAccessions([q2ytd, q1]),
4262
5189
  unit: q2ytd.unit,
4263
5190
  derived: true,
5191
+ duration_days: spanDays(q1.period_end, q2ytd.period_end),
5192
+ ytd: false, // this IS the discrete quarter — the YTD fact it was computed from keeps its own row, relabeled below
4264
5193
  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
5194
  total_liquidity: undefined,
4266
5195
  });
@@ -4279,8 +5208,11 @@ async function companyConcept(
4279
5208
  form: 'derived',
4280
5209
  filed: q3ytd.filed,
4281
5210
  accession: undefined, // YTD(Q3) minus YTD(Q2) — spans two filings
5211
+ derived_from_accessions: uniqueAccessions([q3ytd, q2ytd]),
4282
5212
  unit: q3ytd.unit,
4283
5213
  derived: true,
5214
+ duration_days: spanDays(q2ytd.period_end, q3ytd.period_end),
5215
+ ytd: false, // the discrete quarter; the YTD fact it was computed from keeps its own row, relabeled below
4284
5216
  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
5217
  total_liquidity: undefined,
4286
5218
  });
@@ -4291,6 +5223,33 @@ async function companyConcept(
4291
5223
  entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
4292
5224
  }
4293
5225
 
5226
+ // RELABEL YTD-SHAPED AS-FILED FACTS (fleet #3187). The block above derives a
5227
+ // discrete quarter FROM the as-filed cumulative fact but leaves that original
5228
+ // fact in `entries` still labeled fiscal_period "Q2"/"Q3" — the SEC label for
5229
+ // the period the filing covers, not the duration of the fact itself. A 180-day
5230
+ // fact labeled "Q2" reads as a quarter to any caller that doesn't check
5231
+ // duration_days, and `edgar_company_concept({concept:'capex', fiscal_year:2026})`
5232
+ // on TSLA did exactly that: 8.282B (six months) presented as fiscal_period
5233
+ // "Q2" alongside a discrete-quarter sibling nobody was told to prefer.
5234
+ // Relabel BY DURATION (not by tag, so it covers any concept a filer reports
5235
+ // this way) to "H1"/"9M" and mark `ytd:true`, so "Q2"/"Q3" always means a real
5236
+ // quarter and the six/nine-month total is never presented as one. Runs AFTER
5237
+ // the Q4 and standalone-quarter derivations above, which rely on the original
5238
+ // Q2/Q3 labels to find their YTD legs, and BEFORE the period filter below, so
5239
+ // a caller asking fiscal_period:"Q2" gets the discrete quarter, not the H1 total.
5240
+ for (const e of entries) {
5241
+ if (e.derived || e.unit.includes('/')) continue; // derived rows are already correctly labeled; ratio/per-share units don't sum/subtract meaningfully
5242
+ if (e.fiscal_period === 'Q2' && e.duration_days != null && e.duration_days >= 170 && e.duration_days <= 190) {
5243
+ e.fiscal_period = 'H1';
5244
+ e.ytd = true;
5245
+ 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.`;
5246
+ } else if (e.fiscal_period === 'Q3' && e.duration_days != null && e.duration_days >= 260 && e.duration_days <= 290) {
5247
+ e.fiscal_period = '9M';
5248
+ e.ytd = true;
5249
+ 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.`;
5250
+ }
5251
+ }
5252
+
4294
5253
  // PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
4295
5254
  // names of two fields on every row below, and callers asking a one-period
4296
5255
  // question ("net income fiscal 2024") send them as ARGUMENTS — measured on
@@ -4439,7 +5398,20 @@ async function companyConcept(
4439
5398
  }
4440
5399
  }
4441
5400
 
4442
- const latestFact = rows.find((e) => !e.derived) ?? null;
5401
+ // `latest` must never be a bare YTD figure presented without saying so. The
5402
+ // freshest row by period_end can now be an as-filed "H1"/"9M" fact (ytd:true,
5403
+ // relabeled above) — when that happens, prefer its DERIVED discrete-quarter
5404
+ // sibling at the same period_end instead (an exact subtraction within the
5405
+ // same fiscal year, so it's trustworthy even though derived:true), the same
5406
+ // way `values` already offers both. Q4 stays excluded from this preference:
5407
+ // FY-minus-9M is the one case where no better reported fact exists at all, so
5408
+ // it is never promoted to `latest` — a caller explicitly filtering
5409
+ // fiscal_period:"Q4" still gets it from `values`.
5410
+ const freshestEnd = rows[0]?.period_end;
5411
+ const latestFact =
5412
+ rows.find((e) => e.derived && e.fiscal_period !== 'Q4' && e.period_end === freshestEnd)
5413
+ ?? rows.find((e) => !e.derived)
5414
+ ?? null;
4443
5415
  let coverageBound: string | undefined;
4444
5416
  if (latestFact?.filed) {
4445
5417
  const daysSinceFiled = (Date.now() - Date.parse(latestFact.filed)) / 86400000;
@@ -5667,6 +6639,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5667
6639
  args.start_date as string | undefined,
5668
6640
  args.end_date as string | undefined,
5669
6641
  args.limit as number | undefined,
6642
+ (args.entity ?? args.ticker_or_cik ?? args.cik ?? args.ticker) as string | undefined,
5670
6643
  );
5671
6644
  // Every tool below takes the same thing — a company — and this pack spells
5672
6645
  // it three ways across its own tools (`ticker_or_cik`, `cik`, `ticker`), so
@@ -5741,6 +6714,17 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5741
6714
  (args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
5742
6715
  (args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
5743
6716
  );
6717
+ case 'edgar_filing_exhibit_text':
6718
+ return filingExhibitText(
6719
+ args.accession as string | undefined,
6720
+ (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
6721
+ args.exhibit as string | undefined,
6722
+ (args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
6723
+ args.max_chars as number | undefined,
6724
+ args.offset as number | undefined,
6725
+ args.form_type as string | undefined,
6726
+ (args.filed ?? args.filing_date) as string | undefined,
6727
+ );
5744
6728
  case 'edgar_ticker_to_cik':
5745
6729
  return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
5746
6730
  case 'sponsor_to_filer':