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