@pipeworx/mcp-edgar 0.3.11 → 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/src/index.ts CHANGED
@@ -24,6 +24,93 @@ interface McpToolExport {
24
24
  provider?: string;
25
25
  }
26
26
 
27
+ /**
28
+ * The class routing tokens, and the two safe ways to wrap a message carrying one.
29
+ *
30
+ * A pack signals an error's class with a leading token — `user_error:`,
31
+ * `upstream_down:`, `upstream_throttled:`, `not_found:`, `blocked_host:`. The
32
+ * gateway's classifier anchors on `^`, and `stripClassPrefix` (which hides the
33
+ * token from the caller) anchors on `^` too. So the convention has one failure
34
+ * mode, and it is silent: a catch block that wraps the message —
35
+ * `` `${slug}/${tool}: ${message}` `` — pushes the token off position 0. The
36
+ * error then books as `error` ("Pipeworx has a defect") instead of as the
37
+ * caller mistake it is, AND the raw token leaks into what the caller reads.
38
+ *
39
+ * Nothing about that fails loudly. The call still returns, the message still
40
+ * reads plausibly, and the misclassification only shows up as a pack sitting on
41
+ * the Problem Tools list for a bug it does not have. Found live in
42
+ * `medicaid-intelligence` on 2026-08-21; the same wrapper template is copied
43
+ * across 18 DMV packs, none of which emit a token *yet*.
44
+ *
45
+ * `scripts/check-error-class-prefix.mjs` is the gate that keeps this honest —
46
+ * it fails any pack that both emits a token and wraps a caught message without
47
+ * using one of the helpers below.
48
+ */
49
+
50
+ /**
51
+ * The canonical token set. `workers/gateway/src/error-class.ts` carries its own
52
+ * copy on the read side (it is deliberately importable without pulling a pack
53
+ * in); the gate asserts the two agree, because this list has already drifted
54
+ * twice — `not_found:` and `blocked_host:` were honoured by the classifier and
55
+ * not stripped, so both went out to callers verbatim for months.
56
+ */
57
+ const CLASS_TOKENS = [
58
+ 'upstream_down',
59
+ 'upstream_throttled',
60
+ 'user_error',
61
+ 'not_found',
62
+ 'blocked_host',
63
+ // `blocked_url:` is emitted at position 0 from five sites in ssrf.ts
64
+ // (`assertPublicHttpUrl`, and every redirect hop in `safeFetch`) and was in
65
+ // NEITHER reader — so it went to callers verbatim for its whole life. Caught
66
+ // 2026-08-21 by a live n8n call, which answered a private instance_url with
67
+ // "…host). blocked_url: refusing to fetch non-public or non-https URL".
68
+ // Exactly the drift the gate now blocks.
69
+ 'blocked_url',
70
+ // `auth_required:` joins the list 2026-08-29 (fleet #638). It exists for the
71
+ // same reason `user_error:` does: a bare 401/403 in an upstream body matches
72
+ // the `upstream_throttled` heuristic below before anything auth-specific, so
73
+ // a pack that needs to say "this is a credential problem, not a rate limit"
74
+ // has no wording-based route — only the explicit-prefix escape hatch works.
75
+ // tiingo and open-sanctions both reached for it on their own, on the
76
+ // (reasonable, but wrong at the time) assumption that any snake_case class
77
+ // already meant something to the gateway. Neither shipped a leak from
78
+ // MIS-CLASSIFICATION — the `error` field was already correct — the leak was
79
+ // the literal token riding along in `message`, unstripped, because this list
80
+ // didn't know the token either reader was seeing.
81
+ 'auth_required',
82
+ ] as const;
83
+
84
+ const CLASS_PREFIX_RE =
85
+ /^(?:upstream_down|upstream_throttled|user_error|not_found|blocked_host|blocked_url|auth_required)\s*:\s*/;
86
+
87
+ /**
88
+ * Split a caught message into its leading routing token (possibly empty) and
89
+ * the human-readable body, so a wrapper can put the token back on the front.
90
+ *
91
+ * const { token, body } = splitClassPrefix(message);
92
+ * return { error: `${token}my-pack/${name}: ${body}` };
93
+ *
94
+ * The `${token}` must be the FIRST thing in the template — that is the whole
95
+ * point, and it is what the gate checks.
96
+ */
97
+ function splitClassPrefix(message: string): { token: string; body: string } {
98
+ const token = message.match(CLASS_PREFIX_RE)?.[0] ?? '';
99
+ return { token, body: message.slice(token.length) };
100
+ }
101
+
102
+ /**
103
+ * Drop a leading routing token from a message that is about to become a
104
+ * FRAGMENT of a larger one — a per-mirror failure joined into "all providers
105
+ * failed (...)", say. Hoisting is wrong there: the fragment never reaches
106
+ * position 0, so the token cannot route anything and would only leak. The outer
107
+ * message declares its own class.
108
+ */
109
+ function dropClassPrefix(message: string): string {
110
+ return message.replace(CLASS_PREFIX_RE, '');
111
+ }
112
+
113
+
27
114
  /**
28
115
  * Was this failure OUR OWN web service? — the other half of `internal-db-class.ts`.
29
116
  *
@@ -671,6 +758,254 @@ function collapse(s: string): string {
671
758
  return s.replace(/\s+/g, ' ').trim();
672
759
  }
673
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
+
674
1009
 
675
1010
  // Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
676
1011
  // imports — so publish-pack.sh can inline it into standalone pack builds the same
@@ -1136,6 +1471,8 @@ function stripConcept(v: AnnualValue): Omit<AnnualValue, 'concept'> {
1136
1471
  * (the resolve -> filings -> concept chain, collapsed; fleet #2182)
1137
1472
  * - edgar_filing_documents: list the documents inside one filing by accession number
1138
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
1139
1476
  *
1140
1477
  * Note: SEC requires a descriptive User-Agent header per their guidelines.
1141
1478
  */
