@pipeworx/mcp-edgar 0.3.13 → 0.3.15
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 +2 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +341 -20
- package/src/server.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The Securities and Exchange Commission's filings database. Every public company in the US files here, every filing is timestamped and immutable, and every disclosure (revenue, debt, risk factors, executive compensation, M&A activity, insider trading) is structured for machine retrieval. Free and authoritative.
|
|
4
4
|
|
|
5
|
-
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1879+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
6
6
|
|
|
7
7
|
## Why this matters for AI agents
|
|
8
8
|
|
|
@@ -111,7 +111,7 @@ directly, instead of just this one's:
|
|
|
111
111
|
}
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
Both URLs reach the same gateway and the same
|
|
114
|
+
Both URLs reach the same gateway and the same 1879+ data sources. The
|
|
115
115
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
116
116
|
reaches all of them from either one.
|
|
117
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-edgar",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
27
|
},
|
|
28
28
|
"pipeworx": {
|
|
29
|
-
"sourceHash": "v1-
|
|
30
|
-
"sourceCommit": "
|
|
29
|
+
"sourceHash": "v1-786eb4a2338149a713e1e23d15c1173ad5ffa949b94abc23a72ee39bf75db46e",
|
|
30
|
+
"sourceCommit": "445bacc5596a845c12d6945fbfcdf330ef60dcc4"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/edgar",
|
|
4
4
|
"title": "Edgar",
|
|
5
5
|
"description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.15",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/edgar",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-edgar",
|
package/src/index.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -1084,6 +1100,13 @@ interface SecEntity {
|
|
|
1084
1100
|
* `matched_by: 'company_name'` alone cannot (fleet #3100).
|
|
1085
1101
|
*/
|
|
1086
1102
|
matched_kind?: MatchKind;
|
|
1103
|
+
/**
|
|
1104
|
+
* Other companies whose names also matched — distinct companies, deduped
|
|
1105
|
+
* by CIK, excluding this issuer's own share classes (never the winner's
|
|
1106
|
+
* own ticker/CIK under a different symbol). Consumers read this to tell a
|
|
1107
|
+
* genuinely CONTESTED name match from a clean one. See
|
|
1108
|
+
* `distinctCompanyAlternatives` below for how it's built (fleet #3621).
|
|
1109
|
+
*/
|
|
1087
1110
|
alternatives?: { ticker: string; company_name: string; cik: string; kind: MatchKind }[];
|
|
1088
1111
|
}
|
|
1089
1112
|
|
|
@@ -1121,12 +1144,7 @@ async function resolveSecEntity(
|
|
|
1121
1144
|
if (ranked.length) {
|
|
1122
1145
|
const best = toEntity(ranked[0].item, 'company_name');
|
|
1123
1146
|
best.matched_kind = ranked[0].kind;
|
|
1124
|
-
const alts = ranked
|
|
1125
|
-
ticker: m.item.ticker,
|
|
1126
|
-
company_name: m.item.title,
|
|
1127
|
-
cik: String(m.item.cik_str),
|
|
1128
|
-
kind: m.kind,
|
|
1129
|
-
}));
|
|
1147
|
+
const alts = distinctCompanyAlternatives(ranked);
|
|
1130
1148
|
if (alts.length) best.alternatives = alts;
|
|
1131
1149
|
return best;
|
|
1132
1150
|
}
|
|
@@ -1134,6 +1152,33 @@ async function resolveSecEntity(
|
|
|
1134
1152
|
throw new Error(`No SEC company matches "${query}". Pass a US-listed ticker ("AAPL") or the exact listed-company name ("Apple Inc."). If this is a clinical-trial sponsor, an operating subsidiary (e.g. "Merck Sharp & Dohme" → Merck & Co), or a foreign/private entity, call sponsor_to_filer({sponsor}) instead — it resolves subsidiaries to the listed parent and honestly reports when no US-listed filer exists.`);
|
|
1135
1153
|
}
|
|
1136
1154
|
|
|
1155
|
+
/** `alternatives` lists at most this many OTHER companies; company-spine.test.ts pins it to the real function. */
|
|
1156
|
+
const ALTERNATIVES_CAP = 3;
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* The runners-up for a name match, as DISTINCT COMPANIES (fleet #3621).
|
|
1160
|
+
*
|
|
1161
|
+
* company_tickers.json lists one row per ticker, so an issuer with preferred
|
|
1162
|
+
* shares appears several times under one CIK (Ford: F, F-PB, F-PC, F-PD; AT&T:
|
|
1163
|
+
* T, TBB, T-PA, T-PC). Slicing the ranked rows left those rows to fill every
|
|
1164
|
+
* alternatives slot, hiding any real rival ranked behind them, and made a company
|
|
1165
|
+
* its own "alternative". So: drop every row that is the winner's own CIK, keep
|
|
1166
|
+
* only the best-ranked row of each other CIK, THEN cap. The winner's other
|
|
1167
|
+
* tickers are not lost information: they are the same company, and `ticker` /
|
|
1168
|
+
* `cik` already identify it. `ranked` is already best-first, so first-seen wins.
|
|
1169
|
+
*/
|
|
1170
|
+
function distinctCompanyAlternatives(ranked: RankedMatch<SecTickerRow$shared>[]): NonNullable<SecEntity['alternatives']> {
|
|
1171
|
+
const seen = new Set<number>([ranked[0].item.cik_str]);
|
|
1172
|
+
const out: NonNullable<SecEntity['alternatives']> = [];
|
|
1173
|
+
for (const m of ranked.slice(1)) {
|
|
1174
|
+
if (seen.has(m.item.cik_str)) continue;
|
|
1175
|
+
seen.add(m.item.cik_str);
|
|
1176
|
+
out.push({ ticker: m.item.ticker, company_name: m.item.title, cik: String(m.item.cik_str), kind: m.kind });
|
|
1177
|
+
if (out.length >= ALTERNATIVES_CAP) break;
|
|
1178
|
+
}
|
|
1179
|
+
return out;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1137
1182
|
function toEntity(r: SecTickerRow$shared, matched_by: 'ticker' | 'company_name'): SecEntity {
|
|
1138
1183
|
return {
|
|
1139
1184
|
ticker: r.ticker,
|
|
@@ -1781,7 +1826,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1781
1826
|
{
|
|
1782
1827
|
name: 'edgar_ticker_to_cik',
|
|
1783
1828
|
description:
|
|
1784
|
-
'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives`
|
|
1829
|
+
'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives` — up to 3 OTHER companies whose names also matched, one per distinct CIK, with the winner\'s OWN share classes/tickers excluded (never listed as its own alternative). Use `alternatives` to tell a genuinely CONTESTED name match (a real rival company also matched) from a clean one (nothing else did). Cheap, no rate limit concerns. Most other tools also accept tickers/names directly and call this internally — only use it explicitly when you want the CIK as data. The response carries a `next` hint: the usual NEXT step after resolving is edgar_company_snapshot({ticker_or_cik}), which returns the recent filings list AND the headline XBRL financials in one call — do not chain edgar_company_filings then edgar_company_concept by hand to get that.',
|
|
1785
1830
|
summary: 'The SEC CIK number for a stock ticker or company name, from EDGAR\'s company list.',
|
|
1786
1831
|
inputSchema: {
|
|
1787
1832
|
type: 'object' as const,
|
|
@@ -1967,7 +2012,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1967
2012
|
{
|
|
1968
2013
|
name: 'edgar_filing_text',
|
|
1969
2014
|
description:
|
|
1970
|
-
'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. A `search` response carries ONLY the matched passages, so it reports `returned_chars` against `total_chars` and sets `truncated:true` whenever those differ — read a figure\'s absence from it as "the phrase did not match there", never as "the filing does not say it", and re-read without `search` (or at a passage `offset`) to confirm. (Do not read `search.truncated` as a completeness signal: it is about the SEARCH — false means every distinct match location was returned — and is routinely false on a response holding a third of the document.) **For a question asking two facts from the same filing** ("revenue and test volume in the latest 10-Q"), pass `search` as `|`-separated phrases (e.g. "revenue|tests processed") to get both in this one call under `searches[]` — and when a phrase names a financial total (revenue, net income, EPS, ...), the matching XBRL figure for this exact accession comes back under `xbrl` even if that total never appears as an absolute number in the prose (common: filings often state only the growth delta/percentage in text). Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events); `search` runs within that slice when both are given. Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled, when `search` is not used: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page (and how far `search` can scan) and can make a late section or search term come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q", "how many tests did $TICKER process this quarter". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
|
|
2015
|
+
'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.',
|
|
1971
2016
|
summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
|
|
1972
2017
|
inputSchema: {
|
|
1973
2018
|
type: 'object' as const,
|
|
@@ -1995,8 +2040,8 @@ const tools: McpToolExport['tools'] = [
|
|
|
1995
2040
|
},
|
|
1996
2041
|
section: {
|
|
1997
2042
|
type: 'string',
|
|
1998
|
-
enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events'],
|
|
1999
|
-
description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false).',
|
|
2043
|
+
enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events', 'risk_factors'],
|
|
2044
|
+
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.',
|
|
2000
2045
|
},
|
|
2001
2046
|
max_chars: {
|
|
2002
2047
|
type: 'number',
|
|
@@ -2033,7 +2078,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
2033
2078
|
{
|
|
2034
2079
|
name: 'edgar_filing_exhibit_text',
|
|
2035
2080
|
description:
|
|
2036
|
-
'Full text of an EXHIBIT attached to a filing — not the filing\'s own primary document (use edgar_filing_text for that). This is the only way to read an 8-K\'s Exhibit 99.1/99.2 press release — a quarterly production-and-delivery report, an earnings release, a KPI update — because the 8-K body itself routinely just says "see the attached exhibit" and carries none of the numbers; the figures live in the exhibit document. Pass the filer\'s `ticker` or `cik` plus either an `accession` (from edgar_company_filings) or a `form_type` to auto-resolve the latest matching filing (defaults to "8-K" when both accession and form_type are omitted, since that is overwhelmingly when an exhibit holds the operative content). Set `exhibit` to pick a specific attachment by its SEC-assigned type — "99.1", "99", "10.1" — read off the filing\'s own document index (case-insensitive, the "EX-" prefix optional); omit it to default to the first EX-99.* exhibit, which is the convention Item 2.02 (earnings) and Item 7.01 (Reg FD) 8-Ks use for a press release. When the requested exhibit is not found (or none exists), the response lists every exhibit actually in the filing under `available_exhibits` rather than failing silently. **For a specific number inside the exhibit, pass `search`** (a short phrase likely to appear verbatim, e.g. "vehicle deliveries", "Megapack", "energy storage") the same way edgar_filing_text does — it scans the whole exhibit and returns matching passages ranked by a nearby figure, with `|`-separated phrases for more than one fact in one call; a zero-match search is a real answer (try different wording) and falls back to a word-level match when the exact phrase misses. Without `search`, the exhibit text is returned paged (`max_chars`/`offset`, same convention as edgar_filing_text) since a press release can run several thousand words. Use for "Tesla\'s Q3 2026 vehicle deliveries and production", "what did $TICKER\'s earnings press release say about [KPI]", "read the exhibit attached to $TICKER\'s latest 8-K". Source: U.S. Securities and Exchange Commission, EDGAR filing exhibits (the same primary-source filings edgar_filing_text and edgar_filing_documents read).',
|
|
2081
|
+
'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).',
|
|
2037
2082
|
summary: 'Full text of an exhibit (e.g. a press release) attached to an SEC filing, such as an 8-K\'s EX-99.1.',
|
|
2038
2083
|
inputSchema: {
|
|
2039
2084
|
type: 'object' as const,
|
|
@@ -3133,6 +3178,30 @@ async function fetchTextBounded(url: string, headers: Record<string, string>, ma
|
|
|
3133
3178
|
// stated limit, not a silent gap — rather than never answering at all.
|
|
3134
3179
|
const FILING_DOC_MAX_BYTES = 4_000_000;
|
|
3135
3180
|
|
|
3181
|
+
/**
|
|
3182
|
+
* What a search result MEANS given how much of the document was read (fleet #3562).
|
|
3183
|
+
* A zero-match search over a document whose raw HTML hit the read cap
|
|
3184
|
+
* (`raw_truncated`) only says the phrase is absent from the portion scanned.
|
|
3185
|
+
* Before this, such a zero carried the same `status`-less empty array and a note
|
|
3186
|
+
* calling it "a real answer", so a model read "no going-concern language" off a
|
|
3187
|
+
* scan that never reached the notes. `status` is the distinct, checkable field:
|
|
3188
|
+
* found | not_found | not_found_within_scanned_portion (zero over a capped read)
|
|
3189
|
+
* | word_lead_only | word_lead_only_within_scanned_portion.
|
|
3190
|
+
*/
|
|
3191
|
+
function searchStatus(
|
|
3192
|
+
found: boolean,
|
|
3193
|
+
wordMode: boolean,
|
|
3194
|
+
rawTruncated: boolean,
|
|
3195
|
+
): { status: string; scan_note?: string } {
|
|
3196
|
+
if (found) return { status: 'found' };
|
|
3197
|
+
if (!rawTruncated) return { status: wordMode ? 'word_lead_only' : 'not_found' };
|
|
3198
|
+
return {
|
|
3199
|
+
status: wordMode ? 'word_lead_only_within_scanned_portion' : 'not_found_within_scanned_portion',
|
|
3200
|
+
scan_note:
|
|
3201
|
+
'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.',
|
|
3202
|
+
};
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3136
3205
|
// Strip an HTML document to readable plaintext. Workers have no DOMParser;
|
|
3137
3206
|
// drop script/style blocks, remove tags, decode entities, collapse whitespace.
|
|
3138
3207
|
function htmlToText(html: string): string {
|
|
@@ -3258,6 +3327,117 @@ export function findFormIndices(
|
|
|
3258
3327
|
const AUTO_SELECT_SCAN = 4;
|
|
3259
3328
|
const AUTO_SELECT_LIST = 8;
|
|
3260
3329
|
|
|
3330
|
+
/**
|
|
3331
|
+
* Item 1A (Risk Factors) of a 10-K, bounded at BOTH ends. Unlike the other
|
|
3332
|
+
* sections (anchor to end-of-document), a risk-factors slice must stop at the
|
|
3333
|
+
* next Item heading, or "Item 1A text" would silently be 1A + the rest of the
|
|
3334
|
+
* filing.
|
|
3335
|
+
*
|
|
3336
|
+
* The failure this is built around is returning a plausible-looking wrong
|
|
3337
|
+
* chunk: the table-of-contents line ("Item 1A. Risk Factors 5 Item 1B ..."),
|
|
3338
|
+
* a cross-reference ("see Item 1A. Risk Factors"), a running page header
|
|
3339
|
+
* ("Item 1A. Risk Factors (Continued)"), or a section that stops early at one
|
|
3340
|
+
* of those. So: collect every heading-shaped start, drop the ones that read
|
|
3341
|
+
* as references or running headers, pair each start with the next Item
|
|
3342
|
+
* 1B/1C/2 heading, and keep the start whose slice is LONGEST. A TOC entry's
|
|
3343
|
+
* slice runs to the next TOC line (tens of characters); the real section is
|
|
3344
|
+
* thousands. Pure text, deterministic, no model.
|
|
3345
|
+
*/
|
|
3346
|
+
// Some filers' HTML-to-text leaves ONE stray space inside a heading word
|
|
3347
|
+
// ("RIS K FACTORS" Microsoft, "R isk Factors" Oracle, "Item 1 A" Prdo): the
|
|
3348
|
+
// literal heading then never matches and only the contents line is found.
|
|
3349
|
+
// tol(word) accepts the word whole or with a single space at any inner
|
|
3350
|
+
// position. One space per word, never a wildcard run, so it cannot widen into
|
|
3351
|
+
// unrelated text; the RF_MIN_PLAUSIBLE_CHARS floor below stays the backstop.
|
|
3352
|
+
function tol(word: string): string {
|
|
3353
|
+
const alts = [word];
|
|
3354
|
+
for (let i = 1; i < word.length; i++) alts.push(`${word.slice(0, i)}\\s${word.slice(i)}`);
|
|
3355
|
+
return `(?:${alts.join('|')})`;
|
|
3356
|
+
}
|
|
3357
|
+
const RF_PUNCT = '[.:\\-\\u2013\\u2014]?';
|
|
3358
|
+
const RF_START = new RegExp(`\\b${tol('item')}\\s*${tol('1a')}\\s*${RF_PUNCT}\\s*${tol('risk')}\\s+${tol('factors')}`, 'gi');
|
|
3359
|
+
const RF_END = new RegExp(
|
|
3360
|
+
`\\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')})`,
|
|
3361
|
+
'gi',
|
|
3362
|
+
);
|
|
3363
|
+
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;
|
|
3364
|
+
|
|
3365
|
+
function isHeadingShaped(text: string, idx: number, len: number): boolean {
|
|
3366
|
+
const before = text.slice(Math.max(0, idx - 24), idx).replace(/[\s\u00a0]+$/, '');
|
|
3367
|
+
if (RF_REF_WORDS.test(before)) return false;
|
|
3368
|
+
const after = text.slice(idx + len, idx + len + 24).replace(/^[\s\u00a0.]+/, '');
|
|
3369
|
+
if (/^[”"’')]/.test(after)) return false; // ...“Item 1A. Risk Factors” cross-reference
|
|
3370
|
+
if (/^\(?\s*continued\b/i.test(after)) return false; // running page header
|
|
3371
|
+
if (/^(?:of|in|above|below|under|for|and|section|herein|generally)\b/.test(after)) return false;
|
|
3372
|
+
return true;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
// Below this a slice is not a risk-factors section, it is an index/cross-reference
|
|
3376
|
+
// line that won the longest-slice rule unopposed (Intel FY2025: 32 chars, a
|
|
3377
|
+
// "Pages 37 - 48" index entry, one heading candidate). Chosen from the
|
|
3378
|
+
// measured gap: the failure is tens of characters and the smallest genuine
|
|
3379
|
+
// section measured is 31,270 (Intrusion FY2025); 1,000 sits ~30x above the
|
|
3380
|
+
// failure and ~30x below the smallest true section. The smaller-reporting-
|
|
3381
|
+
// company "not required" statement is exempt: it is legitimately tiny and has
|
|
3382
|
+
// its own not_required flag.
|
|
3383
|
+
export const RF_MIN_PLAUSIBLE_CHARS = 1000;
|
|
3384
|
+
|
|
3385
|
+
export function extractRiskFactors(
|
|
3386
|
+
fullText: string,
|
|
3387
|
+
): { text: string; implausible: boolean; diagnostics: Record<string, unknown> } | null {
|
|
3388
|
+
const starts: number[] = [];
|
|
3389
|
+
let skippedRefs = 0;
|
|
3390
|
+
for (const m of fullText.matchAll(RF_START)) {
|
|
3391
|
+
if (m.index === undefined) continue;
|
|
3392
|
+
if (isHeadingShaped(fullText, m.index, m[0].length)) starts.push(m.index);
|
|
3393
|
+
else skippedRefs++;
|
|
3394
|
+
}
|
|
3395
|
+
if (starts.length === 0) return null;
|
|
3396
|
+
const ends: Array<{ idx: number; label: string }> = [];
|
|
3397
|
+
for (const m of fullText.matchAll(RF_END)) {
|
|
3398
|
+
if (m.index === undefined) continue;
|
|
3399
|
+
if (!isHeadingShaped(fullText, m.index, m[0].length)) continue;
|
|
3400
|
+
const t = m[0].toLowerCase().replace(/\s+/g, '');
|
|
3401
|
+
ends.push({ idx: m.index, label: t.includes('1b') ? 'Item 1B' : t.includes('1c') ? 'Item 1C' : 'Item 2' });
|
|
3402
|
+
}
|
|
3403
|
+
let best: { start: number; end: number; label: string | null } | null = null;
|
|
3404
|
+
for (const st of starts) {
|
|
3405
|
+
const e = ends.find((x) => x.idx > st);
|
|
3406
|
+
const end = e ? e.idx : fullText.length;
|
|
3407
|
+
if (!best || end - st > best.end - best.start) best = { start: st, end, label: e ? e.label : null };
|
|
3408
|
+
}
|
|
3409
|
+
const b = best!;
|
|
3410
|
+
// Drop the page number the stripped HTML leaves glued to the section's last line ("... retention. 32").
|
|
3411
|
+
const text = fullText.slice(b.start, b.end).trim().replace(/\s+\d{1,3}$/, '');
|
|
3412
|
+
// A real Item 1A is thousands of characters; the exceptions are filers who
|
|
3413
|
+
// say "not required for smaller reporting companies" — report that as what
|
|
3414
|
+
// it is rather than a bare short string that reads like a failed parse.
|
|
3415
|
+
const notRequired = text.length < 2500 && /not\s+required|smaller\s+reporting\s+compan/i.test(text);
|
|
3416
|
+
const implausible = !notRequired && text.length < RF_MIN_PLAUSIBLE_CHARS;
|
|
3417
|
+
return {
|
|
3418
|
+
text,
|
|
3419
|
+
implausible,
|
|
3420
|
+
diagnostics: {
|
|
3421
|
+
start_offset: b.start,
|
|
3422
|
+
end_offset: b.end,
|
|
3423
|
+
section_chars: text.length,
|
|
3424
|
+
ended_at: b.label ?? 'end_of_document',
|
|
3425
|
+
heading_candidates: starts.length,
|
|
3426
|
+
references_skipped: skippedRefs,
|
|
3427
|
+
...(b.label === null
|
|
3428
|
+
? { 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.' }
|
|
3429
|
+
: {}),
|
|
3430
|
+
...(notRequired
|
|
3431
|
+
? { not_required: true, note: 'This filer states Item 1A is not required (smaller reporting company); the text is that statement, not omitted risk factors.' }
|
|
3432
|
+
: implausible
|
|
3433
|
+
? { 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.` }
|
|
3434
|
+
: text.length < 2500
|
|
3435
|
+
? { 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).` }
|
|
3436
|
+
: {}),
|
|
3437
|
+
},
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3261
3441
|
/**
|
|
3262
3442
|
* The `section` slice of a filing's text, as a function rather than inline, so
|
|
3263
3443
|
* the auto-selection walk below can score a candidate against the SAME text
|
|
@@ -3267,9 +3447,22 @@ const AUTO_SELECT_LIST = 8;
|
|
|
3267
3447
|
export function sliceFilingSection(
|
|
3268
3448
|
fullText: string,
|
|
3269
3449
|
section: string | undefined,
|
|
3270
|
-
): { text: string; applied: string | null; found: string | boolean | null } {
|
|
3271
|
-
const sec = (section ?? '').trim().toLowerCase();
|
|
3450
|
+
): { text: string; applied: string | null; found: string | boolean | null; diagnostics?: Record<string, unknown> } {
|
|
3451
|
+
const sec = (section ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
3272
3452
|
if (!sec) return { text: fullText, applied: null, found: null };
|
|
3453
|
+
if (sec === 'risk_factors' || sec === 'item_1a') {
|
|
3454
|
+
const r = extractRiskFactors(fullText);
|
|
3455
|
+
if (!r) return { text: fullText, applied: null, found: false };
|
|
3456
|
+
if (r.implausible) {
|
|
3457
|
+
return {
|
|
3458
|
+
text: fullText,
|
|
3459
|
+
applied: null,
|
|
3460
|
+
found: false,
|
|
3461
|
+
diagnostics: { ...r.diagnostics, rejected_slice_chars: r.text.length, rejected_slice_text: r.text.slice(0, 120) },
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
return { text: r.text, applied: 'risk_factors', found: true, diagnostics: r.diagnostics };
|
|
3465
|
+
}
|
|
3273
3466
|
const conf = FILING_SECTION_PATTERNS[sec];
|
|
3274
3467
|
if (!conf) return { text: fullText, applied: null, found: `unknown_section:${sec}` };
|
|
3275
3468
|
const idxs: number[] = [];
|
|
@@ -3592,6 +3785,82 @@ export function findWordPassages(
|
|
|
3592
3785
|
};
|
|
3593
3786
|
}
|
|
3594
3787
|
|
|
3788
|
+
// --- holding-company reorganisations (fleet #3563) -------------------------
|
|
3789
|
+
// SEC's ticker map gives each ticker ONE cik. When a company reorganises under
|
|
3790
|
+
// a new holding company the ticker moves to the new registrant (XOM ->
|
|
3791
|
+
// ExxonMobil Holdings Corp, cik 2115436, proxy material only) while the 10-K
|
|
3792
|
+
// history stays under the old one (EXXON MOBIL CORP, cik 34088). By ticker the
|
|
3793
|
+
// caller then got "no 10-K found" about one of the largest filers in the
|
|
3794
|
+
// market. A sibling is accepted ONLY when its name, stripped of corporate
|
|
3795
|
+
// boilerplate and spacing, EQUALS the resolved registrant's AND it really holds
|
|
3796
|
+
// the requested form: "ExxonMobil Holdings Corp" and "EXXON MOBIL CORP" both
|
|
3797
|
+
// reduce to "EXXONMOBIL"; "ExxonMobil Renewables LLC" does not.
|
|
3798
|
+
const REGISTRANT_BOILERPLATE = new Set([
|
|
3799
|
+
'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED', 'LLC', 'LP', 'PLC',
|
|
3800
|
+
'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA', 'GROUP', 'HOLDINGS', 'HOLDING', 'AND', 'OF',
|
|
3801
|
+
'US', 'USA',
|
|
3802
|
+
]);
|
|
3803
|
+
// Words like INTERNATIONAL / GLOBAL / NEW / TRUST / FUND are deliberately NOT
|
|
3804
|
+
// stripped: "Eastern International Ltd" and "The Eastern Company" are different
|
|
3805
|
+
// registrants (measured in the fleet #3563 survey), and a wrong redirect is worse
|
|
3806
|
+
// than the not-found it replaces. SIC is compared as a second guard.
|
|
3807
|
+
|
|
3808
|
+
const SIBLING_MAX_AGE_DAYS = 800;
|
|
3809
|
+
|
|
3810
|
+
export function registrantCore(name: string): string {
|
|
3811
|
+
return name
|
|
3812
|
+
.replace(/\([^)]*\)/g, ' ')
|
|
3813
|
+
.toUpperCase()
|
|
3814
|
+
.replace(/[^A-Z0-9 ]/g, ' ')
|
|
3815
|
+
.split(/\s+/)
|
|
3816
|
+
.filter((t) => t && !REGISTRANT_BOILERPLATE.has(t))
|
|
3817
|
+
.join('');
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
async function findSiblingFilers(
|
|
3821
|
+
ownName: string,
|
|
3822
|
+
ownCik: string,
|
|
3823
|
+
wantFormTypeArg: string,
|
|
3824
|
+
ownSic?: string,
|
|
3825
|
+
): Promise<{ cik: string; name: string; accession: string; filed: string | null }[]> {
|
|
3826
|
+
const core = registrantCore(ownName);
|
|
3827
|
+
if (core.length < 5) return [];
|
|
3828
|
+
const res = await pwFetch(
|
|
3829
|
+
`https://efts.sec.gov/LATEST/search-index?keysTyped=${encodeURIComponent(core.slice(0, 5).toLowerCase())}`,
|
|
3830
|
+
{ headers: SEC_HEADERS },
|
|
3831
|
+
).catch(() => null);
|
|
3832
|
+
if (!res || !res.ok) return [];
|
|
3833
|
+
const data = (await res.json().catch(() => null)) as
|
|
3834
|
+
| { hits?: { hits?: { _id: string; _source?: { entity?: string } }[] } }
|
|
3835
|
+
| null;
|
|
3836
|
+
const hits = (data?.hits?.hits ?? [])
|
|
3837
|
+
.filter((h) => /^\d+$/.test(h._id) && h._source?.entity)
|
|
3838
|
+
.map((h) => ({ cik: String(Number(h._id)), name: h._source!.entity! }))
|
|
3839
|
+
.filter((h) => h.cik !== ownCik && registrantCore(h.name) === core)
|
|
3840
|
+
.slice(0, 4);
|
|
3841
|
+
const found: { cik: string; name: string; accession: string; filed: string | null }[] = [];
|
|
3842
|
+
for (const h of hits) {
|
|
3843
|
+
const r = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(h.cik)}.json`, { headers: SEC_HEADERS }).catch(() => null);
|
|
3844
|
+
if (!r || !r.ok) continue;
|
|
3845
|
+
const sub = (await r.json().catch(() => null)) as
|
|
3846
|
+
| { sic?: string; filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } } }
|
|
3847
|
+
| null;
|
|
3848
|
+
const rec = sub?.filings?.recent;
|
|
3849
|
+
if (!rec) continue;
|
|
3850
|
+
if (ownSic && sub?.sic && sub.sic !== ownSic) continue; // same name, different industry: not the same company
|
|
3851
|
+
const idx = findFormIndices(rec.form ?? [], wantFormTypeArg, rec.filingDate ?? [], null, 1)[0];
|
|
3852
|
+
// A registrant that last filed this form years ago is a dead namesake
|
|
3853
|
+
// (measured: DMRC's same-named cik last filed a 10-K in 2008), not the
|
|
3854
|
+
// predecessor of a fresh reorganisation.
|
|
3855
|
+
const filed = rec.filingDate?.[idx ?? -1] ?? '';
|
|
3856
|
+
if (!filed || Date.parse(filed) < Date.now() - SIBLING_MAX_AGE_DAYS * 86_400_000) continue;
|
|
3857
|
+
if (idx !== undefined && rec.accessionNumber?.[idx]) {
|
|
3858
|
+
found.push({ cik: h.cik, name: h.name, accession: rec.accessionNumber[idx], filed: rec.filingDate?.[idx] ?? null });
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
return found;
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3595
3864
|
// Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
|
|
3596
3865
|
// out (rather than piggybacking edgar_filing_documents' include_primary_text,
|
|
3597
3866
|
// which caps at 40k and can't page) lets the register pull deep disclosures —
|
|
@@ -3605,7 +3874,8 @@ async function filingText(
|
|
|
3605
3874
|
wantFormTypeArg?: string,
|
|
3606
3875
|
searchTerm?: string,
|
|
3607
3876
|
filedArg?: string,
|
|
3608
|
-
|
|
3877
|
+
siblingOf?: string,
|
|
3878
|
+
): Promise<any> {
|
|
3609
3879
|
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
3610
3880
|
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"})).');
|
|
3611
3881
|
}
|
|
@@ -3623,6 +3893,7 @@ async function filingText(
|
|
|
3623
3893
|
if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) for ${tickerOrCik}.`);
|
|
3624
3894
|
const sub = (await subRes.json()) as {
|
|
3625
3895
|
name?: string;
|
|
3896
|
+
sic?: string;
|
|
3626
3897
|
filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[]; primaryDocument?: string[] } };
|
|
3627
3898
|
};
|
|
3628
3899
|
const r = sub.filings?.recent;
|
|
@@ -3714,6 +3985,26 @@ async function filingText(
|
|
|
3714
3985
|
}
|
|
3715
3986
|
const candidateIdx = findFormIndices(forms, wantFormTypeArg, filingDates, filedPrefix, AUTO_SELECT_LIST)
|
|
3716
3987
|
.filter((idx) => !!accs[idx] && !!normalizeAccession(accs[idx]));
|
|
3988
|
+
if (!candidateIdx.length && wantForm && !siblingOf && !isNumericCik(tickerOrCik) && sub.name) {
|
|
3989
|
+
// Ticker resolved to a registrant that has never filed this form. Look
|
|
3990
|
+
// for the sibling registrant that carries the history (holdco reorg).
|
|
3991
|
+
const sibs = await findSiblingFilers(sub.name, cikNoZeros, wantForm, sub.sic);
|
|
3992
|
+
if (sibs.length === 1) {
|
|
3993
|
+
const redirected = await filingText(undefined, sibs[0].cik, section, maxChars, offset, wantFormTypeArg, searchTerm, filedArg, cikNoZeros);
|
|
3994
|
+
return {
|
|
3995
|
+
...redirected,
|
|
3996
|
+
registrant_redirect: {
|
|
3997
|
+
asked_for: tickerOrCik,
|
|
3998
|
+
resolved_to: { cik: cikNoZeros, company: sub.name, note: `has no ${wantForm} on file` },
|
|
3999
|
+
read_instead: { cik: sibs[0].cik, company: sibs[0].name },
|
|
4000
|
+
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.`,
|
|
4001
|
+
},
|
|
4002
|
+
};
|
|
4003
|
+
}
|
|
4004
|
+
if (sibs.length > 1) {
|
|
4005
|
+
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.`);
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
3717
4008
|
if (!candidateIdx.length) {
|
|
3718
4009
|
const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
|
|
3719
4010
|
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.`);
|
|
@@ -3790,6 +4081,7 @@ async function filingText(
|
|
|
3790
4081
|
fullText = sliced.text;
|
|
3791
4082
|
const sectionApplied = sliced.applied;
|
|
3792
4083
|
const sectionFound = sliced.found;
|
|
4084
|
+
const sectionDiagnostics = sliced.diagnostics;
|
|
3793
4085
|
|
|
3794
4086
|
// `search` short-circuits the normal offset/max_chars windowing: it scans
|
|
3795
4087
|
// the whole (possibly section-sliced) document and returns matching
|
|
@@ -3835,7 +4127,13 @@ async function filingText(
|
|
|
3835
4127
|
? '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.'
|
|
3836
4128
|
: `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.`,
|
|
3837
4129
|
};
|
|
3838
|
-
|
|
4130
|
+
const st = searchStatus(totalMatches > 0, wordHit !== null, rawTruncated);
|
|
4131
|
+
const out = {
|
|
4132
|
+
...base,
|
|
4133
|
+
status: st.status,
|
|
4134
|
+
...(st.scan_note ? { note: st.scan_note + (wordHit ? ' ' + base.note : ''), scanned_chars: documentChars } : {}),
|
|
4135
|
+
};
|
|
4136
|
+
return xbrl ? { ...out, xbrl } : out;
|
|
3839
4137
|
};
|
|
3840
4138
|
|
|
3841
4139
|
const results = await Promise.all(searchPhrases.map(buildOne));
|
|
@@ -3881,6 +4179,7 @@ async function filingText(
|
|
|
3881
4179
|
document_url: docUrl,
|
|
3882
4180
|
section: sectionApplied,
|
|
3883
4181
|
...(sectionFound !== null ? { section_found: sectionFound } : {}),
|
|
4182
|
+
...(sectionDiagnostics ? { section_diagnostics: sectionDiagnostics } : {}),
|
|
3884
4183
|
total_chars: fullText.length,
|
|
3885
4184
|
...(fullText.length !== documentChars ? { document_chars: documentChars } : {}),
|
|
3886
4185
|
returned_chars: returnedChars,
|
|
@@ -3899,7 +4198,8 @@ async function filingText(
|
|
|
3899
4198
|
...(rawTruncated
|
|
3900
4199
|
? {
|
|
3901
4200
|
raw_truncated: true,
|
|
3902
|
-
|
|
4201
|
+
scanned_chars: documentChars,
|
|
4202
|
+
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".`,
|
|
3903
4203
|
}
|
|
3904
4204
|
: {}),
|
|
3905
4205
|
};
|
|
@@ -3924,6 +4224,7 @@ async function filingText(
|
|
|
3924
4224
|
document_url: docUrl,
|
|
3925
4225
|
section: sectionApplied,
|
|
3926
4226
|
...(sectionFound !== null ? { section_found: sectionFound } : {}),
|
|
4227
|
+
...(sectionDiagnostics ? { section_diagnostics: sectionDiagnostics } : {}),
|
|
3927
4228
|
total_chars: total,
|
|
3928
4229
|
// A section slice reframed `total_chars` to the slice (fleet #3316): say so,
|
|
3929
4230
|
// or a 2,000-char section of a 400,000-char 10-K reads as a whole document.
|
|
@@ -3937,7 +4238,7 @@ async function filingText(
|
|
|
3937
4238
|
...(rawTruncated
|
|
3938
4239
|
? {
|
|
3939
4240
|
raw_truncated: true,
|
|
3940
|
-
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.`,
|
|
4241
|
+
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.`,
|
|
3941
4242
|
}
|
|
3942
4243
|
: {}),
|
|
3943
4244
|
text: slice,
|
|
@@ -4097,7 +4398,8 @@ async function filingExhibitText(
|
|
|
4097
4398
|
const buildOne = (term: string) => {
|
|
4098
4399
|
const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
|
|
4099
4400
|
const wordHit = totalMatches === 0 ? findWordPassages(fullText, term) : null;
|
|
4100
|
-
|
|
4401
|
+
const st = searchStatus(totalMatches > 0, wordHit !== null, rawTruncated);
|
|
4402
|
+
const r = wordHit
|
|
4101
4403
|
? {
|
|
4102
4404
|
term,
|
|
4103
4405
|
match_mode: 'words' as const,
|
|
@@ -4121,6 +4423,11 @@ async function filingExhibitText(
|
|
|
4121
4423
|
? 'No case-insensitive match for this wording (nor any of its individual words) in this exhibit. That is a real answer, not a failure — retry with different wording, or this fact may be in a different exhibit (see `available_exhibits`).'
|
|
4122
4424
|
: 'Passages carrying a nearby number (a likely KPI figure) are ranked first; the rest keep document order.',
|
|
4123
4425
|
};
|
|
4426
|
+
return {
|
|
4427
|
+
...r,
|
|
4428
|
+
status: st.status,
|
|
4429
|
+
...(st.scan_note ? { note: st.scan_note + (wordHit ? ' ' + r.note : ''), scanned_chars: documentChars } : {}),
|
|
4430
|
+
};
|
|
4124
4431
|
};
|
|
4125
4432
|
const results = searchPhrases.map(buildOne);
|
|
4126
4433
|
const returnedChars = results.reduce((n, r) => n + r.passages.reduce((m, p) => m + p.text.length, 0), 0);
|
|
@@ -4135,7 +4442,7 @@ async function filingExhibitText(
|
|
|
4135
4442
|
? { truncated_note: `These are matched passages, not the exhibit: ${returnedChars} of ${documentChars} characters. Call again without \`search\` (or with a passage's \`offset\`) to read the surrounding text.` }
|
|
4136
4443
|
: {}),
|
|
4137
4444
|
...(searchPhrases.length === 1 ? { search: results[0] } : { searches: searchPhrases.map((phrase, i) => ({ phrase, ...results[i] })) }),
|
|
4138
|
-
...(rawTruncated ? { raw_truncated: true } : {}),
|
|
4445
|
+
...(rawTruncated ? { raw_truncated: true, scanned_chars: documentChars } : {}),
|
|
4139
4446
|
};
|
|
4140
4447
|
}
|
|
4141
4448
|
|
|
@@ -4727,6 +5034,13 @@ async function companyConcept(
|
|
|
4727
5034
|
// (fy, fp, end) tuple. Keep the most-recently-filed version.
|
|
4728
5035
|
type Raw = { start?: string; end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
|
|
4729
5036
|
const spanDays = (a?: string, b?: string) => (a && b) ? Math.round((Date.parse(b) - Date.parse(a)) / 86400000) : NaN;
|
|
5037
|
+
/** Distinct accession numbers of the facts a derived row was computed from
|
|
5038
|
+
* (fleet #3179), in the order given; undefined when none carries one. */
|
|
5039
|
+
const uniqueAccessions = (rows: Array<{ accession?: string }>): string[] | undefined => {
|
|
5040
|
+
const out: string[] = [];
|
|
5041
|
+
for (const r of rows) if (r.accession && !out.includes(r.accession)) out.push(r.accession);
|
|
5042
|
+
return out.length > 0 ? out : undefined;
|
|
5043
|
+
};
|
|
4730
5044
|
// Does this fact's DURATION match its period label? A 10-Q files BOTH the
|
|
4731
5045
|
// discrete 3-month quarter AND the year-to-date figure under the same
|
|
4732
5046
|
// (fy, fp, end) tuple, filed the same day — so "most recently filed" was a
|
|
@@ -4782,6 +5096,10 @@ async function companyConcept(
|
|
|
4782
5096
|
ytd: false as boolean,
|
|
4783
5097
|
note: undefined as string | undefined,
|
|
4784
5098
|
total_liquidity: undefined as LiquidityBreakdown | undefined,
|
|
5099
|
+
// Derived rows only (fleet #3179): the filings the arithmetic read, so a
|
|
5100
|
+
// computed Q2/Q3/Q4 figure is still traceable to documents. `accession`
|
|
5101
|
+
// stays undefined on those rows -- no ONE filing states the number.
|
|
5102
|
+
derived_from_accessions: undefined as string[] | undefined,
|
|
4785
5103
|
}))
|
|
4786
5104
|
.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
4787
5105
|
|
|
@@ -4828,6 +5146,7 @@ async function companyConcept(
|
|
|
4828
5146
|
form: 'derived',
|
|
4829
5147
|
filed: fyRow.filed,
|
|
4830
5148
|
accession: undefined, // arithmetic over FY + Q1-Q3, not a fact one filing states
|
|
5149
|
+
derived_from_accessions: uniqueAccessions([fyRow, ...quarters.values()]),
|
|
4831
5150
|
unit: fyRow.unit,
|
|
4832
5151
|
derived: true,
|
|
4833
5152
|
duration_days: undefined, // no period_start to compute a span from
|
|
@@ -4895,6 +5214,7 @@ async function companyConcept(
|
|
|
4895
5214
|
form: 'derived',
|
|
4896
5215
|
filed: q2ytd.filed,
|
|
4897
5216
|
accession: undefined, // YTD(Q2) minus Q1 — spans two filings
|
|
5217
|
+
derived_from_accessions: uniqueAccessions([q2ytd, q1]),
|
|
4898
5218
|
unit: q2ytd.unit,
|
|
4899
5219
|
derived: true,
|
|
4900
5220
|
duration_days: spanDays(q1.period_end, q2ytd.period_end),
|
|
@@ -4917,6 +5237,7 @@ async function companyConcept(
|
|
|
4917
5237
|
form: 'derived',
|
|
4918
5238
|
filed: q3ytd.filed,
|
|
4919
5239
|
accession: undefined, // YTD(Q3) minus YTD(Q2) — spans two filings
|
|
5240
|
+
derived_from_accessions: uniqueAccessions([q3ytd, q2ytd]),
|
|
4920
5241
|
unit: q3ytd.unit,
|
|
4921
5242
|
derived: true,
|
|
4922
5243
|
duration_days: spanDays(q2ytd.period_end, q3ytd.period_end),
|