@pipeworx/mcp-edgar 0.3.11 → 0.3.12
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 +4 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +556 -71
- 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 1837+ 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
|
|
|
@@ -60,6 +60,8 @@ For insider trades specifically, see the dedicated `insider-trading` pack — it
|
|
|
60
60
|
- **Real-time-ish, not real-time.** Filings appear on EDGAR within minutes of submission, but Pipeworx caches results. For breaking-news-grade timeliness, set `Cache-Control: no-cache` (anonymous limit applies) or check the `_meta.cache.fresh_until` field.
|
|
61
61
|
- **Concept availability differs.** Smaller filers tag fewer XBRL concepts than large ones. `edgar_company_concept` may return empty arrays for valid concepts that the company simply doesn't report. Use `edgar_company_facts` to see which concepts a company DOES report.
|
|
62
62
|
- **The company argument is spelled three different ways across this pack, and `edgar_company_concept` accepts all of them.** `edgar_company_filings`/`edgar_insider_transactions`/`edgar_product_revenue` name it `ticker_or_cik`, `edgar_fund_holdings`/`edgar_ticker_to_cik` name it `ticker`, and `edgar_company_concept`/`edgar_company_facts` name it `cik` (which takes a ticker too). A model filling arguments reaches for whichever word it read last, so `edgar_company_concept` declares `ticker` and `ticker_or_cik` as aliases of `cik`, and `metric` as an alias of `concept`. Measured before that change (fleet #2058): of 11 times the ask_pipeworx adjudicator selected this tool over 16 never-before-asked questions, 9 calls were rejected by the gateway's required-argument pre-flight — every one of them because it had sent `ticker` or `ticker_or_cik` rather than `cik`, and a third of those sent `metric` rather than `concept` as well. Both calls that used `cik`+`concept` succeeded. The outcome recorded for the rejected ones was `partial`, not an error, because a single-tool selection that arrives as a one-element `tools` array runs the fan-out branch — so the word for "the only lookup never ran" was the same word used for "some of several lookups ran".
|
|
63
|
+
- **A filing you did not NAME is a filing we CHOSE, and the envelope now says which (fleet #3319).** `edgar_filing_text` and `edgar_filing_documents` auto-resolve the LATEST filing of `form_type` when `accession` is omitted, and every field then describes that filing as though it had been asked for. Measured live 2026-10-18: a grounded question about "Tesla's September 29 2026 8-K" reached `edgar_filing_text({ticker:"TSLA", form_type:"8-K", search:"credit facility"})`, resolved to the 2026-10-02 8-K — a different filing, 3,061 chars, zero matches — and refused `not_in_source` 5 tries out of 5, while the filing asked about sat one row further down the same index with all three credit tranches in it. So every response carries `accession_source` (`"caller"` | `"auto_selected"`) plus, when we chose, an `auto_selected` block naming what was requested, what was picked, what was read with its exact-match count, and the candidates that were not read. **Pass `filed`** ("2026-09-29", "2026-09", "September 29, 2026") whenever the question names a date; for an 8-K the newest filing is routinely not the one meant. When only a `search` is available, `edgar_filing_text` reads on past a candidate whose text contains no exact match (bounded to 4 documents, exact matches only), and if none match it falls back to the newest WITH a warning that a zero-match result there is inconclusive about the company rather than about that filing.
|
|
64
|
+
- **`edgar_company_filings` used to drop a named `accession` on the floor.** It declared neither `accession` nor `include_primary_text`, so a caller that named one filing got the same unnarrowed list as one that named nothing — HTTP 200, reads as a clean success. That is how the fleet #3319 question finally died: the grounded ladder's last attempt routed here with `{accession:"0001628280-26-063820", ticker:"TSLA", include_primary_text:true}` (AE `blob12` — the router had already resolved the right 8-K) and the extraction model received a generic 10-row filings list carrying no facility amounts. Both arguments are real now, plus `filed`; a one-row result says so in `narrowed_note`, and an accession the filer never filed is a loud `not_found:` error rather than an unfiltered list.
|
|
63
65
|
- **`fiscal_year` and `fiscal_period` are filters AND response fields.** They are the names of two fields on every row in `values`, and the description's advice to "match the requested fiscal_year and fiscal_period" used to make argument-fillers send them as arguments, where they were undeclared and silently dropped — so the caller got all 89-230 reported periods back with nothing saying its filter had been ignored. They are now real optional filters: pass either or both and `values`/`latest` come back scoped to it, with a `period_filter` block echoing what was applied. An unmatched filter returns NO rows plus the list of fiscal years and periods the filer actually reports, rather than falling back to every period — `fiscal_year` is the filer's OWN label (NVDA's FY2024 ended January 2024), so a caller thinking in calendar years needs to see the labels rather than be handed a neighbouring year's number.
|
|
64
66
|
|
|
65
67
|
## Quick Start
|
|
@@ -106,7 +108,7 @@ directly, instead of just this one's:
|
|
|
106
108
|
}
|
|
107
109
|
```
|
|
108
110
|
|
|
109
|
-
Both URLs reach the same gateway and the same
|
|
111
|
+
Both URLs reach the same gateway and the same 1837+ data sources. The
|
|
110
112
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
111
113
|
reaches all of them from either one.
|
|
112
114
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-edgar",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
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-91337e87e2be0647d629be7b6c821ab5c696b671f31a18ead8dedd775cd40966",
|
|
30
|
+
"sourceCommit": "0f33463aa47677f38e4f177a4b4a7160dd80819f"
|
|
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.12",
|
|
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
|
@@ -24,6 +24,93 @@ interface McpToolExport {
|
|
|
24
24
|
provider?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The class routing tokens, and the two safe ways to wrap a message carrying one.
|
|
29
|
+
*
|
|
30
|
+
* A pack signals an error's class with a leading token — `user_error:`,
|
|
31
|
+
* `upstream_down:`, `upstream_throttled:`, `not_found:`, `blocked_host:`. The
|
|
32
|
+
* gateway's classifier anchors on `^`, and `stripClassPrefix` (which hides the
|
|
33
|
+
* token from the caller) anchors on `^` too. So the convention has one failure
|
|
34
|
+
* mode, and it is silent: a catch block that wraps the message —
|
|
35
|
+
* `` `${slug}/${tool}: ${message}` `` — pushes the token off position 0. The
|
|
36
|
+
* error then books as `error` ("Pipeworx has a defect") instead of as the
|
|
37
|
+
* caller mistake it is, AND the raw token leaks into what the caller reads.
|
|
38
|
+
*
|
|
39
|
+
* Nothing about that fails loudly. The call still returns, the message still
|
|
40
|
+
* reads plausibly, and the misclassification only shows up as a pack sitting on
|
|
41
|
+
* the Problem Tools list for a bug it does not have. Found live in
|
|
42
|
+
* `medicaid-intelligence` on 2026-08-21; the same wrapper template is copied
|
|
43
|
+
* across 18 DMV packs, none of which emit a token *yet*.
|
|
44
|
+
*
|
|
45
|
+
* `scripts/check-error-class-prefix.mjs` is the gate that keeps this honest —
|
|
46
|
+
* it fails any pack that both emits a token and wraps a caught message without
|
|
47
|
+
* using one of the helpers below.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The canonical token set. `workers/gateway/src/error-class.ts` carries its own
|
|
52
|
+
* copy on the read side (it is deliberately importable without pulling a pack
|
|
53
|
+
* in); the gate asserts the two agree, because this list has already drifted
|
|
54
|
+
* twice — `not_found:` and `blocked_host:` were honoured by the classifier and
|
|
55
|
+
* not stripped, so both went out to callers verbatim for months.
|
|
56
|
+
*/
|
|
57
|
+
const CLASS_TOKENS = [
|
|
58
|
+
'upstream_down',
|
|
59
|
+
'upstream_throttled',
|
|
60
|
+
'user_error',
|
|
61
|
+
'not_found',
|
|
62
|
+
'blocked_host',
|
|
63
|
+
// `blocked_url:` is emitted at position 0 from five sites in ssrf.ts
|
|
64
|
+
// (`assertPublicHttpUrl`, and every redirect hop in `safeFetch`) and was in
|
|
65
|
+
// NEITHER reader — so it went to callers verbatim for its whole life. Caught
|
|
66
|
+
// 2026-08-21 by a live n8n call, which answered a private instance_url with
|
|
67
|
+
// "…host). blocked_url: refusing to fetch non-public or non-https URL".
|
|
68
|
+
// Exactly the drift the gate now blocks.
|
|
69
|
+
'blocked_url',
|
|
70
|
+
// `auth_required:` joins the list 2026-08-29 (fleet #638). It exists for the
|
|
71
|
+
// same reason `user_error:` does: a bare 401/403 in an upstream body matches
|
|
72
|
+
// the `upstream_throttled` heuristic below before anything auth-specific, so
|
|
73
|
+
// a pack that needs to say "this is a credential problem, not a rate limit"
|
|
74
|
+
// has no wording-based route — only the explicit-prefix escape hatch works.
|
|
75
|
+
// tiingo and open-sanctions both reached for it on their own, on the
|
|
76
|
+
// (reasonable, but wrong at the time) assumption that any snake_case class
|
|
77
|
+
// already meant something to the gateway. Neither shipped a leak from
|
|
78
|
+
// MIS-CLASSIFICATION — the `error` field was already correct — the leak was
|
|
79
|
+
// the literal token riding along in `message`, unstripped, because this list
|
|
80
|
+
// didn't know the token either reader was seeing.
|
|
81
|
+
'auth_required',
|
|
82
|
+
] as const;
|
|
83
|
+
|
|
84
|
+
const CLASS_PREFIX_RE =
|
|
85
|
+
/^(?:upstream_down|upstream_throttled|user_error|not_found|blocked_host|blocked_url|auth_required)\s*:\s*/;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split a caught message into its leading routing token (possibly empty) and
|
|
89
|
+
* the human-readable body, so a wrapper can put the token back on the front.
|
|
90
|
+
*
|
|
91
|
+
* const { token, body } = splitClassPrefix(message);
|
|
92
|
+
* return { error: `${token}my-pack/${name}: ${body}` };
|
|
93
|
+
*
|
|
94
|
+
* The `${token}` must be the FIRST thing in the template — that is the whole
|
|
95
|
+
* point, and it is what the gate checks.
|
|
96
|
+
*/
|
|
97
|
+
function splitClassPrefix(message: string): { token: string; body: string } {
|
|
98
|
+
const token = message.match(CLASS_PREFIX_RE)?.[0] ?? '';
|
|
99
|
+
return { token, body: message.slice(token.length) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Drop a leading routing token from a message that is about to become a
|
|
104
|
+
* FRAGMENT of a larger one — a per-mirror failure joined into "all providers
|
|
105
|
+
* failed (...)", say. Hoisting is wrong there: the fragment never reaches
|
|
106
|
+
* position 0, so the token cannot route anything and would only leak. The outer
|
|
107
|
+
* message declares its own class.
|
|
108
|
+
*/
|
|
109
|
+
function dropClassPrefix(message: string): string {
|
|
110
|
+
return message.replace(CLASS_PREFIX_RE, '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
27
114
|
/**
|
|
28
115
|
* Was this failure OUR OWN web service? — the other half of `internal-db-class.ts`.
|
|
29
116
|
*
|
|
@@ -1181,7 +1268,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1181
1268
|
{
|
|
1182
1269
|
name: 'edgar_company_filings',
|
|
1183
1270
|
description:
|
|
1184
|
-
'AUTHORITATIVE list of recent SEC filings for a specific US public company. Send the company as `ticker_or_cik` — that argument takes a ticker ("AAPL") or a CIK ("320193"), and `cik` / `ticker` are accepted as aliases for it. Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts. If you also need the headline financials alongside the filings, edgar_company_snapshot returns both in one call.',
|
|
1271
|
+
'AUTHORITATIVE list of recent SEC filings for a specific US public company. Send the company as `ticker_or_cik` — that argument takes a ticker ("AAPL") or a CIK ("320193"), and `cik` / `ticker` are accepted as aliases for it. **When the question is about ONE filing rather than the list, say which one**: pass `accession` to narrow to it, or `filed` ("2026-09-29", "2026-09") when you know only the date, and add `include_primary_text:true` to get that filing\'s text back here instead of chaining a second call. Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts. If you also need the headline financials alongside the filings, edgar_company_snapshot returns both in one call.',
|
|
1185
1272
|
summary: 'Every SEC filing a US public company has made, newest first.',
|
|
1186
1273
|
inputSchema: {
|
|
1187
1274
|
type: 'object' as const,
|
|
@@ -1195,6 +1282,18 @@ const tools: McpToolExport['tools'] = [
|
|
|
1195
1282
|
description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K"). Omit for all types.',
|
|
1196
1283
|
},
|
|
1197
1284
|
limit: { type: 'number', description: 'Max filings to return (1-40, default 20)' },
|
|
1285
|
+
accession: {
|
|
1286
|
+
type: 'string',
|
|
1287
|
+
description: 'Narrow the list to ONE filing you already know the accession of, dashed or not (e.g. "0001628280-26-063820") — use this when the question is about a specific filing rather than "what has this company filed". Pair it with `include_primary_text:true` to get that filing\'s text back in the same call. A named accession that does not belong to this filer is an explicit error, never a silently unfiltered list.',
|
|
1288
|
+
},
|
|
1289
|
+
include_primary_text: {
|
|
1290
|
+
type: 'boolean',
|
|
1291
|
+
description: 'When true, also return the primary document\'s text (HTML stripped to plaintext, capped at ~40,000 chars) under `primary_text`, for the SINGLE filing `accession` names. Ignored — with a note saying so — when no accession narrows the list, since the text of twenty filings is not a thing this tool returns; use edgar_filing_text for that.',
|
|
1292
|
+
},
|
|
1293
|
+
filed: {
|
|
1294
|
+
type: 'string',
|
|
1295
|
+
description: 'Only filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" are accepted too). Use it when the question names WHEN ("Tesla\'s September 29 2026 8-K") and you do not have the accession: without it the newest filing of that form is returned, which for an 8-K is routinely a different filing from the one asked about.',
|
|
1296
|
+
},
|
|
1198
1297
|
cik: {
|
|
1199
1298
|
type: 'string',
|
|
1200
1299
|
description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
|
|
@@ -1577,7 +1676,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
1577
1676
|
properties: {
|
|
1578
1677
|
accession: {
|
|
1579
1678
|
type: 'string',
|
|
1580
|
-
description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead.',
|
|
1679
|
+
description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead, and the response will say `accession_source:"auto_selected"` and list under `auto_selected.candidates` the other filings you might have meant. If the question names WHICH filing (a date, "the September 29 8-K"), pass `filed` or the accession rather than relying on that choice.',
|
|
1581
1680
|
},
|
|
1582
1681
|
ticker: {
|
|
1583
1682
|
type: 'string',
|
|
@@ -1591,6 +1690,11 @@ const tools: McpToolExport['tools'] = [
|
|
|
1591
1690
|
type: 'string',
|
|
1592
1691
|
description: 'When accession is omitted, the form type of the latest filing to fetch, e.g. "10-K", "10-Q", "8-K", "DEF 14A" — or a `|`-separated SET ("10-K|10-Q") for "the most recent of either, whichever is newer". Omit both accession and form_type to get the single most recent filing of ANY type at all (routine 8-Ks/Form 4s/Form 144s included, not just annual/quarterly reports) — use the `|` set instead when you specifically want the newest 10-K or 10-Q.',
|
|
1593
1692
|
},
|
|
1693
|
+
filed: {
|
|
1694
|
+
type: 'string',
|
|
1695
|
+
description: 'Only consider filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" accepted). **Pass this whenever the question names a date** ("Tesla\'s September 29 2026 8-K"): without it the NEWEST filing of that form is used, and for an 8-K that is routinely a different filing.',
|
|
1696
|
+
},
|
|
1697
|
+
filing_date: { type: 'string', description: 'Alias for `filed`.' },
|
|
1594
1698
|
include_primary_text: {
|
|
1595
1699
|
type: 'boolean',
|
|
1596
1700
|
description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false. For the FULL, pageable document text — or just one section like going-concern/liquidity — use edgar_filing_text instead.',
|
|
@@ -1606,15 +1710,20 @@ const tools: McpToolExport['tools'] = [
|
|
|
1606
1710
|
{
|
|
1607
1711
|
name: 'edgar_filing_text',
|
|
1608
1712
|
description:
|
|
1609
|
-
'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. **For a question asking two facts from the same filing** ("revenue and test volume in the latest 10-Q"), pass `search` as `|`-separated phrases (e.g. "revenue|tests processed") to get both in this one call under `searches[]` — and when a phrase names a financial total (revenue, net income, EPS, ...), the matching XBRL figure for this exact accession comes back under `xbrl` even if that total never appears as an absolute number in the prose (common: filings often state only the growth delta/percentage in text). Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events); `search` runs within that slice when both are given. Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled, when `search` is not used: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page (and how far `search` can scan) and can make a late section or search term come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q", "how many tests did $TICKER process this quarter". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
|
|
1713
|
+
'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. A `search` response carries ONLY the matched passages, so it reports `returned_chars` against `total_chars` and sets `truncated:true` whenever those differ — read a figure\'s absence from it as "the phrase did not match there", never as "the filing does not say it", and re-read without `search` (or at a passage `offset`) to confirm. (Do not read `search.truncated` as a completeness signal: it is about the SEARCH — false means every distinct match location was returned — and is routinely false on a response holding a third of the document.) **For a question asking two facts from the same filing** ("revenue and test volume in the latest 10-Q"), pass `search` as `|`-separated phrases (e.g. "revenue|tests processed") to get both in this one call under `searches[]` — and when a phrase names a financial total (revenue, net income, EPS, ...), the matching XBRL figure for this exact accession comes back under `xbrl` even if that total never appears as an absolute number in the prose (common: filings often state only the growth delta/percentage in text). Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events); `search` runs within that slice when both are given. Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled, when `search` is not used: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page (and how far `search` can scan) and can make a late section or search term come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q", "how many tests did $TICKER process this quarter". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
|
|
1610
1714
|
summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
|
|
1611
1715
|
inputSchema: {
|
|
1612
1716
|
type: 'object' as const,
|
|
1613
1717
|
properties: {
|
|
1614
1718
|
accession: {
|
|
1615
1719
|
type: 'string',
|
|
1616
|
-
description: 'SEC accession number, dashed or not (e.g. "0001683168-26-003909"). Omit to auto-resolve the latest filing of form_type for the given ticker/cik.',
|
|
1720
|
+
description: 'SEC accession number, dashed or not (e.g. "0001683168-26-003909"). Omit to auto-resolve the latest filing of form_type for the given ticker/cik — the response then says `accession_source:"auto_selected"` and lists under `auto_selected.candidates` the other filings you might have meant. If the question names WHICH filing (a date, "the September 29 8-K"), pass `filed` or an accession instead of relying on that choice.',
|
|
1721
|
+
},
|
|
1722
|
+
filed: {
|
|
1723
|
+
type: 'string',
|
|
1724
|
+
description: 'Only consider filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" accepted). **Pass this whenever the question names a date** ("Tesla\'s September 29 2026 8-K"): without it the NEWEST filing of that form is read, and for an 8-K that is routinely a different filing — verified live 2026-10-18, the newest TSLA 8-K was six days later, 3,061 chars, and contained none of the credit-facility figures asked about.',
|
|
1617
1725
|
},
|
|
1726
|
+
filing_date: { type: 'string', description: 'Alias for `filed`.' },
|
|
1618
1727
|
ticker: {
|
|
1619
1728
|
type: 'string',
|
|
1620
1729
|
description: 'Filer ticker (e.g. "ACTU"). Provide this OR cik. ONLY pass a ticker you are CERTAIN of — a wrong remembered ticker silently retrieves a DIFFERENT company\'s filing as a clean success (a "SpaceX" question filled with SPCE returns Virgin Galactic\'s S-1). For a recent IPO or any uncertain ticker, resolve first: edgar_company_filings accepts the company NAME and returns the cik — pass that cik here.',
|
|
@@ -2303,13 +2412,100 @@ function recentFilingRows(data: SubmissionsPayload, keep: (form: string) => bool
|
|
|
2303
2412
|
return filings;
|
|
2304
2413
|
}
|
|
2305
2414
|
|
|
2306
|
-
async function companyFilings(
|
|
2415
|
+
async function companyFilings(
|
|
2416
|
+
tickerOrCik: string,
|
|
2417
|
+
formType?: string,
|
|
2418
|
+
limit?: number,
|
|
2419
|
+
accession?: string,
|
|
2420
|
+
includePrimaryText?: boolean,
|
|
2421
|
+
filed?: string,
|
|
2422
|
+
) {
|
|
2307
2423
|
const cik = await resolveCik(tickerOrCik);
|
|
2308
2424
|
const paddedCik = padCik(cik);
|
|
2309
2425
|
const count = Math.min(40, Math.max(1, limit ?? 20));
|
|
2310
2426
|
|
|
2311
2427
|
const data = await fetchSubmissions(tickerOrCik, paddedCik);
|
|
2312
|
-
|
|
2428
|
+
|
|
2429
|
+
// ── A NAMED FILING IS NOT A FILTER SUGGESTION (fleet #3319) ──────────────
|
|
2430
|
+
//
|
|
2431
|
+
// `accession` and `include_primary_text` used to be undeclared here, so a
|
|
2432
|
+
// caller that named a filing got the same unnarrowed list as one that named
|
|
2433
|
+
// nothing — the silently-ignored-filter-key failure, which returns HTTP 200
|
|
2434
|
+
// and reads as a clean success. Measured live 2026-10-18 on
|
|
2435
|
+
// ask_pipeworx_grounded: its last ladder attempt routed to this tool with
|
|
2436
|
+
// {accession:"0001628280-26-063820", ticker:"TSLA", include_primary_text:true}
|
|
2437
|
+
// — the router had already resolved the RIGHT 8-K — and the extraction model
|
|
2438
|
+
// received a 10-row filings list carrying no facility amounts and refused
|
|
2439
|
+
// `not_in_source`, 5 tries out of 5. The accession was in our hands the whole
|
|
2440
|
+
// time.
|
|
2441
|
+
const wantAcc = accession ? normalizeAccession(accession) : null;
|
|
2442
|
+
if (accession && !wantAcc) {
|
|
2443
|
+
throw new Error(
|
|
2444
|
+
`Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0001628280-26-063820", with or without dashes). Omit it to list this filer's recent filings, or pass \`filed\` ("2026-09-29") if you know only the date.`,
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
const filedPrefix = normalizeFiledPrefix(filed);
|
|
2448
|
+
if (filed && !filedPrefix) {
|
|
2449
|
+
throw new Error(
|
|
2450
|
+
`Could not read "${filed}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`,
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
const formKeep = (form: string) => !formType || formType.toLowerCase() === 'all' || form === formType;
|
|
2455
|
+
let filings = recentFilingRows(data, formKeep, wantAcc || filedPrefix ? 40 : count);
|
|
2456
|
+
const totalBeforeNarrowing = filings.length;
|
|
2457
|
+
if (wantAcc) {
|
|
2458
|
+
const hit = filings.filter((f) => normalizeAccession(f.accession_number)?.dashed === wantAcc.dashed);
|
|
2459
|
+
if (!hit.length) {
|
|
2460
|
+
// Loud, not an unfiltered list: an accession this filer has not filed is
|
|
2461
|
+
// a wrong question, and answering it with somebody else's filings is how
|
|
2462
|
+
// a caller ends up quoting the wrong document.
|
|
2463
|
+
throw new Error(
|
|
2464
|
+
`not_found: accession ${wantAcc.dashed} is not among ${data.name}'s recent filings${formType && formType.toLowerCase() !== 'all' ? ` of form ${formType}` : ''}. Drop \`accession\` to list what this filer HAS filed (the recent index covers roughly the last 1,000 filings), or check the digits — an accession belongs to exactly one filer.`,
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
filings = hit;
|
|
2468
|
+
} else if (filedPrefix) {
|
|
2469
|
+
filings = filings.filter((f) => (f.filing_date ?? '').startsWith(filedPrefix)).slice(0, count);
|
|
2470
|
+
if (!filings.length) {
|
|
2471
|
+
throw new Error(
|
|
2472
|
+
`not_found: ${data.name} has no${formType && formType.toLowerCase() !== 'all' ? ` ${formType}` : ''} filing dated ${filedPrefix} in SEC's recent index (${totalBeforeNarrowing} candidate filing(s) were checked). Widen the date ("${filedPrefix.slice(0, 7)}" for the whole month) or drop \`filed\` to see what is actually there.`,
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// `include_primary_text` is only answerable for ONE filing. Say which way it
|
|
2478
|
+
// went rather than returning a list and leaving the caller to notice the
|
|
2479
|
+
// field is missing (silent-zero policy).
|
|
2480
|
+
let primaryText: string | null = null;
|
|
2481
|
+
let primaryTextNote: string | null = null;
|
|
2482
|
+
if (includePrimaryText) {
|
|
2483
|
+
if (filings.length === 1) {
|
|
2484
|
+
const only = filings[0];
|
|
2485
|
+
try {
|
|
2486
|
+
const { text: body, truncated: rawTrunc } = await fetchTextBounded(
|
|
2487
|
+
only.document_url,
|
|
2488
|
+
{ 'User-Agent': SEC_HEADERS['User-Agent'] },
|
|
2489
|
+
FILING_DOC_MAX_BYTES,
|
|
2490
|
+
);
|
|
2491
|
+
const isHtml = /\.x?html?$/i.test(only.primary_document) || /^\s*</.test(body);
|
|
2492
|
+
const text = isHtml ? htmlToText(body) : body;
|
|
2493
|
+
primaryText = text.slice(0, 40000);
|
|
2494
|
+
if (text.length > 40000 || rawTrunc) {
|
|
2495
|
+
primaryTextNote = `primary_text is the first 40,000 of ${text.length} characters${rawTrunc ? ' of the portion of an oversized document that was read at all' : ''} — NOT the whole document. Read the rest with edgar_filing_text({accession:"${only.accession_number}", ticker_or_cik:"${tickerOrCik}", offset:40000}).`;
|
|
2496
|
+
}
|
|
2497
|
+
} catch (err) {
|
|
2498
|
+
// The list is the core payload; a failed text fetch degrades with a
|
|
2499
|
+
// stated reason rather than failing the whole call.
|
|
2500
|
+
// dropClassPrefix: this is a NOTE fragment, not the call's error — the
|
|
2501
|
+
// call succeeded. A routing token carried in here would be read by the
|
|
2502
|
+
// gateway's classifier as this response's class, which it is not.
|
|
2503
|
+
primaryTextNote = `primary_text could not be fetched (${dropClassPrefix(err instanceof Error ? err.message : String(err))}). The filing row below is still correct; retry the text with edgar_filing_text({accession:"${only.accession_number}", ticker_or_cik:"${tickerOrCik}"}).`;
|
|
2504
|
+
}
|
|
2505
|
+
} else {
|
|
2506
|
+
primaryTextNote = `include_primary_text was requested but ${filings.length} filings match, and this tool returns the text of ONE filing only. Pass \`accession\` (or a narrower \`filed\`) to pick one, or call edgar_filing_text for a filing's full, pageable text.`;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2313
2509
|
|
|
2314
2510
|
return {
|
|
2315
2511
|
cik: data.cik,
|
|
@@ -2327,7 +2523,18 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
|
|
|
2327
2523
|
state_of_incorporation: data.stateOfIncorporation,
|
|
2328
2524
|
fiscal_year_end: data.fiscalYearEnd,
|
|
2329
2525
|
filter_form_type: formType ?? 'all',
|
|
2526
|
+
...(wantAcc ? { filter_accession: wantAcc.dashed } : {}),
|
|
2527
|
+
...(filedPrefix ? { filter_filed: filedPrefix } : {}),
|
|
2528
|
+
// Narrowing is stated, so a one-row answer never reads as "this company
|
|
2529
|
+
// has filed exactly one thing".
|
|
2530
|
+
...(wantAcc || filedPrefix
|
|
2531
|
+
? {
|
|
2532
|
+
narrowed_note: `This is NOT ${data.name}'s recent filings list — it was narrowed to ${wantAcc ? `the single filing ${wantAcc.dashed}` : `filings dated ${filedPrefix}`}, ${filings.length} of ${totalBeforeNarrowing} candidate(s) examined. Drop that argument for the full list.`,
|
|
2533
|
+
}
|
|
2534
|
+
: {}),
|
|
2330
2535
|
filings,
|
|
2536
|
+
...(primaryText !== null ? { primary_text: primaryText } : {}),
|
|
2537
|
+
...(primaryTextNote ? { primary_text_note: primaryTextNote } : {}),
|
|
2331
2538
|
};
|
|
2332
2539
|
}
|
|
2333
2540
|
|
|
@@ -2553,11 +2760,117 @@ function findLatestFormIndex(forms: string[], wantFormTypeArg: string | undefine
|
|
|
2553
2760
|
return forms.findIndex((f) => wanted.includes(f.toUpperCase()));
|
|
2554
2761
|
}
|
|
2555
2762
|
|
|
2763
|
+
/**
|
|
2764
|
+
* Normalise a caller's `filed` into a date PREFIX we can match against SEC's
|
|
2765
|
+
* own `filingDate` strings ("2026-09-29"). Accepts a full ISO date, a month
|
|
2766
|
+
* ("2026-09"), a year ("2026"), and the US spellings a model actually types
|
|
2767
|
+
* ("09/29/2026", "September 29 2026"). Returns null when nothing date-shaped
|
|
2768
|
+
* is in there, which is treated as "no date filter" rather than an error — a
|
|
2769
|
+
* wrong-shaped date that silently matched nothing would be the same class of
|
|
2770
|
+
* defect this whole change exists to remove.
|
|
2771
|
+
*/
|
|
2772
|
+
// Full names AND the abbreviations in actual use, listed rather than matched by
|
|
2773
|
+
// prefix: a 3-letter prefix accepts "Septober" as September, which is the
|
|
2774
|
+
// silent-wrong-answer shape this whole change is about. An unrecognised month
|
|
2775
|
+
// returns null and the caller gets an explicit error.
|
|
2776
|
+
const FILED_MONTHS: Record<string, string> = {
|
|
2777
|
+
january: '01', jan: '01',
|
|
2778
|
+
february: '02', feb: '02',
|
|
2779
|
+
march: '03', mar: '03',
|
|
2780
|
+
april: '04', apr: '04',
|
|
2781
|
+
may: '05',
|
|
2782
|
+
june: '06', jun: '06',
|
|
2783
|
+
july: '07', jul: '07',
|
|
2784
|
+
august: '08', aug: '08',
|
|
2785
|
+
september: '09', sept: '09', sep: '09',
|
|
2786
|
+
october: '10', oct: '10',
|
|
2787
|
+
november: '11', nov: '11',
|
|
2788
|
+
december: '12', dec: '12',
|
|
2789
|
+
};
|
|
2790
|
+
export function normalizeFiledPrefix(filed: string | undefined): string | null {
|
|
2791
|
+
const s = (filed ?? '').trim();
|
|
2792
|
+
if (!s) return null;
|
|
2793
|
+
let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
|
2794
|
+
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
2795
|
+
m = /^(\d{4})-(\d{2})$/.exec(s);
|
|
2796
|
+
if (m) return `${m[1]}-${m[2]}`;
|
|
2797
|
+
m = /^(\d{4})$/.exec(s);
|
|
2798
|
+
if (m) return m[1];
|
|
2799
|
+
m = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})$/.exec(s); // 09/29/2026 (US order)
|
|
2800
|
+
if (m) return `${m[3]}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`;
|
|
2801
|
+
// "September 29, 2026" / "Sept 29 2026" / "September 2026"
|
|
2802
|
+
const words = /^([A-Za-z]{3,9})\.?\s+(?:(\d{1,2})(?:st|nd|rd|th)?,?\s+)?(\d{4})$/.exec(s);
|
|
2803
|
+
if (words) {
|
|
2804
|
+
const mo = FILED_MONTHS[words[1].toLowerCase()];
|
|
2805
|
+
if (mo) return words[2] ? `${words[3]}-${mo}-${words[2].padStart(2, '0')}` : `${words[3]}-${mo}`;
|
|
2806
|
+
}
|
|
2807
|
+
return null;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
/**
|
|
2811
|
+
* Recent-index positions of EVERY filing matching `wantFormTypeArg`, newest
|
|
2812
|
+
* first, optionally restricted to those whose filingDate starts with
|
|
2813
|
+
* `filedPrefix`. `findLatestFormIndex` above answers "which one filing", which
|
|
2814
|
+
* is all the auto-resolve path used to need; this answers "which filings could
|
|
2815
|
+
* the caller have meant", which is what lets the tool (a) honour a date in the
|
|
2816
|
+
* question and (b) say out loud that it picked one of several.
|
|
2817
|
+
*/
|
|
2818
|
+
export function findFormIndices(
|
|
2819
|
+
forms: string[],
|
|
2820
|
+
wantFormTypeArg: string | undefined,
|
|
2821
|
+
filingDates: string[],
|
|
2822
|
+
filedPrefix: string | null,
|
|
2823
|
+
limit: number,
|
|
2824
|
+
): number[] {
|
|
2825
|
+
const wanted = (wantFormTypeArg ?? '')
|
|
2826
|
+
.split('|')
|
|
2827
|
+
.map((f) => f.trim().toUpperCase())
|
|
2828
|
+
.filter(Boolean);
|
|
2829
|
+
const out: number[] = [];
|
|
2830
|
+
for (let i = 0; i < forms.length && out.length < limit; i++) {
|
|
2831
|
+
if (wanted.length && !wanted.includes((forms[i] ?? '').toUpperCase())) continue;
|
|
2832
|
+
if (filedPrefix && !(filingDates[i] ?? '').startsWith(filedPrefix)) continue;
|
|
2833
|
+
out.push(i);
|
|
2834
|
+
}
|
|
2835
|
+
return out;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// How many auto-selected candidates this tool will READ when a `search` can
|
|
2839
|
+
// tell a silent filing apart from the right one, and how many it will LIST in
|
|
2840
|
+
// the envelope either way. Reading is bounded because each candidate is a real
|
|
2841
|
+
// SEC document fetch; listing is free (it comes out of the submissions payload
|
|
2842
|
+
// already in hand).
|
|
2843
|
+
const AUTO_SELECT_SCAN = 4;
|
|
2844
|
+
const AUTO_SELECT_LIST = 8;
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* The `section` slice of a filing's text, as a function rather than inline, so
|
|
2848
|
+
* the auto-selection walk below can score a candidate against the SAME text
|
|
2849
|
+
* the search will actually run on. `section` is best-effort: an unmatched
|
|
2850
|
+
* section returns the whole document with section_found:false, unchanged.
|
|
2851
|
+
*/
|
|
2852
|
+
export function sliceFilingSection(
|
|
2853
|
+
fullText: string,
|
|
2854
|
+
section: string | undefined,
|
|
2855
|
+
): { text: string; applied: string | null; found: string | boolean | null } {
|
|
2856
|
+
const sec = (section ?? '').trim().toLowerCase();
|
|
2857
|
+
if (!sec) return { text: fullText, applied: null, found: null };
|
|
2858
|
+
const conf = FILING_SECTION_PATTERNS[sec];
|
|
2859
|
+
if (!conf) return { text: fullText, applied: null, found: `unknown_section:${sec}` };
|
|
2860
|
+
const idxs: number[] = [];
|
|
2861
|
+
for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
|
|
2862
|
+
const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
|
|
2863
|
+
if (chosen < 0) return { text: fullText, applied: null, found: false };
|
|
2864
|
+
// Back up a little so the section heading itself is included, not clipped mid-phrase.
|
|
2865
|
+
return { text: fullText.slice(Math.max(0, chosen - 120)), applied: sec, found: true };
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2556
2868
|
async function filingDocuments(
|
|
2557
2869
|
accession: string | undefined,
|
|
2558
2870
|
tickerOrCik: string,
|
|
2559
2871
|
includePrimaryText?: boolean,
|
|
2560
2872
|
wantFormTypeArg?: string,
|
|
2873
|
+
filedArg?: string,
|
|
2561
2874
|
) {
|
|
2562
2875
|
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
2563
2876
|
throw new Error(
|
|
@@ -2575,22 +2888,48 @@ async function filingDocuments(
|
|
|
2575
2888
|
`Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0000320193-25-000079", with or without dashes). Get accession numbers from edgar_company_filings, or omit accession and pass form_type to auto-resolve the latest.`,
|
|
2576
2889
|
);
|
|
2577
2890
|
}
|
|
2891
|
+
// ── WHEN WE CHOSE THE FILING, SAY SO (fleet #3319) ───────────────────────
|
|
2892
|
+
// Same defect and same disclosure as edgar_filing_text: with `accession`
|
|
2893
|
+
// omitted this resolves the LATEST filing of form_type and then describes it
|
|
2894
|
+
// as though the caller had asked for it. There is no `search` here to tell
|
|
2895
|
+
// candidates apart, so the newest is still taken — but the choice, and the
|
|
2896
|
+
// filings not chosen, are now stated rather than invisible.
|
|
2897
|
+
let autoSelected: Record<string, unknown> | null = null;
|
|
2578
2898
|
if (!acc) {
|
|
2579
2899
|
const wantForm = wantFormTypeArg?.trim().toUpperCase();
|
|
2900
|
+
const filedPrefix = normalizeFiledPrefix(filedArg);
|
|
2901
|
+
if (filedArg && !filedPrefix) {
|
|
2902
|
+
throw new Error(`Could not read "${filedArg}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`);
|
|
2903
|
+
}
|
|
2580
2904
|
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
|
|
2581
2905
|
if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
|
|
2582
|
-
const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
|
|
2906
|
+
const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } } };
|
|
2583
2907
|
const r = sub.filings?.recent;
|
|
2584
2908
|
const forms = r?.form ?? [];
|
|
2585
2909
|
const accs = r?.accessionNumber ?? [];
|
|
2586
|
-
const
|
|
2587
|
-
|
|
2910
|
+
const dates = r?.filingDate ?? [];
|
|
2911
|
+
const candidateIdx = findFormIndices(forms, wantFormTypeArg, dates, filedPrefix, AUTO_SELECT_LIST)
|
|
2912
|
+
.filter((i) => !!accs[i] && !!normalizeAccession(accs[i]));
|
|
2913
|
+
if (!candidateIdx.length) {
|
|
2914
|
+
const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
|
|
2588
2915
|
throw new Error(
|
|
2589
|
-
`No ${wantForm ?? 'recent'} filing found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
|
|
2916
|
+
`No ${wantForm ?? 'recent'} filing${dateClause} found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
|
|
2590
2917
|
);
|
|
2591
2918
|
}
|
|
2592
|
-
|
|
2919
|
+
const chosenIdx = candidateIdx[0];
|
|
2920
|
+
acc = normalizeAccession(accs[chosenIdx]);
|
|
2593
2921
|
if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
|
|
2922
|
+
autoSelected = {
|
|
2923
|
+
reason: `No accession was given, so this tool CHOSE the filing below — ${filedPrefix ? `the newest ${wantForm ?? 'filing'} dated ${filedPrefix}` : wantForm ? `the newest ${wantForm}` : 'the single most recent filing of ANY type'}.`,
|
|
2924
|
+
requested: { form_type: wantForm ?? null, filed: filedPrefix ?? null },
|
|
2925
|
+
chosen: { accession: acc.dashed, form: forms[chosenIdx] ?? null, filed: dates[chosenIdx] ?? null },
|
|
2926
|
+
candidates: candidateIdx.map((i) => ({
|
|
2927
|
+
accession: normalizeAccession(accs[i])!.dashed,
|
|
2928
|
+
form: forms[i] ?? null,
|
|
2929
|
+
filed: dates[i] ?? null,
|
|
2930
|
+
})),
|
|
2931
|
+
note: `If the question named a particular filing, pass its \`accession\` (or \`filed\`: "${dates[chosenIdx] ?? '2026-09-29'}") rather than relying on this choice — a company files many ${wantForm ?? 'document'}s and the newest is routinely not the one asked about.`,
|
|
2932
|
+
};
|
|
2594
2933
|
}
|
|
2595
2934
|
const cikNoZeros = String(Number(cik.replace(/\D/g, ''))); // archive path uses CIK with no leading zeros
|
|
2596
2935
|
const paddedCik = padCik(cik);
|
|
@@ -2660,6 +2999,10 @@ async function filingDocuments(
|
|
|
2660
2999
|
company,
|
|
2661
3000
|
form_type: formType,
|
|
2662
3001
|
filing_date: filingDate,
|
|
3002
|
+
// Whether the caller NAMED this filing or we picked it (fleet #3319).
|
|
3003
|
+
// Present on every response so its absence is never the signal.
|
|
3004
|
+
accession_source: autoSelected ? 'auto_selected' : 'caller',
|
|
3005
|
+
...(autoSelected ? { auto_selected: autoSelected } : {}),
|
|
2663
3006
|
primary_document: primaryDocument,
|
|
2664
3007
|
filing_url: `${folder}/${acc.dashed}-index.html`,
|
|
2665
3008
|
documents,
|
|
@@ -2846,6 +3189,7 @@ async function filingText(
|
|
|
2846
3189
|
offset?: number,
|
|
2847
3190
|
wantFormTypeArg?: string,
|
|
2848
3191
|
searchTerm?: string,
|
|
3192
|
+
filedArg?: string,
|
|
2849
3193
|
) {
|
|
2850
3194
|
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
2851
3195
|
throw new Error('A company is required: pass `ticker` ("ACTU"), `cik` or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_text({ticker:"ACTU", form_type:"10-Q"})).');
|
|
@@ -2869,83 +3213,175 @@ async function filingText(
|
|
|
2869
3213
|
const r = sub.filings?.recent;
|
|
2870
3214
|
const accs = r?.accessionNumber ?? [];
|
|
2871
3215
|
const forms = r?.form ?? [];
|
|
3216
|
+
const filingDates = r?.filingDate ?? [];
|
|
2872
3217
|
|
|
2873
|
-
|
|
3218
|
+
// `searchPhrases` is parsed up here, rather than beside its use further down,
|
|
3219
|
+
// because the auto-selection walk below needs to know whether there is a
|
|
3220
|
+
// phrase that can distinguish "this is the filing the caller meant" from
|
|
3221
|
+
// "this filing does not mention the subject at all".
|
|
3222
|
+
//
|
|
3223
|
+
// fleet #2450: a question can ask for TWO facts from the SAME filing
|
|
3224
|
+
// ("revenue AND test volume") and ask_pipeworx's router picks ONE tool call
|
|
3225
|
+
// for a single-entity question — so a single `search` phrase per call meant
|
|
3226
|
+
// the second fact was structurally unreachable. `search` accepts multiple
|
|
3227
|
+
// phrases separated by `|` (same convention as `form_type`'s "10-K|10-Q"
|
|
3228
|
+
// set) and reports each phrase's passages independently under `searches`,
|
|
3229
|
+
// keyed by `phrase`. A single phrase (no `|`) keeps the original singular
|
|
3230
|
+
// `search` shape unchanged.
|
|
3231
|
+
const searchPhrases = [...new Set(
|
|
3232
|
+
(searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean),
|
|
3233
|
+
)].slice(0, 5);
|
|
3234
|
+
|
|
3235
|
+
// Everything downstream that depends on WHICH filing this is, as one step,
|
|
3236
|
+
// so the walk below can evaluate more than one candidate.
|
|
3237
|
+
const loadFiling = async (chosen: { dashed: string; nodash: string }) => {
|
|
3238
|
+
const i = accs.indexOf(chosen.dashed);
|
|
3239
|
+
const form = i >= 0 ? forms[i] ?? null : null;
|
|
3240
|
+
const filedOn = i >= 0 ? filingDates[i] ?? null : null;
|
|
3241
|
+
let primaryDoc = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
|
|
3242
|
+
|
|
3243
|
+
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${chosen.nodash}`;
|
|
3244
|
+
// If the accession wasn't in the recent index (older filing), fall back to the
|
|
3245
|
+
// folder index to find the primary document.
|
|
3246
|
+
if (!primaryDoc) {
|
|
3247
|
+
const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
|
|
3248
|
+
if (idxRes.status === 404) throw new Error(`No filing found for accession ${chosen.dashed} — it does not exist in SEC EDGAR for this filer. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"}) to list real accessions.`);
|
|
3249
|
+
if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
|
|
3250
|
+
const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
|
|
3251
|
+
const docs = idxJson.directory?.item ?? [];
|
|
3252
|
+
// Prefer the main htm document that isn't an exhibit/XBRL artifact.
|
|
3253
|
+
primaryDoc =
|
|
3254
|
+
docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
|
|
3255
|
+
if (!primaryDoc) throw new Error(`Could not identify the primary document for accession ${chosen.dashed}. Use edgar_filing_documents to list the filing's files and fetch one by URL.`);
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
const url = `${folder}/${primaryDoc}`;
|
|
3259
|
+
const { text: raw, truncated: rawTrunc } = await fetchTextBounded(url, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
|
|
3260
|
+
const html = /\.x?html?$/i.test(primaryDoc) || /^\s*</.test(raw);
|
|
3261
|
+
return {
|
|
3262
|
+
acc: chosen,
|
|
3263
|
+
formType: form,
|
|
3264
|
+
filingDate: filedOn,
|
|
3265
|
+
primaryDocument: primaryDoc,
|
|
3266
|
+
docUrl: url,
|
|
3267
|
+
rawTruncated: rawTrunc,
|
|
3268
|
+
fullText: html ? htmlToText(raw) : raw,
|
|
3269
|
+
};
|
|
3270
|
+
};
|
|
3271
|
+
|
|
3272
|
+
// ── WHEN WE CHOSE THE FILING, SAY SO (fleet #3319) ───────────────────────
|
|
3273
|
+
//
|
|
3274
|
+
// With `accession` omitted this tool resolves the LATEST filing of
|
|
3275
|
+
// `form_type` — and then every field in the response describes that filing
|
|
3276
|
+
// as though the caller had asked for it. Measured live 2026-10-18: a
|
|
3277
|
+
// grounded question about "Tesla's September 29 2026 8-K" reached here as
|
|
3278
|
+
// {ticker:"TSLA", form_type:"8-K", search:"credit facility"}, resolved to
|
|
3279
|
+
// the 2026-10-02 8-K (a different filing, 3,061 chars, zero matches), and
|
|
3280
|
+
// refused `not_in_source` 5 tries out of 5 — while the filing asked about
|
|
3281
|
+
// sat two rows down the same index with all three figures in it. Nothing in
|
|
3282
|
+
// the envelope could tell a reader that a choice had been made at all, so
|
|
3283
|
+
// "wrong filing" and "filing is silent on this" were indistinguishable.
|
|
3284
|
+
//
|
|
3285
|
+
// Two changes, both narrow: a `filed` date goes straight to the right
|
|
3286
|
+
// filing, and when only a `search` is available the walk below reads on past
|
|
3287
|
+
// a candidate whose text does not contain the phrase AT ALL. Either way the
|
|
3288
|
+
// choice, and the candidates not chosen, are stated in `auto_selected`.
|
|
3289
|
+
let loaded: Awaited<ReturnType<typeof loadFiling>>;
|
|
3290
|
+
let autoSelected: Record<string, unknown> | null = null;
|
|
3291
|
+
|
|
3292
|
+
if (acc) {
|
|
3293
|
+
loaded = await loadFiling(acc);
|
|
3294
|
+
} else {
|
|
2874
3295
|
const wantForm = wantFormTypeArg?.trim().toUpperCase();
|
|
2875
|
-
const
|
|
2876
|
-
if (
|
|
2877
|
-
throw new Error(`
|
|
3296
|
+
const filedPrefix = normalizeFiledPrefix(filedArg);
|
|
3297
|
+
if (filedArg && !filedPrefix) {
|
|
3298
|
+
throw new Error(`Could not read "${filedArg}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`);
|
|
3299
|
+
}
|
|
3300
|
+
const candidateIdx = findFormIndices(forms, wantFormTypeArg, filingDates, filedPrefix, AUTO_SELECT_LIST)
|
|
3301
|
+
.filter((idx) => !!accs[idx] && !!normalizeAccession(accs[idx]));
|
|
3302
|
+
if (!candidateIdx.length) {
|
|
3303
|
+
const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
|
|
3304
|
+
throw new Error(`No ${wantForm ?? 'recent'} filing${dateClause} found for ${tickerOrCik}. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list filings, then pass an accession.`);
|
|
2878
3305
|
}
|
|
2879
|
-
acc = normalizeAccession(accs[idx]);
|
|
2880
|
-
if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
|
|
2881
|
-
}
|
|
2882
3306
|
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
3307
|
+
const candidates = candidateIdx.map((idx) => ({
|
|
3308
|
+
accession: normalizeAccession(accs[idx])!.dashed,
|
|
3309
|
+
form: forms[idx] ?? null,
|
|
3310
|
+
filed: filingDates[idx] ?? null,
|
|
3311
|
+
}));
|
|
2887
3312
|
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
const
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
3313
|
+
// Reading past the first candidate is only useful when a `search` can
|
|
3314
|
+
// score one — with no phrase there is nothing to tell the candidates
|
|
3315
|
+
// apart, so the newest is taken exactly as before. EXACT matches only:
|
|
3316
|
+
// the word-level fallback is "a lead, not a confirmation" by this tool's
|
|
3317
|
+
// own note, and scoring on it would stop the walk at a filing that merely
|
|
3318
|
+
// shares vocabulary.
|
|
3319
|
+
const walk = searchPhrases.length ? candidateIdx.slice(0, AUTO_SELECT_SCAN) : candidateIdx.slice(0, 1);
|
|
3320
|
+
const examined: Array<{ accession: string; form: string | null; filed: string | null; exact_matches?: number }> = [];
|
|
3321
|
+
let picked: Awaited<ReturnType<typeof loadFiling>> | null = null;
|
|
3322
|
+
let fallback: Awaited<ReturnType<typeof loadFiling>> | null = null;
|
|
3323
|
+
for (const idx of walk) {
|
|
3324
|
+
const cand = await loadFiling(normalizeAccession(accs[idx])!);
|
|
3325
|
+
const scoreText = sliceFilingSection(cand.fullText, section).text;
|
|
3326
|
+
const matches = searchPhrases.reduce((n, p) => n + findPassages(scoreText, p).total, 0);
|
|
3327
|
+
examined.push({
|
|
3328
|
+
accession: cand.acc.dashed,
|
|
3329
|
+
form: cand.formType,
|
|
3330
|
+
filed: cand.filingDate,
|
|
3331
|
+
...(searchPhrases.length ? { exact_matches: matches } : {}),
|
|
3332
|
+
});
|
|
3333
|
+
fallback ??= cand; // the newest — the historical choice, and the fallback
|
|
3334
|
+
if (!searchPhrases.length || matches > 0) {
|
|
3335
|
+
picked = cand;
|
|
3336
|
+
break;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
loaded = picked ?? fallback!;
|
|
3340
|
+
const walkedOn = examined.length > 1 && picked !== null;
|
|
3341
|
+
const allSilent = searchPhrases.length > 0 && picked === null;
|
|
3342
|
+
|
|
3343
|
+
autoSelected = {
|
|
3344
|
+
// The one sentence a reader needs: nobody asked for THIS filing.
|
|
3345
|
+
reason: `No accession was given, so this tool CHOSE the filing below — ${filedPrefix ? `the newest ${wantForm ?? 'filing'} dated ${filedPrefix}` : wantForm ? `the newest ${wantForm}` : 'the single most recent filing of ANY type'}${walkedOn ? ', after the newer candidate(s) in `examined` contained no exact match for the search phrase(s)' : ''}${allSilent ? ', and NONE of the candidates examined contained an exact match for the search phrase(s) — this is the newest one, not a filing shown to be relevant' : ''}.`,
|
|
3346
|
+
requested: { form_type: wantForm ?? null, filed: filedPrefix ?? null },
|
|
3347
|
+
chosen: { accession: loaded.acc.dashed, form: loaded.formType, filed: loaded.filingDate },
|
|
3348
|
+
examined,
|
|
3349
|
+
// Everything the caller might have meant, whether or not it was read —
|
|
3350
|
+
// free, since it comes out of the submissions payload already in hand.
|
|
3351
|
+
candidates,
|
|
3352
|
+
note: `If the question named a particular filing, pass its \`accession\` (or \`filed\`: "${loaded.filingDate ?? '2026-09-29'}") rather than relying on this choice — a company files many ${wantForm ?? 'documents'}s and the newest is routinely not the one asked about.`,
|
|
3353
|
+
...(allSilent
|
|
3354
|
+
? {
|
|
3355
|
+
warning: `Treat a zero-match result here as INCONCLUSIVE about the company, not about this filing: ${examined.length} candidate filing(s) were read and none mentions the search phrase(s). The filing the question meant may be one of \`candidates\` that was not read.`,
|
|
3356
|
+
}
|
|
3357
|
+
: {}),
|
|
3358
|
+
};
|
|
2901
3359
|
}
|
|
2902
3360
|
|
|
2903
|
-
|
|
2904
|
-
const {
|
|
2905
|
-
|
|
2906
|
-
|
|
3361
|
+
acc = loaded.acc;
|
|
3362
|
+
const { formType, filingDate, primaryDocument, docUrl, rawTruncated } = loaded;
|
|
3363
|
+
let fullText = loaded.fullText;
|
|
3364
|
+
// Held before any narrowing below, because `total_chars` is reframed by a
|
|
3365
|
+
// section slice and then describes the SLICE — which is what this tool's own
|
|
3366
|
+
// offsets page against, so it cannot also carry the document's size. Without
|
|
3367
|
+
// this, a section slice and a search both shrink what we return while every
|
|
3368
|
+
// number in the response still says "whole document" (fleet #3316).
|
|
3369
|
+
const documentChars = fullText.length;
|
|
2907
3370
|
|
|
2908
3371
|
// Optional section slice: locate the section heading (last occurrence = the
|
|
2909
3372
|
// real body, past any table-of-contents mention) and reframe the text to start
|
|
2910
3373
|
// there. `section` is best-effort — an unmatched section returns the whole doc.
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
const
|
|
2914
|
-
|
|
2915
|
-
const conf = FILING_SECTION_PATTERNS[sec];
|
|
2916
|
-
if (!conf) {
|
|
2917
|
-
sectionFound = `unknown_section:${sec}`;
|
|
2918
|
-
} else {
|
|
2919
|
-
const idxs: number[] = [];
|
|
2920
|
-
for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
|
|
2921
|
-
const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
|
|
2922
|
-
if (chosen >= 0) {
|
|
2923
|
-
// Back up a little so the section heading itself is included, not clipped mid-phrase.
|
|
2924
|
-
fullText = fullText.slice(Math.max(0, chosen - 120));
|
|
2925
|
-
sectionApplied = sec;
|
|
2926
|
-
sectionFound = true;
|
|
2927
|
-
} else {
|
|
2928
|
-
sectionFound = false;
|
|
2929
|
-
}
|
|
2930
|
-
}
|
|
2931
|
-
}
|
|
3374
|
+
const sliced = sliceFilingSection(fullText, section);
|
|
3375
|
+
fullText = sliced.text;
|
|
3376
|
+
const sectionApplied = sliced.applied;
|
|
3377
|
+
const sectionFound = sliced.found;
|
|
2932
3378
|
|
|
2933
3379
|
// `search` short-circuits the normal offset/max_chars windowing: it scans
|
|
2934
3380
|
// the whole (possibly section-sliced) document and returns matching
|
|
2935
3381
|
// passages with their own offsets, rather than one page of raw text the
|
|
2936
3382
|
// caller has to eyeball for the fact they wanted (fleet #2446).
|
|
2937
3383
|
//
|
|
2938
|
-
//
|
|
2939
|
-
// ("revenue AND test volume") and ask_pipeworx's router picks ONE tool call
|
|
2940
|
-
// for a single-entity question — so a single `search` phrase per call meant
|
|
2941
|
-
// the second fact was structurally unreachable. `search` now accepts
|
|
2942
|
-
// multiple phrases separated by `|` (same convention as `form_type`'s
|
|
2943
|
-
// "10-K|10-Q" set) and reports each phrase's passages independently under
|
|
2944
|
-
// `searches`, keyed by `phrase`. A single phrase (no `|`) keeps the original
|
|
2945
|
-
// singular `search` shape unchanged.
|
|
2946
|
-
const searchPhrases = [...new Set(
|
|
2947
|
-
(searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean),
|
|
2948
|
-
)].slice(0, 5);
|
|
3384
|
+
// (`searchPhrases` is parsed near the top — the auto-selection walk needs it.)
|
|
2949
3385
|
if (searchPhrases.length > 0) {
|
|
2950
3386
|
const buildOne = async (term: string) => {
|
|
2951
3387
|
const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
|
|
@@ -2989,17 +3425,56 @@ async function filingText(
|
|
|
2989
3425
|
|
|
2990
3426
|
const results = await Promise.all(searchPhrases.map(buildOne));
|
|
2991
3427
|
|
|
3428
|
+
// ── HOW MUCH OF THE DOCUMENT THIS RESPONSE ACTUALLY CARRIES (fleet #3316) ──
|
|
3429
|
+
//
|
|
3430
|
+
// A `search` returns matched passages INSTEAD of the text, and before this
|
|
3431
|
+
// every size field in the envelope described the document rather than the
|
|
3432
|
+
// answer: `total_chars: 9859` for a response holding 3,080 chars of it.
|
|
3433
|
+
// The one field that looked like a loss signal, `search.truncated`, is
|
|
3434
|
+
// about the SEARCH — false means "every distinct match location is here" —
|
|
3435
|
+
// so a reader checking for loss found a field saying there was none.
|
|
3436
|
+
//
|
|
3437
|
+
// That is not merely untidy. ask_pipeworx_grounded sizes its own
|
|
3438
|
+
// truncation by what it RECEIVES, so a narrowed payload fit its budget
|
|
3439
|
+
// whole and was reported to the extraction model as complete: measured
|
|
3440
|
+
// live 2026-10-18, 5 of 8 tries of "what credit facilities did Tesla enter
|
|
3441
|
+
// into" answered with a WRONG total at confidence 0.4-0.6 and no refusal,
|
|
3442
|
+
// off exactly this response shape.
|
|
3443
|
+
//
|
|
3444
|
+
// `returned_chars` / `truncated` therefore mean the same thing here as in
|
|
3445
|
+
// the paged branch below — how much came back, and whether that is all of
|
|
3446
|
+
// it. `next_offset` stays ABSENT: there is no page forward from a set of
|
|
3447
|
+
// passages, and the way to read more is a follow-up call at a passage's
|
|
3448
|
+
// own `offset`. Absent is a different fact from null and says so.
|
|
3449
|
+
const returnedChars = results.reduce(
|
|
3450
|
+
(n, r) => n + r.passages.reduce((m, psg) => m + psg.text.length, 0),
|
|
3451
|
+
0,
|
|
3452
|
+
);
|
|
3453
|
+
const searchTruncated = returnedChars < documentChars;
|
|
3454
|
+
|
|
2992
3455
|
return {
|
|
2993
3456
|
accession: acc.dashed,
|
|
2994
3457
|
cik: cikNoZeros,
|
|
2995
3458
|
company: sub.name ?? null,
|
|
2996
3459
|
form: formType,
|
|
2997
3460
|
filed: filingDate,
|
|
3461
|
+
// Whether the caller NAMED this filing or we picked it (fleet #3319).
|
|
3462
|
+
// Present on every response so its absence is never the signal.
|
|
3463
|
+
accession_source: autoSelected ? ('auto_selected' as const) : ('caller' as const),
|
|
3464
|
+
...(autoSelected ? { auto_selected: autoSelected } : {}),
|
|
2998
3465
|
document: primaryDocument,
|
|
2999
3466
|
document_url: docUrl,
|
|
3000
3467
|
section: sectionApplied,
|
|
3001
3468
|
...(sectionFound !== null ? { section_found: sectionFound } : {}),
|
|
3002
3469
|
total_chars: fullText.length,
|
|
3470
|
+
...(fullText.length !== documentChars ? { document_chars: documentChars } : {}),
|
|
3471
|
+
returned_chars: returnedChars,
|
|
3472
|
+
truncated: searchTruncated,
|
|
3473
|
+
...(searchTruncated
|
|
3474
|
+
? {
|
|
3475
|
+
truncated_note: `These are matched passages, not the document: ${returnedChars} of ${documentChars} characters. Anything the phrase(s) did not match is NOT in this response — do not read a figure's absence here as absence from the filing. Call again without \`search\` (or with a passage's \`offset\`) to read the surrounding text.`,
|
|
3476
|
+
}
|
|
3477
|
+
: {}),
|
|
3003
3478
|
...(searchPhrases.length === 1
|
|
3004
3479
|
? { search: results[0] }
|
|
3005
3480
|
: {
|
|
@@ -3028,11 +3503,16 @@ async function filingText(
|
|
|
3028
3503
|
company: sub.name ?? null,
|
|
3029
3504
|
form: formType,
|
|
3030
3505
|
filed: filingDate,
|
|
3506
|
+
accession_source: autoSelected ? ('auto_selected' as const) : ('caller' as const),
|
|
3507
|
+
...(autoSelected ? { auto_selected: autoSelected } : {}),
|
|
3031
3508
|
document: primaryDocument,
|
|
3032
3509
|
document_url: docUrl,
|
|
3033
3510
|
section: sectionApplied,
|
|
3034
3511
|
...(sectionFound !== null ? { section_found: sectionFound } : {}),
|
|
3035
3512
|
total_chars: total,
|
|
3513
|
+
// A section slice reframed `total_chars` to the slice (fleet #3316): say so,
|
|
3514
|
+
// or a 2,000-char section of a 400,000-char 10-K reads as a whole document.
|
|
3515
|
+
...(total !== documentChars ? { document_chars: documentChars } : {}),
|
|
3036
3516
|
offset: off,
|
|
3037
3517
|
returned_chars: slice.length,
|
|
3038
3518
|
truncated,
|
|
@@ -5201,6 +5681,9 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
5201
5681
|
companyArg(args),
|
|
5202
5682
|
args.form_type as string | undefined,
|
|
5203
5683
|
args.limit as number | undefined,
|
|
5684
|
+
args.accession as string | undefined,
|
|
5685
|
+
args.include_primary_text as boolean | undefined,
|
|
5686
|
+
(args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
|
|
5204
5687
|
);
|
|
5205
5688
|
case 'edgar_company_facts':
|
|
5206
5689
|
return companyFacts(companyArg(args));
|
|
@@ -5245,6 +5728,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
5245
5728
|
(args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
|
|
5246
5729
|
args.include_primary_text as boolean | undefined,
|
|
5247
5730
|
args.form_type as string | undefined,
|
|
5731
|
+
(args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
|
|
5248
5732
|
);
|
|
5249
5733
|
case 'edgar_filing_text':
|
|
5250
5734
|
return filingText(
|
|
@@ -5255,6 +5739,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
5255
5739
|
args.offset as number | undefined,
|
|
5256
5740
|
args.form_type as string | undefined,
|
|
5257
5741
|
(args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
|
|
5742
|
+
(args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
|
|
5258
5743
|
);
|
|
5259
5744
|
case 'edgar_ticker_to_cik':
|
|
5260
5745
|
return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
|