@@ -1161,7 +1498,7 @@ const tools: McpToolExport['tools'] = [
1161
1498
  {
1162
1499
  name: 'edgar_search_filings',
1163
1500
  description:
1164
- '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).',
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).',
1165
1502
  summary: 'Full-text search across every SEC filing, using EDGAR\'s own search index.',
1166
1503
  inputSchema: {
1167
1504
  type: 'object' as const,
@@ -1171,8 +1508,15 @@ const tools: McpToolExport['tools'] = [
1171
1508
  type: 'string',
1172
1509
  description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K", "DEF 14A"). Omit for all types.',
1173
1510
  },
1174
- start_date: { type: 'string', description: 'Start date in YYYY-MM-DD format (e.g., "2024-01-01")' },
1175
- 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`.' },
1176
1520
  limit: { type: 'number', description: 'Number of results to return (1-40, default 10)' },
1177
1521
  },
1178
1522
  required: ['query'],
@@ -1181,7 +1525,7 @@ const tools: McpToolExport['tools'] = [
1181
1525
  {
1182
1526
  name: 'edgar_company_filings',
1183
1527
  description:
1184
- 'AUTHORITATIVE list of recent SEC filings for a specific US public company. Send the company as `ticker_or_cik` — that argument takes a ticker ("AAPL") or a CIK ("320193"), and `cik` / `ticker` are accepted as aliases for it. Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts. If you also need the headline financials alongside the filings, edgar_company_snapshot returns both in one call.',
1528
+ 'AUTHORITATIVE list of recent SEC filings for a specific US public company. Send the company as `ticker_or_cik` — that argument takes a ticker ("AAPL") or a CIK ("320193"), and `cik` / `ticker` are accepted as aliases for it. **When the question is about ONE filing rather than the list, say which one**: pass `accession` to narrow to it, or `filed` ("2026-09-29", "2026-09") when you know only the date, and add `include_primary_text:true` to get that filing\'s text back here instead of chaining a second call. Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts. If you also need the headline financials alongside the filings, edgar_company_snapshot returns both in one call.',
1185
1529
  summary: 'Every SEC filing a US public company has made, newest first.',
1186
1530
  inputSchema: {
1187
1531
  type: 'object' as const,
@@ -1195,6 +1539,18 @@ const tools: McpToolExport['tools'] = [
1195
1539
  description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K"). Omit for all types.',
1196
1540
  },
1197
1541
  limit: { type: 'number', description: 'Max filings to return (1-40, default 20)' },
1542
+ accession: {
1543
+ type: 'string',
1544
+ description: 'Narrow the list to ONE filing you already know the accession of, dashed or not (e.g. "0001628280-26-063820") — use this when the question is about a specific filing rather than "what has this company filed". Pair it with `include_primary_text:true` to get that filing\'s text back in the same call. A named accession that does not belong to this filer is an explicit error, never a silently unfiltered list.',
1545
+ },
1546
+ include_primary_text: {
1547
+ type: 'boolean',
1548
+ description: 'When true, also return the primary document\'s text (HTML stripped to plaintext, capped at ~40,000 chars) under `primary_text`, for the SINGLE filing `accession` names. Ignored — with a note saying so — when no accession narrows the list, since the text of twenty filings is not a thing this tool returns; use edgar_filing_text for that.',
1549
+ },
1550
+ filed: {
1551
+ type: 'string',
1552
+ description: 'Only filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" are accepted too). Use it when the question names WHEN ("Tesla\'s September 29 2026 8-K") and you do not have the accession: without it the newest filing of that form is returned, which for an 8-K is routinely a different filing from the one asked about.',
1553
+ },
1198
1554
  cik: {
1199
1555
  type: 'string',
1200
1556
  description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
@@ -1246,7 +1602,7 @@ const tools: McpToolExport['tools'] = [
1246
1602
  {
1247
1603
  name: 'edgar_company_concept',
1248
1604
  description:
1249
- '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.',
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.',
1250
1606
  summary: 'One financial metric for a US public company across every period it has reported, from SEC XBRL.',
1251
1607
  inputSchema: {
1252
1608
  type: 'object' as const,
@@ -1271,8 +1627,8 @@ const tools: McpToolExport['tools'] = [
1271
1627
  },
1272
1628
  fiscal_period: {
1273
1629
  type: 'string',
1274
- 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.',
1275
- 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'],
1276
1632
  },
1277
1633
  ticker: {
1278
1634
  type: 'string',
@@ -1577,7 +1933,7 @@ const tools: McpToolExport['tools'] = [
1577
1933
  properties: {
1578
1934
  accession: {
1579
1935
  type: 'string',
1580
- description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead.',
1936
+ description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead, and the response will say `accession_source:"auto_selected"` and list under `auto_selected.candidates` the other filings you might have meant. If the question names WHICH filing (a date, "the September 29 8-K"), pass `filed` or the accession rather than relying on that choice.',
1581
1937
  },
1582
1938
  ticker: {
1583
1939
  type: 'string',
@@ -1591,6 +1947,11 @@ const tools: McpToolExport['tools'] = [
1591
1947
  type: 'string',
1592
1948
  description: 'When accession is omitted, the form type of the latest filing to fetch, e.g. "10-K", "10-Q", "8-K", "DEF 14A" — or a `|`-separated SET ("10-K|10-Q") for "the most recent of either, whichever is newer". Omit both accession and form_type to get the single most recent filing of ANY type at all (routine 8-Ks/Form 4s/Form 144s included, not just annual/quarterly reports) — use the `|` set instead when you specifically want the newest 10-K or 10-Q.',
1593
1949
  },
1950
+ filed: {
1951
+ type: 'string',
1952
+ description: 'Only consider filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" accepted). **Pass this whenever the question names a date** ("Tesla\'s September 29 2026 8-K"): without it the NEWEST filing of that form is used, and for an 8-K that is routinely a different filing.',
1953
+ },
1954
+ filing_date: { type: 'string', description: 'Alias for `filed`.' },
1594
1955
  include_primary_text: {
1595
1956
  type: 'boolean',
1596
1957
  description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false. For the FULL, pageable document text — or just one section like going-concern/liquidity — use edgar_filing_text instead.',
@@ -1606,15 +1967,20 @@ const tools: McpToolExport['tools'] = [
1606
1967
  {
1607
1968
  name: 'edgar_filing_text',
1608
1969
  description:
1609
- '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. **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.',
1970
+ '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.',
1610
1971
  summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1611
1972
  inputSchema: {
1612
1973
  type: 'object' as const,
1613
1974
  properties: {
1614
1975
  accession: {
1615
1976
  type: 'string',
1616
- description: 'SEC accession number, dashed or not (e.g. "0001683168-26-003909"). Omit to auto-resolve the latest filing of form_type for the given ticker/cik.',
1977
+ description: 'SEC accession number, dashed or not (e.g. "0001683168-26-003909"). Omit to auto-resolve the latest filing of form_type for the given ticker/cik — the response then says `accession_source:"auto_selected"` and lists under `auto_selected.candidates` the other filings you might have meant. If the question names WHICH filing (a date, "the September 29 8-K"), pass `filed` or an accession instead of relying on that choice.',
1978
+ },
1979
+ filed: {
1980
+ type: 'string',
1981
+ description: 'Only consider filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" accepted). **Pass this whenever the question names a date** ("Tesla\'s September 29 2026 8-K"): without it the NEWEST filing of that form is read, and for an 8-K that is routinely a different filing — verified live 2026-10-18, the newest TSLA 8-K was six days later, 3,061 chars, and contained none of the credit-facility figures asked about.',
1617
1982
  },
1983
+ filing_date: { type: 'string', description: 'Alias for `filed`.' },
1618
1984
  ticker: {
1619
1985
  type: 'string',
1620
1986
  description: 'Filer ticker (e.g. "ACTU"). Provide this OR cik. ONLY pass a ticker you are CERTAIN of — a wrong remembered ticker silently retrieves a DIFFERENT company\'s filing as a clean success (a "SpaceX" question filled with SPCE returns Virgin Galactic\'s S-1). For a recent IPO or any uncertain ticker, resolve first: edgar_company_filings accepts the company NAME and returns the cik — pass that cik here.',
@@ -1664,6 +2030,62 @@ const tools: McpToolExport['tools'] = [
1664
2030
  required: [],
1665
2031
  },
1666
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
+ },
1667
2089
  ];
1668
2090
 
1669
2091
  // ── Helpers ─────────────────────────────────────────────────────────
@@ -1678,21 +2100,93 @@ function isNumericCik(value: string): boolean {
1678
2100
 
1679
2101
  // ── Tool implementations ────────────────────────────────────────────
1680
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
+
1681
2120
  async function searchFilings(
1682
2121
  query: string,
1683
2122
  formType?: string,
1684
2123
  startDate?: string,
1685
2124
  endDate?: string,
1686
2125
  limit?: number,
2126
+ entity?: string,
1687
2127
  ) {
1688
2128
  const count = Math.min(40, Math.max(1, limit ?? 10));
1689
2129
  const params = new URLSearchParams({ q: query, from: '0', size: String(count) });
1690
2130
 
1691
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;
1692
2139
  if (startDate || endDate) {
2140
+ effStart = startDate ?? EFTS_FLOOR;
2141
+ effEnd = endDate ?? new Date().toISOString().slice(0, 10);
1693
2142
  params.set('dateRange', 'custom');
1694
- if (startDate) params.set('startdt', startDate);
1695
- if (endDate) params.set('enddt', endDate);
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)}`);
1696
2190
  }
