@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 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 1855+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
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 1855+ data sources. The
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.13",
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-8dcbabc71f11a133be24546b4546bf7ee8497587841c394b19f76a770375639f",
30
- "sourceCommit": "5749f83c0ea115fab4c0c0ef04b66b05f2507d67"
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.13",
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.slice(1, 4).map((m) => ({
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` for disambiguation. 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.',
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
- return xbrl ? { ...base, xbrl } : base;
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
- raw_truncated_note: `This document's raw HTML exceeds the ${(FILING_DOC_MAX_BYTES / 1_000_000).toFixed(0)}MB this tool reads in one call, so the search above only scanned the portion read — NOT the whole filing. Treat total_matches:0 as inconclusive, not "absent", for a raw_truncated document.`,
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
- return wordHit
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),
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@ import pack from './index.js';
10
10
 
11
11
 
12
12
  const server = new Server(
13
- { name: '@pipeworx/mcp-edgar', version: '0.3.13' },
13
+ { name: '@pipeworx/mcp-edgar', version: '0.3.15' },
14
14
  { capabilities: { tools: {} } },
15
15
  );
16
16