1697
2191
 
1698
2192
  const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
@@ -1726,7 +2220,26 @@ async function searchFilings(
1726
2220
  // adsh (accession), and — for 8-Ks — items[] (e.g. "3.01"). The prior mapper
1727
2221
  // read entity_name/form_type/biz_location, which do NOT exist in this payload,
1728
2222
  // so every field but the date/id serialized as undefined and dropped.
1729
- const results = (data.hits?.hits ?? []).map((hit) => {
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) => {
1730
2243
  const s = hit._source;
1731
2244
  const display = s.display_names?.[0] ?? '';
1732
2245
  // display_names is "NAME (TICKER) (CIK 000...)" (double-space before each
@@ -1749,12 +2262,23 @@ async function searchFilings(
1749
2262
  };
1750
2263
  });
1751
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
+
1752
2268
  return {
1753
2269
  query,
1754
2270
  form_type_filter: formType ?? 'all',
1755
- date_range: { start: startDate ?? null, end: endDate ?? null },
1756
- total_hits: data.hits?.total?.value ?? 0,
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,
1757
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).',
1758
2282
  };
1759
2283
  }
1760
2284
 
@@ -2303,13 +2827,100 @@ function recentFilingRows(data: SubmissionsPayload, keep: (form: string) => bool
2303
2827
  return filings;
2304
2828
  }
2305
2829
 
2306
- async function companyFilings(tickerOrCik: string, formType?: string, limit?: number) {
2830
+ async function companyFilings(
2831
+ tickerOrCik: string,
2832
+ formType?: string,
2833
+ limit?: number,
2834
+ accession?: string,
2835
+ includePrimaryText?: boolean,
2836
+ filed?: string,
2837
+ ) {
2307
2838
  const cik = await resolveCik(tickerOrCik);
2308
2839
  const paddedCik = padCik(cik);
2309
2840
  const count = Math.min(40, Math.max(1, limit ?? 20));
2310
2841
 
2311
2842
  const data = await fetchSubmissions(tickerOrCik, paddedCik);
2312
- const filings = recentFilingRows(data, (form) => !formType || form === formType, count);
2843
+
2844
+ // ── A NAMED FILING IS NOT A FILTER SUGGESTION (fleet #3319) ──────────────
2845
+ //
2846
+ // `accession` and `include_primary_text` used to be undeclared here, so a
2847
+ // caller that named a filing got the same unnarrowed list as one that named
2848
+ // nothing — the silently-ignored-filter-key failure, which returns HTTP 200
2849
+ // and reads as a clean success. Measured live 2026-10-18 on
2850
+ // ask_pipeworx_grounded: its last ladder attempt routed to this tool with
2851
+ // {accession:"0001628280-26-063820", ticker:"TSLA", include_primary_text:true}
2852
+ // — the router had already resolved the RIGHT 8-K — and the extraction model
2853
+ // received a 10-row filings list carrying no facility amounts and refused
2854
+ // `not_in_source`, 5 tries out of 5. The accession was in our hands the whole
2855
+ // time.
2856
+ const wantAcc = accession ? normalizeAccession(accession) : null;
2857
+ if (accession && !wantAcc) {
2858
+ throw new Error(
2859
+ `Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0001628280-26-063820", with or without dashes). Omit it to list this filer's recent filings, or pass \`filed\` ("2026-09-29") if you know only the date.`,
2860
+ );
2861
+ }
2862
+ const filedPrefix = normalizeFiledPrefix(filed);
2863
+ if (filed && !filedPrefix) {
2864
+ throw new Error(
2865
+ `Could not read "${filed}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`,
2866
+ );
2867
+ }
2868
+
2869
+ const formKeep = (form: string) => !formType || formType.toLowerCase() === 'all' || form === formType;
2870
+ let filings = recentFilingRows(data, formKeep, wantAcc || filedPrefix ? 40 : count);
2871
+ const totalBeforeNarrowing = filings.length;
2872
+ if (wantAcc) {
2873
+ const hit = filings.filter((f) => normalizeAccession(f.accession_number)?.dashed === wantAcc.dashed);
2874
+ if (!hit.length) {
2875
+ // Loud, not an unfiltered list: an accession this filer has not filed is
2876
+ // a wrong question, and answering it with somebody else's filings is how
2877
+ // a caller ends up quoting the wrong document.
2878
+ throw new Error(
2879
+ `not_found: accession ${wantAcc.dashed} is not among ${data.name}'s recent filings${formType && formType.toLowerCase() !== 'all' ? ` of form ${formType}` : ''}. Drop \`accession\` to list what this filer HAS filed (the recent index covers roughly the last 1,000 filings), or check the digits — an accession belongs to exactly one filer.`,
2880
+ );
2881
+ }
2882
+ filings = hit;
2883
+ } else if (filedPrefix) {
2884
+ filings = filings.filter((f) => (f.filing_date ?? '').startsWith(filedPrefix)).slice(0, count);
2885
+ if (!filings.length) {
2886
+ throw new Error(
2887
+ `not_found: ${data.name} has no${formType && formType.toLowerCase() !== 'all' ? ` ${formType}` : ''} filing dated ${filedPrefix} in SEC's recent index (${totalBeforeNarrowing} candidate filing(s) were checked). Widen the date ("${filedPrefix.slice(0, 7)}" for the whole month) or drop \`filed\` to see what is actually there.`,
2888
+ );
2889
+ }
2890
+ }
2891
+
2892
+ // `include_primary_text` is only answerable for ONE filing. Say which way it
2893
+ // went rather than returning a list and leaving the caller to notice the
2894
+ // field is missing (silent-zero policy).
2895
+ let primaryText: string | null = null;
2896
+ let primaryTextNote: string | null = null;
2897
+ if (includePrimaryText) {
2898
+ if (filings.length === 1) {
2899
+ const only = filings[0];
2900
+ try {
2901
+ const { text: body, truncated: rawTrunc } = await fetchTextBounded(
2902
+ only.document_url,
2903
+ { 'User-Agent': SEC_HEADERS['User-Agent'] },
2904
+ FILING_DOC_MAX_BYTES,
2905
+ );
2906
+ const isHtml = /\.x?html?$/i.test(only.primary_document) || /^\s*</.test(body);
2907
+ const text = isHtml ? htmlToText(body) : body;
2908
+ primaryText = text.slice(0, 40000);
2909
+ if (text.length > 40000 || rawTrunc) {
2910
+ primaryTextNote = `primary_text is the first 40,000 of ${text.length} characters${rawTrunc ? ' of the portion of an oversized document that was read at all' : ''} — NOT the whole document. Read the rest with edgar_filing_text({accession:"${only.accession_number}", ticker_or_cik:"${tickerOrCik}", offset:40000}).`;
2911
+ }
2912
+ } catch (err) {
2913
+ // The list is the core payload; a failed text fetch degrades with a
2914
+ // stated reason rather than failing the whole call.
2915
+ // dropClassPrefix: this is a NOTE fragment, not the call's error — the
2916
+ // call succeeded. A routing token carried in here would be read by the
2917
+ // gateway's classifier as this response's class, which it is not.
2918
+ primaryTextNote = `primary_text could not be fetched (${dropClassPrefix(err instanceof Error ? err.message : String(err))}). The filing row below is still correct; retry the text with edgar_filing_text({accession:"${only.accession_number}", ticker_or_cik:"${tickerOrCik}"}).`;
2919
+ }
2920
+ } else {
2921
+ primaryTextNote = `include_primary_text was requested but ${filings.length} filings match, and this tool returns the text of ONE filing only. Pass \`accession\` (or a narrower \`filed\`) to pick one, or call edgar_filing_text for a filing's full, pageable text.`;
2922
+ }
2923
+ }
2313
2924
 
2314
2925
  return {
2315
2926
  cik: data.cik,
@@ -2327,7 +2938,18 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
2327
2938
  state_of_incorporation: data.stateOfIncorporation,
2328
2939
  fiscal_year_end: data.fiscalYearEnd,
2329
2940
  filter_form_type: formType ?? 'all',
2941
+ ...(wantAcc ? { filter_accession: wantAcc.dashed } : {}),
2942
+ ...(filedPrefix ? { filter_filed: filedPrefix } : {}),
2943
+ // Narrowing is stated, so a one-row answer never reads as "this company
2944
+ // has filed exactly one thing".
2945
+ ...(wantAcc || filedPrefix
2946
+ ? {
2947
+ narrowed_note: `This is NOT ${data.name}'s recent filings list — it was narrowed to ${wantAcc ? `the single filing ${wantAcc.dashed}` : `filings dated ${filedPrefix}`}, ${filings.length} of ${totalBeforeNarrowing} candidate(s) examined. Drop that argument for the full list.`,
2948
+ }
2949
+ : {}),
2330
2950
  filings,
2951
+ ...(primaryText !== null ? { primary_text: primaryText } : {}),
2952
+ ...(primaryTextNote ? { primary_text_note: primaryTextNote } : {}),
2331
2953
  };
2332
2954
  }
2333
2955
 
@@ -2553,11 +3175,117 @@ function findLatestFormIndex(forms: string[], wantFormTypeArg: string | undefine
2553
3175
  return forms.findIndex((f) => wanted.includes(f.toUpperCase()));
2554
3176
  }
2555
3177
 
3178
+ /**
3179
+ * Normalise a caller's `filed` into a date PREFIX we can match against SEC's
3180
+ * own `filingDate` strings ("2026-09-29"). Accepts a full ISO date, a month
3181
+ * ("2026-09"), a year ("2026"), and the US spellings a model actually types
3182
+ * ("09/29/2026", "September 29 2026"). Returns null when nothing date-shaped
3183
+ * is in there, which is treated as "no date filter" rather than an error — a
3184
+ * wrong-shaped date that silently matched nothing would be the same class of
3185
+ * defect this whole change exists to remove.
3186
+ */
3187
+ // Full names AND the abbreviations in actual use, listed rather than matched by
3188
+ // prefix: a 3-letter prefix accepts "Septober" as September, which is the
3189
+ // silent-wrong-answer shape this whole change is about. An unrecognised month
3190
+ // returns null and the caller gets an explicit error.
3191
+ const FILED_MONTHS: Record<string, string> = {
3192
+ january: '01', jan: '01',
3193
+ february: '02', feb: '02',
3194
+ march: '03', mar: '03',
3195
+ april: '04', apr: '04',
3196
+ may: '05',
3197
+ june: '06', jun: '06',
3198
+ july: '07', jul: '07',
3199
+ august: '08', aug: '08',
3200
+ september: '09', sept: '09', sep: '09',
3201
+ october: '10', oct: '10',
3202
+ november: '11', nov: '11',
3203
+ december: '12', dec: '12',
3204
+ };
3205
+ export function normalizeFiledPrefix(filed: string | undefined): string | null {
3206
+ const s = (filed ?? '').trim();
3207
+ if (!s) return null;
3208
+ let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
3209
+ if (m) return `${m[1]}-${m[2]}-${m[3]}`;
3210
+ m = /^(\d{4})-(\d{2})$/.exec(s);
3211
+ if (m) return `${m[1]}-${m[2]}`;
3212
+ m = /^(\d{4})$/.exec(s);
3213
+ if (m) return m[1];
3214
+ m = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})$/.exec(s); // 09/29/2026 (US order)
3215
+ if (m) return `${m[3]}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`;
3216
+ // "September 29, 2026" / "Sept 29 2026" / "September 2026"
3217
+ const words = /^([A-Za-z]{3,9})\.?\s+(?:(\d{1,2})(?:st|nd|rd|th)?,?\s+)?(\d{4})$/.exec(s);
3218
+ if (words) {
3219
+ const mo = FILED_MONTHS[words[1].toLowerCase()];
3220
+ if (mo) return words[2] ? `${words[3]}-${mo}-${words[2].padStart(2, '0')}` : `${words[3]}-${mo}`;
3221
+ }
3222
+ return null;
3223
+ }
3224
+
3225
+ /**
3226
+ * Recent-index positions of EVERY filing matching `wantFormTypeArg`, newest
3227
+ * first, optionally restricted to those whose filingDate starts with
3228
+ * `filedPrefix`. `findLatestFormIndex` above answers "which one filing", which
3229
+ * is all the auto-resolve path used to need; this answers "which filings could
3230
+ * the caller have meant", which is what lets the tool (a) honour a date in the
3231
+ * question and (b) say out loud that it picked one of several.
3232
+ */
3233
+ export function findFormIndices(
3234
+ forms: string[],
3235
+ wantFormTypeArg: string | undefined,
3236
+ filingDates: string[],
3237
+ filedPrefix: string | null,
3238
+ limit: number,
3239
+ ): number[] {
3240
+ const wanted = (wantFormTypeArg ?? '')
3241
+ .split('|')
3242
+ .map((f) => f.trim().toUpperCase())
3243
+ .filter(Boolean);
3244
+ const out: number[] = [];
3245
+ for (let i = 0; i < forms.length && out.length < limit; i++) {
3246
+ if (wanted.length && !wanted.includes((forms[i] ?? '').toUpperCase())) continue;
3247
+ if (filedPrefix && !(filingDates[i] ?? '').startsWith(filedPrefix)) continue;
3248
+ out.push(i);
3249
+ }
3250
+ return out;
3251
+ }
3252
+
3253
+ // How many auto-selected candidates this tool will READ when a `search` can
3254
+ // tell a silent filing apart from the right one, and how many it will LIST in
3255
+ // the envelope either way. Reading is bounded because each candidate is a real
3256
+ // SEC document fetch; listing is free (it comes out of the submissions payload
3257
+ // already in hand).
3258
+ const AUTO_SELECT_SCAN = 4;
3259
+ const AUTO_SELECT_LIST = 8;
3260
+
3261
+ /**
3262
+ * The `section` slice of a filing's text, as a function rather than inline, so
3263
+ * the auto-selection walk below can score a candidate against the SAME text
3264
+ * the search will actually run on. `section` is best-effort: an unmatched
3265
+ * section returns the whole document with section_found:false, unchanged.
3266
+ */
3267
+ export function sliceFilingSection(
3268
+ fullText: string,
3269
+ section: string | undefined,
3270
+ ): { text: string; applied: string | null; found: string | boolean | null } {
3271
+ const sec = (section ?? '').trim().toLowerCase();
3272
+ if (!sec) return { text: fullText, applied: null, found: null };
3273
+ const conf = FILING_SECTION_PATTERNS[sec];
3274
+ if (!conf) return { text: fullText, applied: null, found: `unknown_section:${sec}` };
3275
+ const idxs: number[] = [];
3276
+ for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
3277
+ const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
3278
+ if (chosen < 0) return { text: fullText, applied: null, found: false };
3279
+ // Back up a little so the section heading itself is included, not clipped mid-phrase.
3280
+ return { text: fullText.slice(Math.max(0, chosen - 120)), applied: sec, found: true };
3281
+ }
3282
+
2556
3283
  async function filingDocuments(
2557
3284
  accession: string | undefined,
2558
3285
  tickerOrCik: string,
2559
3286
  includePrimaryText?: boolean,
2560
3287
  wantFormTypeArg?: string,
3288
+ filedArg?: string,
2561
3289
  ) {
2562
3290
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2563
3291
  throw new Error(
@@ -2575,22 +3303,48 @@ async function filingDocuments(
2575
3303
  `Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0000320193-25-000079", with or without dashes). Get accession numbers from edgar_company_filings, or omit accession and pass form_type to auto-resolve the latest.`,
2576
3304
  );
2577
3305
  }
3306
+ // ── WHEN WE CHOSE THE FILING, SAY SO (fleet #3319) ───────────────────────
3307
+ // Same defect and same disclosure as edgar_filing_text: with `accession`
3308
+ // omitted this resolves the LATEST filing of form_type and then describes it
3309
+ // as though the caller had asked for it. There is no `search` here to tell
3310
+ // candidates apart, so the newest is still taken — but the choice, and the
3311
+ // filings not chosen, are now stated rather than invisible.
3312
+ let autoSelected: Record<string, unknown> | null = null;
2578
3313
  if (!acc) {
2579
3314
  const wantForm = wantFormTypeArg?.trim().toUpperCase();
3315
+ const filedPrefix = normalizeFiledPrefix(filedArg);
3316
+ if (filedArg && !filedPrefix) {
3317
+ throw new Error(`Could not read "${filedArg}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`);
3318
+ }
2580
3319
  const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
2581
3320
  if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
2582
- const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
3321
+ const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } } };
2583
3322
  const r = sub.filings?.recent;
2584
3323
  const forms = r?.form ?? [];
2585
3324
  const accs = r?.accessionNumber ?? [];
2586
- const idx = findLatestFormIndex(forms, wantFormTypeArg);
2587
- if (idx < 0 || !accs[idx]) {
3325
+ const dates = r?.filingDate ?? [];
3326
+ const candidateIdx = findFormIndices(forms, wantFormTypeArg, dates, filedPrefix, AUTO_SELECT_LIST)
3327
+ .filter((i) => !!accs[i] && !!normalizeAccession(accs[i]));
3328
+ if (!candidateIdx.length) {
3329
+ const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
2588
3330
  throw new Error(
2589
- `No ${wantForm ?? 'recent'} filing found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
3331
+ `No ${wantForm ?? 'recent'} filing${dateClause} found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
2590
3332
  );
2591
3333
  }
2592
- acc = normalizeAccession(accs[idx]);
3334
+ const chosenIdx = candidateIdx[0];
3335
+ acc = normalizeAccession(accs[chosenIdx]);
2593
3336
  if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
3337
+ autoSelected = {
3338
+ reason: `No accession was given, so this tool CHOSE the filing below — ${filedPrefix ? `the newest ${wantForm ?? 'filing'} dated ${filedPrefix}` : wantForm ? `the newest ${wantForm}` : 'the single most recent filing of ANY type'}.`,
3339
+ requested: { form_type: wantForm ?? null, filed: filedPrefix ?? null },
3340
+ chosen: { accession: acc.dashed, form: forms[chosenIdx] ?? null, filed: dates[chosenIdx] ?? null },
3341
+ candidates: candidateIdx.map((i) => ({
3342
+ accession: normalizeAccession(accs[i])!.dashed,
3343
+ form: forms[i] ?? null,
3344
+ filed: dates[i] ?? null,
3345
+ })),
3346
+ note: `If the question named a particular filing, pass its \`accession\` (or \`filed\`: "${dates[chosenIdx] ?? '2026-09-29'}") rather than relying on this choice — a company files many ${wantForm ?? 'document'}s and the newest is routinely not the one asked about.`,
3347
+ };
2594
3348
  }
2595
3349
  const cikNoZeros = String(Number(cik.replace(/\D/g, ''))); // archive path uses CIK with no leading zeros
2596
3350
  const paddedCik = padCik(cik);
@@ -2660,6 +3414,10 @@ async function filingDocuments(
2660
3414
  company,
2661
3415
  form_type: formType,
2662
3416
  filing_date: filingDate,
3417
+ // Whether the caller NAMED this filing or we picked it (fleet #3319).
3418
+ // Present on every response so its absence is never the signal.
3419
+ accession_source: autoSelected ? 'auto_selected' : 'caller',
3420
+ ...(autoSelected ? { auto_selected: autoSelected } : {}),
2663
3421
  primary_document: primaryDocument,
2664
3422
  filing_url: `${folder}/${acc.dashed}-index.html`,
2665
3423
  documents,
@@ -2846,6 +3604,7 @@ async function filingText(
2846
3604
  offset?: number,
2847
3605
  wantFormTypeArg?: string,
2848
3606
  searchTerm?: string,
3607
+ filedArg?: string,
2849
3608
  ) {
2850
3609
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2851
3610
  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"})).');
@@ -2869,83 +3628,175 @@ async function filingText(
2869
3628
  const r = sub.filings?.recent;
2870
3629
  const accs = r?.accessionNumber ?? [];
2871
3630
  const forms = r?.form ?? [];
3631
+ const filingDates = r?.filingDate ?? [];
2872
3632
 
2873
- if (!acc) {
3633
+ // `searchPhrases` is parsed up here, rather than beside its use further down,
3634
+ // because the auto-selection walk below needs to know whether there is a
3635
+ // phrase that can distinguish "this is the filing the caller meant" from
3636
+ // "this filing does not mention the subject at all".
3637
+ //
3638
+ // fleet #2450: a question can ask for TWO facts from the SAME filing
3639
+ // ("revenue AND test volume") and ask_pipeworx's router picks ONE tool call
3640
+ // for a single-entity question — so a single `search` phrase per call meant
3641
+ // the second fact was structurally unreachable. `search` accepts multiple
3642
+ // phrases separated by `|` (same convention as `form_type`'s "10-K|10-Q"
3643
+ // set) and reports each phrase's passages independently under `searches`,
3644
+ // keyed by `phrase`. A single phrase (no `|`) keeps the original singular
3645
+ // `search` shape unchanged.
3646
+ const searchPhrases = [...new Set(
3647
+ (searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean),
3648
+ )].slice(0, 5);
3649
+
3650
+ // Everything downstream that depends on WHICH filing this is, as one step,
3651
+ // so the walk below can evaluate more than one candidate.
3652
+ const loadFiling = async (chosen: { dashed: string; nodash: string }) => {
3653
+ const i = accs.indexOf(chosen.dashed);
3654
+ const form = i >= 0 ? forms[i] ?? null : null;
3655
+ const filedOn = i >= 0 ? filingDates[i] ?? null : null;
3656
+ let primaryDoc = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
3657
+
3658
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${chosen.nodash}`;
3659
+ // If the accession wasn't in the recent index (older filing), fall back to the
3660
+ // folder index to find the primary document.
3661
+ if (!primaryDoc) {
3662
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
3663
+ if (idxRes.status === 404) throw new Error(`No filing found for accession ${chosen.dashed} — it does not exist in SEC EDGAR for this filer. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"}) to list real accessions.`);
3664
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
3665
+ const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
3666
+ const docs = idxJson.directory?.item ?? [];
3667
+ // Prefer the main htm document that isn't an exhibit/XBRL artifact.
3668
+ primaryDoc =
3669
+ docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
3670
+ if (!primaryDoc) throw new Error(`Could not identify the primary document for accession ${chosen.dashed}. Use edgar_filing_documents to list the filing's files and fetch one by URL.`);
3671
+ }
3672
+
3673
+ const url = `${folder}/${primaryDoc}`;
3674
+ const { text: raw, truncated: rawTrunc } = await fetchTextBounded(url, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
3675
+ const html = /\.x?html?$/i.test(primaryDoc) || /^\s*</.test(raw);
3676
+ return {
3677
+ acc: chosen,
3678
+ formType: form,
3679
+ filingDate: filedOn,
3680
+ primaryDocument: primaryDoc,
3681
+ docUrl: url,
3682
+ rawTruncated: rawTrunc,
3683
+ fullText: html ? htmlToText(raw) : raw,
3684
+ };
3685
+ };
3686
+
3687
+ // ── WHEN WE CHOSE THE FILING, SAY SO (fleet #3319) ───────────────────────
3688
+ //
3689
+ // With `accession` omitted this tool resolves the LATEST filing of
3690
+ // `form_type` — and then every field in the response describes that filing
3691
+ // as though the caller had asked for it. Measured live 2026-10-18: a
3692
+ // grounded question about "Tesla's September 29 2026 8-K" reached here as
3693
+ // {ticker:"TSLA", form_type:"8-K", search:"credit facility"}, resolved to
3694
+ // the 2026-10-02 8-K (a different filing, 3,061 chars, zero matches), and
3695
+ // refused `not_in_source` 5 tries out of 5 — while the filing asked about
3696
+ // sat two rows down the same index with all three figures in it. Nothing in
3697
+ // the envelope could tell a reader that a choice had been made at all, so
3698
+ // "wrong filing" and "filing is silent on this" were indistinguishable.
3699
+ //
3700
+ // Two changes, both narrow: a `filed` date goes straight to the right
3701
+ // filing, and when only a `search` is available the walk below reads on past
3702
+ // a candidate whose text does not contain the phrase AT ALL. Either way the
3703
+ // choice, and the candidates not chosen, are stated in `auto_selected`.
3704
+ let loaded: Awaited<ReturnType<typeof loadFiling>>;
3705
+ let autoSelected: Record<string, unknown> | null = null;
3706
+
3707
+ if (acc) {
3708
+ loaded = await loadFiling(acc);
3709
+ } else {
2874
3710
  const wantForm = wantFormTypeArg?.trim().toUpperCase();
2875
- const idx = findLatestFormIndex(forms, wantFormTypeArg);
2876
- if (idx < 0 || !accs[idx]) {
2877
- throw new Error(`No ${wantForm ?? 'recent'} filing found for ${tickerOrCik}. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list filings, then pass an accession.`);
3711
+ const filedPrefix = normalizeFiledPrefix(filedArg);
3712
+ if (filedArg && !filedPrefix) {
3713
+ throw new Error(`Could not read "${filedArg}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`);
3714
+ }
3715
+ const candidateIdx = findFormIndices(forms, wantFormTypeArg, filingDates, filedPrefix, AUTO_SELECT_LIST)
3716
+ .filter((idx) => !!accs[idx] && !!normalizeAccession(accs[idx]));
3717
+ if (!candidateIdx.length) {
3718
+ const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
3719
+ 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.`);
2878
3720
  }
2879
- acc = normalizeAccession(accs[idx]);
2880
- if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
2881
- }
2882
3721
 
2883
- const i = accs.indexOf(acc.dashed);
2884
- const formType = i >= 0 ? forms[i] ?? null : null;
2885
- const filingDate = i >= 0 ? r?.filingDate?.[i] ?? null : null;
2886
- let primaryDocument = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
3722
+ const candidates = candidateIdx.map((idx) => ({
3723
+ accession: normalizeAccession(accs[idx])!.dashed,
3724
+ form: forms[idx] ?? null,
3725
+ filed: filingDates[idx] ?? null,
3726
+ }));
2887
3727
 
2888
- const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
2889
- // If the accession wasn't in the recent index (older filing), fall back to the
2890
- // folder index to find the primary document.
2891
- if (!primaryDocument) {
2892
- const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
2893
- if (idxRes.status === 404) throw new Error(`No filing found for accession ${acc.dashed} — it does not exist in SEC EDGAR for this filer. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"}) to list real accessions.`);
2894
- if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
2895
- const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
2896
- const docs = idxJson.directory?.item ?? [];
2897
- // Prefer the main htm document that isn't an exhibit/XBRL artifact.
2898
- primaryDocument =
2899
- docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
2900
- if (!primaryDocument) throw new Error(`Could not identify the primary document for accession ${acc.dashed}. Use edgar_filing_documents to list the filing's files and fetch one by URL.`);
2901
- }
2902
-
2903
- const docUrl = `${folder}/${primaryDocument}`;
2904
- const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
2905
- const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
2906
- let fullText = isHtml ? htmlToText(body) : body;
3728
+ // Reading past the first candidate is only useful when a `search` can
3729
+ // score one — with no phrase there is nothing to tell the candidates
3730
+ // apart, so the newest is taken exactly as before. EXACT matches only:
3731
+ // the word-level fallback is "a lead, not a confirmation" by this tool's
3732
+ // own note, and scoring on it would stop the walk at a filing that merely
3733
+ // shares vocabulary.
3734
+ const walk = searchPhrases.length ? candidateIdx.slice(0, AUTO_SELECT_SCAN) : candidateIdx.slice(0, 1);
3735
+ const examined: Array<{ accession: string; form: string | null; filed: string | null; exact_matches?: number }> = [];
3736
+ let picked: Awaited<ReturnType<typeof loadFiling>> | null = null;
3737
+ let fallback: Awaited<ReturnType<typeof loadFiling>> | null = null;
3738
+ for (const idx of walk) {
3739
+ const cand = await loadFiling(normalizeAccession(accs[idx])!);
3740
+ const scoreText = sliceFilingSection(cand.fullText, section).text;
3741
+ const matches = searchPhrases.reduce((n, p) => n + findPassages(scoreText, p).total, 0);
3742
+ examined.push({
3743
+ accession: cand.acc.dashed,
3744
+ form: cand.formType,
3745
+ filed: cand.filingDate,
3746
+ ...(searchPhrases.length ? { exact_matches: matches } : {}),
3747
+ });
3748
+ fallback ??= cand; // the newest — the historical choice, and the fallback
3749
+ if (!searchPhrases.length || matches > 0) {
3750
+ picked = cand;
3751
+ break;
3752
+ }
3753
+ }
3754
+ loaded = picked ?? fallback!;
3755
+ const walkedOn = examined.length > 1 && picked !== null;
3756
+ const allSilent = searchPhrases.length > 0 && picked === null;
3757
+
3758
+ autoSelected = {
3759
+ // The one sentence a reader needs: nobody asked for THIS filing.
3760
+ reason: `No accession was given, so this tool CHOSE the filing below — ${filedPrefix ? `the newest ${wantForm ?? 'filing'} dated ${filedPrefix}` : wantForm ? `the newest ${wantForm}` : 'the single most recent filing of ANY type'}${walkedOn ? ', after the newer candidate(s) in `examined` contained no exact match for the search phrase(s)' : ''}${allSilent ? ', and NONE of the candidates examined contained an exact match for the search phrase(s) — this is the newest one, not a filing shown to be relevant' : ''}.`,
3761
+ requested: { form_type: wantForm ?? null, filed: filedPrefix ?? null },
3762
+ chosen: { accession: loaded.acc.dashed, form: loaded.formType, filed: loaded.filingDate },
3763
+ examined,
3764
+ // Everything the caller might have meant, whether or not it was read —
3765
+ // free, since it comes out of the submissions payload already in hand.
3766
+ candidates,
3767
+ note: `If the question named a particular filing, pass its \`accession\` (or \`filed\`: "${loaded.filingDate ?? '2026-09-29'}") rather than relying on this choice — a company files many ${wantForm ?? 'documents'}s and the newest is routinely not the one asked about.`,
3768
+ ...(allSilent
3769
+ ? {
3770
+ warning: `Treat a zero-match result here as INCONCLUSIVE about the company, not about this filing: ${examined.length} candidate filing(s) were read and none mentions the search phrase(s). The filing the question meant may be one of \`candidates\` that was not read.`,
3771
+ }
3772
+ : {}),
3773
+ };
3774
+ }
3775
+
3776
+ acc = loaded.acc;
3777
+ const { formType, filingDate, primaryDocument, docUrl, rawTruncated } = loaded;
3778
+ let fullText = loaded.fullText;
3779
+ // Held before any narrowing below, because `total_chars` is reframed by a
3780
+ // section slice and then describes the SLICE — which is what this tool's own
3781
+ // offsets page against, so it cannot also carry the document's size. Without
3782
+ // this, a section slice and a search both shrink what we return while every
3783
+ // number in the response still says "whole document" (fleet #3316).
3784
+ const documentChars = fullText.length;
2907
3785
 
2908
3786
  // Optional section slice: locate the section heading (last occurrence = the
2909
3787
  // real body, past any table-of-contents mention) and reframe the text to start
2910
3788
  // there. `section` is best-effort — an unmatched section returns the whole doc.
2911
- let sectionApplied: string | null = null;
2912
- let sectionFound: string | boolean | null = null;
2913
- const sec = (section ?? '').trim().toLowerCase();
2914
- if (sec) {
2915
- const conf = FILING_SECTION_PATTERNS[sec];
2916
- if (!conf) {
2917
- sectionFound = `unknown_section:${sec}`;
2918
- } else {
2919
- const idxs: number[] = [];
2920
- for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
2921
- const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
2922
- if (chosen >= 0) {
2923
- // Back up a little so the section heading itself is included, not clipped mid-phrase.
2924
- fullText = fullText.slice(Math.max(0, chosen - 120));
2925
- sectionApplied = sec;
2926
- sectionFound = true;
2927
- } else {
2928
- sectionFound = false;
2929
- }
2930
- }
2931
- }
3789
+ const sliced = sliceFilingSection(fullText, section);
3790
+ fullText = sliced.text;
3791
+ const sectionApplied = sliced.applied;
3792
+ const sectionFound = sliced.found;
2932
3793
 
2933
3794
  // `search` short-circuits the normal offset/max_chars windowing: it scans
2934
3795
  // the whole (possibly section-sliced) document and returns matching
2935
3796
  // passages with their own offsets, rather than one page of raw text the
2936
3797
  // caller has to eyeball for the fact they wanted (fleet #2446).
2937
3798
  //
2938
- // fleet #2450: a question can ask for TWO facts from the SAME filing
2939
- // ("revenue AND test volume") and ask_pipeworx's router picks ONE tool call
2940
- // for a single-entity question — so a single `search` phrase per call meant
2941
- // the second fact was structurally unreachable. `search` now accepts
2942
- // multiple phrases separated by `|` (same convention as `form_type`'s
2943
- // "10-K|10-Q" set) and reports each phrase's passages independently under
2944
- // `searches`, keyed by `phrase`. A single phrase (no `|`) keeps the original
2945
- // singular `search` shape unchanged.
2946
- const searchPhrases = [...new Set(
2947
- (searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean),
2948
- )].slice(0, 5);
3799
+ // (`searchPhrases` is parsed near the top — the auto-selection walk needs it.)
2949
3800
  if (searchPhrases.length > 0) {
2950
3801
  const buildOne = async (term: string) => {
2951
3802
  const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
@@ -2989,17 +3840,56 @@ async function filingText(
2989
3840
 
2990
3841
  const results = await Promise.all(searchPhrases.map(buildOne));
2991
3842
 
3843
+ // ── HOW MUCH OF THE DOCUMENT THIS RESPONSE ACTUALLY CARRIES (fleet #3316) ──
3844
+ //
3845
+ // A `search` returns matched passages INSTEAD of the text, and before this
3846
+ // every size field in the envelope described the document rather than the
3847
+ // answer: `total_chars: 9859` for a response holding 3,080 chars of it.
3848
+ // The one field that looked like a loss signal, `search.truncated`, is
3849
+ // about the SEARCH — false means "every distinct match location is here" —
3850
+ // so a reader checking for loss found a field saying there was none.
3851
+ //
3852
+ // That is not merely untidy. ask_pipeworx_grounded sizes its own
3853
+ // truncation by what it RECEIVES, so a narrowed payload fit its budget
3854
+ // whole and was reported to the extraction model as complete: measured
3855
+ // live 2026-10-18, 5 of 8 tries of "what credit facilities did Tesla enter
3856
+ // into" answered with a WRONG total at confidence 0.4-0.6 and no refusal,
3857
+ // off exactly this response shape.
3858
+ //
3859
+ // `returned_chars` / `truncated` therefore mean the same thing here as in
3860
+ // the paged branch below — how much came back, and whether that is all of
3861
+ // it. `next_offset` stays ABSENT: there is no page forward from a set of
3862
+ // passages, and the way to read more is a follow-up call at a passage's
3863
+ // own `offset`. Absent is a different fact from null and says so.
3864
+ const returnedChars = results.reduce(
3865
+ (n, r) => n + r.passages.reduce((m, psg) => m + psg.text.length, 0),
3866
+ 0,
3867
+ );
3868
+ const searchTruncated = returnedChars < documentChars;
3869
+
2992
3870
  return {
2993
3871
  accession: acc.dashed,
2994
3872
  cik: cikNoZeros,
2995
3873
  company: sub.name ?? null,
2996
3874
  form: formType,
2997
3875
  filed: filingDate,
3876
+ // Whether the caller NAMED this filing or we picked it (fleet #3319).
3877
+ // Present on every response so its absence is never the signal.
3878
+ accession_source: autoSelected ? ('auto_selected' as const) : ('caller' as const),
3879
+ ...(autoSelected ? { auto_selected: autoSelected } : {}),
2998
3880
  document: primaryDocument,
2999
3881
  document_url: docUrl,
3000
3882
  section: sectionApplied,
3001
3883
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
3002
3884
  total_chars: fullText.length,
3885
+ ...(fullText.length !== documentChars ? { document_chars: documentChars } : {}),
3886
+ returned_chars: returnedChars,
3887
+ truncated: searchTruncated,
3888
+ ...(searchTruncated
3889
+ ? {
3890
+ truncated_note: `These are matched passages, not the document: ${returnedChars} of ${documentChars} characters. Anything the phrase(s) did not match is NOT in this response — do not read a figure's absence here as absence from the filing. Call again without \`search\` (or with a passage's \`offset\`) to read the surrounding text.`,
3891
+ }
3892
+ : {}),
3003
3893
  ...(searchPhrases.length === 1
3004
3894
  ? { search: results[0] }
3005
3895
  : {
@@ -3028,11 +3918,16 @@ async function filingText(
3028
3918
  company: sub.name ?? null,
3029
3919
  form: formType,
3030
3920
  filed: filingDate,
3921
+ accession_source: autoSelected ? ('auto_selected' as const) : ('caller' as const),
3922
+ ...(autoSelected ? { auto_selected: autoSelected } : {}),
3031
3923
  document: primaryDocument,
3032
3924
  document_url: docUrl,
3033
3925
  section: sectionApplied,
3034
3926
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
3035
3927
  total_chars: total,
3928
+ // A section slice reframed `total_chars` to the slice (fleet #3316): say so,
3929
+ // or a 2,000-char section of a 400,000-char 10-K reads as a whole document.
3930
+ ...(total !== documentChars ? { document_chars: documentChars } : {}),
3036
3931
  offset: off,
3037
3932
  returned_chars: slice.length,
3038
3933
  truncated,
@@ -3049,6 +3944,219 @@ async function filingText(
3049
3944
  };
3050
3945
  }
3051
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
+
3052
4160
  // data.sec.gov answers a bare 404 both when no filer has the CIK at all and when
3053
4161
  // the filer exists but has never submitted XBRL financial data (investment funds,
3054
4162
  // trusts, individuals, most pre-2011 foreign private issuers). "SEC EDGAR company
@@ -3666,6 +4774,12 @@ async function companyConcept(
3666
4774
  accession: entry.accn as string | undefined,
3667
4775
  unit,
3668
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,
3669
4783
  note: undefined as string | undefined,
3670
4784
  total_liquidity: undefined as LiquidityBreakdown | undefined,
3671
4785
  }))
@@ -3716,6 +4830,8 @@ async function companyConcept(
3716
4830
  accession: undefined, // arithmetic over FY + Q1-Q3, not a fact one filing states
3717
4831
  unit: fyRow.unit,
3718
4832
  derived: true,
4833
+ duration_days: undefined, // no period_start to compute a span from
4834
+ ytd: false,
3719
4835
  note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
3720
4836
  total_liquidity: undefined,
3721
4837
  });
@@ -3781,6 +4897,8 @@ async function companyConcept(
3781
4897
  accession: undefined, // YTD(Q2) minus Q1 — spans two filings
3782
4898
  unit: q2ytd.unit,
3783
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
3784
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.`,
3785
4903
  total_liquidity: undefined,
3786
4904
  });
@@ -3801,6 +4919,8 @@ async function companyConcept(
3801
4919
  accession: undefined, // YTD(Q3) minus YTD(Q2) — spans two filings
3802
4920
  unit: q3ytd.unit,
3803
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
3804
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.`,
3805
4925
  total_liquidity: undefined,
3806
4926
  });
@@ -3811,6 +4931,33 @@ async function companyConcept(
3811
4931
  entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
3812
4932
  }
3813
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
+
3814
4961
  // PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
3815
4962
  // names of two fields on every row below, and callers asking a one-period
3816
4963
  // question ("net income fiscal 2024") send them as ARGUMENTS — measured on
@@ -3959,7 +5106,20 @@ async function companyConcept(
3959
5106
  }
3960
5107
  }
3961
5108
 
3962
- const latestFact = rows.find((e) => !e.derived) ?? null;
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;
3963
5123
  let coverageBound: string | undefined;
3964
5124
  if (latestFact?.filed) {
3965
5125
  const daysSinceFiled = (Date.now() - Date.parse(latestFact.filed)) / 86400000;
@@ -5187,6 +6347,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5187
6347
  args.start_date as string | undefined,
5188
6348
  args.end_date as string | undefined,
5189
6349
  args.limit as number | undefined,
6350
+ (args.entity ?? args.ticker_or_cik ?? args.cik ?? args.ticker) as string | undefined,
5190
6351
  );
5191
6352
  // Every tool below takes the same thing — a company — and this pack spells
5192
6353
  // it three ways across its own tools (`ticker_or_cik`, `cik`, `ticker`), so
@@ -5201,6 +6362,9 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5201
6362
  companyArg(args),
5202
6363
  args.form_type as string | undefined,
5203
6364
  args.limit as number | undefined,
6365
+ args.accession as string | undefined,
6366
+ args.include_primary_text as boolean | undefined,
6367
+ (args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
5204
6368
  );
5205
6369
  case 'edgar_company_facts':
5206
6370
  return companyFacts(companyArg(args));
@@ -5245,6 +6409,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5245
6409
  (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
5246
6410
  args.include_primary_text as boolean | undefined,
5247
6411
  args.form_type as string | undefined,
6412
+ (args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
5248
6413
  );
5249
6414
  case 'edgar_filing_text':
5250
6415
  return filingText(
@@ -5255,6 +6420,18 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5255
6420
  args.offset as number | undefined,
5256
6421
  args.form_type as string | undefined,
5257
6422
  (args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
6423
+ (args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
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,
5258
6435
  );
5259
6436
  case 'edgar_ticker_to_cik':
5260
6437
  return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);