@cyanheads/secedgar-mcp-server 0.13.1 → 0.15.0
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 +63 -7
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/company-analysis.prompt.d.ts.map +1 -1
- package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js +11 -7
- package/dist/mcp-server/prompts/definitions/company-analysis.prompt.js.map +1 -1
- package/dist/mcp-server/resources/definitions/filing-types.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/filing-types.resource.js +24 -2
- package/dist/mcp-server/resources/definitions/filing-types.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/compare-companies.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/compare-companies.tool.js +25 -11
- package/dist/mcp-server/tools/definitions/compare-companies.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-holders.tool.d.ts +61 -0
- package/dist/mcp-server/tools/definitions/find-holders.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/find-holders.tool.js +396 -0
- package/dist/mcp-server/tools/definitions/find-holders.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts +93 -0
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js +457 -0
- package/dist/mcp-server/tools/definitions/get-beneficial-owners.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/get-financials.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-financials.tool.js +34 -17
- package/dist/mcp-server/tools/definitions/get-financials.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts +88 -0
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js +674 -0
- package/dist/mcp-server/tools/definitions/get-fund-holdings.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.d.ts +3 -2
- package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.js +11 -6
- package/dist/mcp-server/tools/definitions/get-institutional-holdings.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-material-events.tool.d.ts +63 -0
- package/dist/mcp-server/tools/definitions/get-material-events.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/get-material-events.tool.js +380 -0
- package/dist/mcp-server/tools/definitions/get-material-events.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/get-snapshot.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-snapshot.tool.js +30 -8
- package/dist/mcp-server/tools/definitions/get-snapshot.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts +10 -13
- package/dist/mcp-server/tools/definitions/search-filings.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-filings.tool.js +295 -133
- package/dist/mcp-server/tools/definitions/search-filings.tool.js.map +1 -1
- package/dist/services/edgar/beneficial-ownership-parser.d.ts +70 -0
- package/dist/services/edgar/beneficial-ownership-parser.d.ts.map +1 -0
- package/dist/services/edgar/beneficial-ownership-parser.js +157 -0
- package/dist/services/edgar/beneficial-ownership-parser.js.map +1 -0
- package/dist/services/edgar/concept-map.d.ts +3 -1
- package/dist/services/edgar/concept-map.d.ts.map +1 -1
- package/dist/services/edgar/concept-map.js +45 -5
- package/dist/services/edgar/concept-map.js.map +1 -1
- package/dist/services/edgar/concept-series.d.ts +94 -22
- package/dist/services/edgar/concept-series.d.ts.map +1 -1
- package/dist/services/edgar/concept-series.js +171 -33
- package/dist/services/edgar/concept-series.js.map +1 -1
- package/dist/services/edgar/edgar-api-service.d.ts +85 -0
- package/dist/services/edgar/edgar-api-service.d.ts.map +1 -1
- package/dist/services/edgar/edgar-api-service.js +153 -1
- package/dist/services/edgar/edgar-api-service.js.map +1 -1
- package/dist/services/edgar/eight-k-items.d.ts +62 -0
- package/dist/services/edgar/eight-k-items.d.ts.map +1 -0
- package/dist/services/edgar/eight-k-items.js +131 -0
- package/dist/services/edgar/eight-k-items.js.map +1 -0
- package/dist/services/edgar/nport-parser.d.ts +68 -0
- package/dist/services/edgar/nport-parser.d.ts.map +1 -0
- package/dist/services/edgar/nport-parser.js +102 -0
- package/dist/services/edgar/nport-parser.js.map +1 -0
- package/dist/services/edgar/types.d.ts +39 -0
- package/dist/services/edgar/types.d.ts.map +1 -1
- package/dist/services/edgar/xml-nodes.d.ts +31 -0
- package/dist/services/edgar/xml-nodes.d.ts.map +1 -0
- package/dist/services/edgar/xml-nodes.js +74 -0
- package/dist/services/edgar/xml-nodes.js.map +1 -0
- package/package.json +1 -1
- package/server.json +3 -3
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Search EDGAR filings since 1993 — EFTS full-text for 2001-present,
|
|
3
3
|
* archive-backed browse (submissions history + quarterly full-index) for pre-2001
|
|
4
|
-
* date ranges
|
|
4
|
+
* date ranges, a bounded local document scan for pre-2001 entity-scoped free text,
|
|
5
|
+
* and an auto-split merge for a range straddling the full-text floor.
|
|
5
6
|
* @module mcp-server/tools/definitions/search-filings
|
|
6
7
|
*/
|
|
7
8
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
8
9
|
import { JsonRpcErrorCode, McpError, notFound, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
9
10
|
import { getCanvasBridge, toDatasetField } from '../../../services/canvas-bridge/canvas-bridge.js';
|
|
10
|
-
import { getEdgarApiService, quartersInRange, selectArchivePages, } from '../../../services/edgar/edgar-api-service.js';
|
|
11
|
+
import { cleanDisplayName, getEdgarApiService, quartersInRange, selectArchivePages, } from '../../../services/edgar/edgar-api-service.js';
|
|
12
|
+
import { filingToExtract } from '../../../services/edgar/filing-to-text.js';
|
|
11
13
|
/**
|
|
12
14
|
* The EFTS full-text index only covers filings from this date onward, even
|
|
13
15
|
* though the archives reach 1993. A date range entirely before this floor is
|
|
14
|
-
* routed to the archive-backed paths; a range straddling it is
|
|
16
|
+
* routed to the archive-backed paths; a range straddling it is split at the
|
|
17
|
+
* floor and the two sides merged (#87).
|
|
15
18
|
*/
|
|
16
19
|
const EFTS_FULLTEXT_FLOOR = '2001-01-01';
|
|
20
|
+
/** Last day the archive arms serve when a range is split at {@link EFTS_FULLTEXT_FLOOR}. */
|
|
21
|
+
const ARCHIVE_ERA_END = '2000-12-31';
|
|
17
22
|
/**
|
|
18
23
|
* Cap on submissions archive pages fetched in one call (pre-2001 entity-scoped
|
|
19
24
|
* arm) — bounds latency and the rate-limited request budget. Mirrors the same
|
|
@@ -28,6 +33,14 @@ const ARCHIVE_PAGE_SCAN_CAP = 10;
|
|
|
28
33
|
* hard, and `dataset.truncated` discloses when older quarters went unscanned.
|
|
29
34
|
*/
|
|
30
35
|
const FULL_INDEX_QUARTER_SCAN_CAP = 8;
|
|
36
|
+
/**
|
|
37
|
+
* Cap on filing documents fetched in one pre-2001 local text scan. The cost is
|
|
38
|
+
* SEC's rate limit, not the work: a document fetches in ~47ms and converts in
|
|
39
|
+
* ~16ms, so at `EDGAR_RATE_LIMIT_RPS` (default 10) a full 50-document scan is
|
|
40
|
+
* ~5s of throttling and little else. `scan.capped` discloses when candidates
|
|
41
|
+
* were left unscanned (#87).
|
|
42
|
+
*/
|
|
43
|
+
const DOCUMENT_SCAN_CAP = 50;
|
|
31
44
|
function eftsHitToRow(hit) {
|
|
32
45
|
const displayName = hit._source.display_names?.[0] || '';
|
|
33
46
|
return {
|
|
@@ -35,7 +48,7 @@ function eftsHitToRow(hit) {
|
|
|
35
48
|
form: hit._source.form ?? null,
|
|
36
49
|
filing_date: hit._source.file_date,
|
|
37
50
|
period_ending: hit._source.period_ending ?? null,
|
|
38
|
-
company_name:
|
|
51
|
+
company_name: cleanDisplayName(displayName),
|
|
39
52
|
cik: hit._source.ciks?.[0] ?? null,
|
|
40
53
|
ticker: extractTicker(displayName),
|
|
41
54
|
file_description: hit._source.file_description ?? null,
|
|
@@ -82,18 +95,6 @@ function buildFormDistribution(forms) {
|
|
|
82
95
|
}
|
|
83
96
|
return Object.keys(dist).length > 0 ? dist : undefined;
|
|
84
97
|
}
|
|
85
|
-
/**
|
|
86
|
-
* EDGAR's `display_names[0]` embeds the ticker(s) and CIK in trailing parentheticals
|
|
87
|
-
* (e.g., "Apple Inc. (AAPL) (CIK 0000320193)"). Strip them so consumers see a clean
|
|
88
|
-
* company name — ticker and CIK are already surfaced as their own fields.
|
|
89
|
-
*/
|
|
90
|
-
function cleanCompanyName(displayName) {
|
|
91
|
-
return displayName
|
|
92
|
-
.replace(/\s*\(CIK\s*\d+\)/gi, '')
|
|
93
|
-
.replace(/\s*\([A-Z0-9,\s.-]+\)\s*$/, '')
|
|
94
|
-
.replace(/\s+/g, ' ')
|
|
95
|
-
.trim();
|
|
96
|
-
}
|
|
97
98
|
/**
|
|
98
99
|
* Pull the primary ticker out of `display_names[0]`. Strips the CIK parenthetical
|
|
99
100
|
* first, then captures the trailing parens iff they look like ticker symbols
|
|
@@ -146,20 +147,84 @@ async function resolveEntityTargeting(query, ctx) {
|
|
|
146
147
|
return { query };
|
|
147
148
|
}
|
|
148
149
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
150
|
+
* Compile one query term to a matcher over converted filing text. Bare words and
|
|
151
|
+
* phrases are word-boundary anchored so `revenue` does not match `unrevenued`;
|
|
152
|
+
* a `*` suffix drops the trailing boundary for prefix matching. Whitespace inside
|
|
153
|
+
* a phrase matches any run, because line wrapping in the converted text is not
|
|
154
|
+
* the filer's.
|
|
155
|
+
*/
|
|
156
|
+
function termToRegExp(term) {
|
|
157
|
+
const wildcard = term.endsWith('*');
|
|
158
|
+
const body = wildcard ? term.slice(0, -1) : term;
|
|
159
|
+
const escaped = body.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
|
160
|
+
const lead = /^\w/.test(body) ? '\\b' : '';
|
|
161
|
+
const tail = wildcard ? '' : /\w$/.test(body) ? '\\b' : '';
|
|
162
|
+
return new RegExp(`${lead}${escaped}${tail}`, 'i');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Parse a free-text query into {@link LocalQuery}. Tokens are whitespace-separated
|
|
166
|
+
* except inside double quotes; a leading `-` marks an exclusion; a bare uppercase
|
|
167
|
+
* `OR` opens a new alternative group (EDGAR's own convention — lowercase "or" is
|
|
168
|
+
* an ordinary search term).
|
|
154
169
|
*/
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
170
|
+
function parseLocalQuery(query) {
|
|
171
|
+
const groups = [];
|
|
172
|
+
const exclusions = [];
|
|
173
|
+
let current = [];
|
|
174
|
+
for (const token of query.match(/-?"[^"]*"|\S+/g) ?? []) {
|
|
175
|
+
if (token === 'OR') {
|
|
176
|
+
groups.push(current);
|
|
177
|
+
current = [];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const negated = token.startsWith('-');
|
|
181
|
+
const term = (negated ? token.slice(1) : token).replaceAll('"', '').trim();
|
|
182
|
+
if (!term)
|
|
183
|
+
continue;
|
|
184
|
+
if (negated)
|
|
185
|
+
exclusions.push(termToRegExp(term));
|
|
186
|
+
else
|
|
187
|
+
current.push(termToRegExp(term));
|
|
188
|
+
}
|
|
189
|
+
groups.push(current);
|
|
190
|
+
return { groups: groups.filter((group) => group.length > 0), exclusions };
|
|
191
|
+
}
|
|
192
|
+
/** True when the converted filing text satisfies the compiled query. */
|
|
193
|
+
function matchesLocalQuery(text, parsed) {
|
|
194
|
+
if (parsed.exclusions.some((re) => re.test(text)))
|
|
195
|
+
return false;
|
|
196
|
+
if (parsed.groups.length === 0)
|
|
197
|
+
return true;
|
|
198
|
+
return parsed.groups.some((group) => group.every((re) => re.test(text)));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Order rows for the inline window. The archives carry no relevance score, so
|
|
202
|
+
* relevance collapses to date-descending; only an explicit ascending request
|
|
203
|
+
* reorders. Also picks which end of a candidate list the scan cap keeps.
|
|
204
|
+
*/
|
|
205
|
+
function sortRows(rows, sort) {
|
|
206
|
+
return [...rows].sort((a, b) => sort === 'filing_date_asc'
|
|
159
207
|
? a.filing_date.localeCompare(b.filing_date)
|
|
160
208
|
: b.filing_date.localeCompare(a.filing_date));
|
|
161
|
-
|
|
162
|
-
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Shared tail for every row set this tool assembles in memory (the pre-2001 arms
|
|
212
|
+
* and the straddling merge): sort, slice the inline window, recompute the form
|
|
213
|
+
* distribution, register the full set to the canvas (source-tagged), and emit the
|
|
214
|
+
* query echo / boundary notices.
|
|
215
|
+
*
|
|
216
|
+
* **`offset` model.** `offset` indexes the sorted row list assembled by this call
|
|
217
|
+
* — every row the tool holds, across every source it read. The 2001+ full-text
|
|
218
|
+
* path instead offsets into a single ≤100-row EFTS window (or, under
|
|
219
|
+
* sort=relevance, EDGAR's server-side pagination). The assembled-list model is
|
|
220
|
+
* the only one a merge can honor: once archive rows interleave with EFTS rows by
|
|
221
|
+
* date, an offset relative to either source's own window addresses nothing the
|
|
222
|
+
* caller can see. `total` may exceed the assembled row count (EFTS reports its
|
|
223
|
+
* true match count while returning one window), so paging past the rows in hand
|
|
224
|
+
* is a real state — the offset-exceeded notice names it.
|
|
225
|
+
*/
|
|
226
|
+
async function finishAssembledResult(ctx, args) {
|
|
227
|
+
const sorted = sortRows(args.matched, args.sort);
|
|
163
228
|
const sliced = sorted.slice(args.offset, args.offset + args.limit);
|
|
164
229
|
const results = sliced.map(toInlineResult);
|
|
165
230
|
const formDistribution = buildFormDistribution(sorted.map((row) => row.form));
|
|
@@ -170,44 +235,45 @@ async function finishArchiveResult(ctx, args) {
|
|
|
170
235
|
rows: sorted,
|
|
171
236
|
sourceTool: 'secedgar_search_filings',
|
|
172
237
|
queryParams: args.queryParams,
|
|
173
|
-
truncated: args.
|
|
238
|
+
truncated: args.truncated,
|
|
174
239
|
});
|
|
175
240
|
if (registered)
|
|
176
|
-
dataset = { ...toDatasetField(registered), truncated: args.
|
|
241
|
+
dataset = { ...toDatasetField(registered), truncated: args.truncated };
|
|
177
242
|
}
|
|
178
243
|
ctx.enrich.echo(args.effectiveQuery);
|
|
179
|
-
if (total === 0) {
|
|
244
|
+
if (args.total === 0) {
|
|
180
245
|
ctx.enrich.notice(`No filings matched ${args.zeroHitCriteria}. ${args.coverageNote}`);
|
|
181
246
|
}
|
|
182
247
|
else if (results.length === 0 && args.offset >= sorted.length) {
|
|
183
|
-
ctx.enrich.notice(`Offset (${args.offset}) exceeds the ${sorted.length}
|
|
248
|
+
ctx.enrich.notice(`Offset (${args.offset}) exceeds the ${sorted.length} rows this search assembled. ${args.total} filings matched — lower the offset or query the full set via secedgar_dataframe_query.`);
|
|
184
249
|
}
|
|
185
|
-
else if (total > results.length) {
|
|
250
|
+
else if (args.total > results.length) {
|
|
186
251
|
ctx.enrich.truncated({
|
|
187
252
|
shown: results.length,
|
|
188
253
|
cap: args.limit,
|
|
189
|
-
guidance: args.
|
|
190
|
-
? `${args.coverageNote}
|
|
254
|
+
guidance: args.truncated
|
|
255
|
+
? `${args.coverageNote} More matches exist beyond the rows fetched — narrow the range, or query the dataframe.`
|
|
191
256
|
: `${args.coverageNote} Query the full matched set via secedgar_dataframe_query, or page with offset.`,
|
|
192
257
|
});
|
|
193
258
|
}
|
|
194
259
|
return {
|
|
195
|
-
total,
|
|
196
|
-
total_is_exact: totalIsExact,
|
|
260
|
+
total: args.total,
|
|
261
|
+
total_is_exact: args.totalIsExact,
|
|
197
262
|
results,
|
|
198
263
|
form_distribution: formDistribution,
|
|
264
|
+
scan: args.scan,
|
|
199
265
|
dataset,
|
|
200
266
|
};
|
|
201
267
|
}
|
|
202
268
|
/**
|
|
203
|
-
* Pre-2001 entity-scoped
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
269
|
+
* Pre-2001 entity-scoped rows: the CIK's full submissions history (the `recent`
|
|
270
|
+
* window plus the `filings.files[]` archive pages reused from #78) filtered by
|
|
271
|
+
* form + date. Reuses `getSubmissions`/`selectArchivePages`/`fetchArchivePage`
|
|
272
|
+
* as-is. Rows are tagged `source: 'submissions'`; the EFTS-only fields
|
|
273
|
+
* (period_ending, ticker, file_description, sic, location) are left null for a
|
|
274
|
+
* uniform archive-row shape.
|
|
209
275
|
*/
|
|
210
|
-
async function
|
|
276
|
+
async function collectSubmissionsRows(ctx, args) {
|
|
211
277
|
const api = getEdgarApiService();
|
|
212
278
|
// A `cik:` targeting token is only shape-validated (1-10 digits), never checked
|
|
213
279
|
// against the ticker cache, so a well-formed filing-agent/transmitter CIK reaches
|
|
@@ -267,32 +333,15 @@ async function serveSubmissionsArchive(ctx, args) {
|
|
|
267
333
|
break;
|
|
268
334
|
pushBlock(await api.fetchArchivePage(page.name));
|
|
269
335
|
}
|
|
270
|
-
|
|
271
|
-
return finishArchiveResult(ctx, {
|
|
272
|
-
matched: rows,
|
|
273
|
-
sort: args.sort,
|
|
274
|
-
limit: args.limit,
|
|
275
|
-
offset: args.offset,
|
|
276
|
-
scanTruncated,
|
|
277
|
-
queryParams: {
|
|
278
|
-
entity_cik: args.entityCik,
|
|
279
|
-
forms: args.forms,
|
|
280
|
-
start_date: args.startDate,
|
|
281
|
-
end_date: args.endDate,
|
|
282
|
-
source: 'submissions',
|
|
283
|
-
},
|
|
284
|
-
effectiveQuery: `(pre-2001 archive: CIK ${args.entityCik}${formsNote}, ${args.startDate} to ${args.endDate})`,
|
|
285
|
-
coverageNote: 'Served from the entity submissions history — full-text search covers 2001-present only.',
|
|
286
|
-
zeroHitCriteria: `CIK ${args.entityCik}${forms ? `, forms [${forms.join(', ')}]` : ''}, dates ${args.startDate} to ${args.endDate}`,
|
|
287
|
-
});
|
|
336
|
+
return { rows, scanTruncated };
|
|
288
337
|
}
|
|
289
338
|
/**
|
|
290
|
-
* Pre-2001 unscoped browse
|
|
339
|
+
* Pre-2001 unscoped browse rows: fetch the quarterly full-index (`master.idx`)
|
|
291
340
|
* files spanning the range and filter by form + date client-side (the index is
|
|
292
341
|
* not form-filterable server-side). Bounded by FULL_INDEX_QUARTER_SCAN_CAP —
|
|
293
342
|
* the heaviest arm. Rows are tagged `source: 'full-index'`.
|
|
294
343
|
*/
|
|
295
|
-
async function
|
|
344
|
+
async function collectFullIndexRows(args) {
|
|
296
345
|
const api = getEdgarApiService();
|
|
297
346
|
const quarters = quartersInRange(args.startDate, args.endDate);
|
|
298
347
|
const quarterLimit = Math.min(quarters.length, FULL_INDEX_QUARTER_SCAN_CAP);
|
|
@@ -323,25 +372,69 @@ async function serveFullIndexBrowse(ctx, args) {
|
|
|
323
372
|
});
|
|
324
373
|
}
|
|
325
374
|
}
|
|
326
|
-
return
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
375
|
+
return { rows, scanTruncated };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Read candidate filings and keep the ones whose text satisfies the query — the
|
|
379
|
+
* pre-2001 entity-scoped free-text arm. There is no pre-2001 full-text index, so
|
|
380
|
+
* the match is local and the candidate set must already be bounded by the form +
|
|
381
|
+
* date pre-filter that produced it.
|
|
382
|
+
*
|
|
383
|
+
* The unit fetched is the whole accession `.txt`. A pre-1997 filing's index lists
|
|
384
|
+
* its exhibits with **empty `name` fields**, so per-document URLs do not exist —
|
|
385
|
+
* SEC's original flat-submission format concatenates every exhibit into that one
|
|
386
|
+
* file as SGML `<DOCUMENT>` blocks (Apple's 1996 10-K holds 7). A hit can
|
|
387
|
+
* therefore land in an attached exhibit rather than the primary form's body,
|
|
388
|
+
* which is why the scan discloses its shape rather than presenting a match as
|
|
389
|
+
* necessarily being in the requested form.
|
|
390
|
+
*
|
|
391
|
+
* Candidates arrive pre-sorted, so the cap keeps the end the caller's `sort`
|
|
392
|
+
* asked for. Documents are fetched concurrently; `EdgarApiService`'s throttle
|
|
393
|
+
* serializes them at the configured request rate, which is the real cost.
|
|
394
|
+
*/
|
|
395
|
+
async function scanFilingText(ctx, candidates, parsed) {
|
|
396
|
+
const api = getEdgarApiService();
|
|
397
|
+
const targets = candidates.slice(0, DOCUMENT_SCAN_CAP);
|
|
398
|
+
const outcomes = await Promise.all(targets.map(async (row) => {
|
|
399
|
+
const text = await api.tryGetFilingDocument(row.cik ?? '', row.accession_number, `${row.accession_number}.txt`);
|
|
400
|
+
if (text === null)
|
|
401
|
+
return null;
|
|
402
|
+
return matchesLocalQuery(filingToExtract(text), parsed) ? row : null;
|
|
403
|
+
}));
|
|
404
|
+
const rows = outcomes.filter((row) => row !== null);
|
|
405
|
+
const scan = {
|
|
406
|
+
candidates: candidates.length,
|
|
407
|
+
scanned: targets.length,
|
|
408
|
+
matched: rows.length,
|
|
409
|
+
capped: candidates.length > targets.length,
|
|
410
|
+
};
|
|
411
|
+
ctx.log.info('Pre-2001 local text scan completed', scan);
|
|
412
|
+
return { rows, scan };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Fetch one 100-row EFTS window plus the index's own match total. Used by the
|
|
416
|
+
* straddling merge for the ≥2001 side, where the rows join an archive row set and
|
|
417
|
+
* are sorted and sliced together — so the window is always fetched from offset 0
|
|
418
|
+
* regardless of the caller's `offset`, which addresses the merged list instead.
|
|
419
|
+
*/
|
|
420
|
+
async function fetchEftsRows(args) {
|
|
421
|
+
const response = await getEdgarApiService().searchFilings({
|
|
422
|
+
query: args.query,
|
|
423
|
+
forms: args.forms,
|
|
424
|
+
ciks: args.entityCik ? [args.entityCik] : undefined,
|
|
425
|
+
startDate: args.startDate,
|
|
426
|
+
endDate: args.endDate,
|
|
427
|
+
from: 0,
|
|
428
|
+
size: 100,
|
|
341
429
|
});
|
|
430
|
+
return {
|
|
431
|
+
rows: response.hits.hits.map(eftsHitToRow),
|
|
432
|
+
total: response.hits.total.value,
|
|
433
|
+
totalIsExact: response.hits.total.relation === 'eq',
|
|
434
|
+
};
|
|
342
435
|
}
|
|
343
436
|
export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
344
|
-
description:
|
|
437
|
+
description: "Search EDGAR filings since 1993. Full-text search covers 2001-present (the EFTS index floor); pre-2001 date ranges (to 1993) are served from the archives by form and entity/date. Pre-2001 free text needs entity scope (ticker:/cik:) — with it, the tool reads the entity's matching filings and matches the terms locally, which costs a few seconds (SEC's request rate caps the scan at roughly 5s for the 50-document maximum). A range crossing 2001-01-01 is split at the boundary and the two eras merged, each row tagged with its source. Supports exact phrases, boolean operators, wildcards, and entity targeting (ticker:AAPL or cik:320193 in query).",
|
|
345
438
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
346
439
|
// Agent-facing context for the success path — the query as EDGAR executed it and
|
|
347
440
|
// an optional notice for empty results. Populated via ctx.enrich so it reaches
|
|
@@ -389,23 +482,11 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
389
482
|
when: 'Neither a full-text query nor a forms filter was provided (a date range cannot stand alone)',
|
|
390
483
|
recovery: 'Provide search terms, or browse by form type (forms: ["S-1"]) or entity (ticker:AAPL / cik:320193 in the query) — optionally narrowed by date.',
|
|
391
484
|
},
|
|
392
|
-
{
|
|
393
|
-
reason: 'straddling_date_range',
|
|
394
|
-
code: JsonRpcErrorCode.ValidationError,
|
|
395
|
-
when: 'The date range crosses 2001-01-01, spanning both the EFTS full-text era and the pre-2001 archives',
|
|
396
|
-
recovery: 'Split at 2001-01-01: run one search for the ≥2001 side and another for the <2001 side, then combine the results.',
|
|
397
|
-
},
|
|
398
485
|
{
|
|
399
486
|
reason: 'pre2001_full_text_unscoped',
|
|
400
487
|
code: JsonRpcErrorCode.ValidationError,
|
|
401
|
-
when: 'A
|
|
402
|
-
recovery: 'Add ticker: or cik: entity scope to the query,
|
|
403
|
-
},
|
|
404
|
-
{
|
|
405
|
-
reason: 'pre2001_full_text_scoped',
|
|
406
|
-
code: JsonRpcErrorCode.ValidationError,
|
|
407
|
-
when: 'A pre-2001 date range carries free-text terms alongside entity scope — local document matching is not yet supported',
|
|
408
|
-
recovery: "Drop the text terms to browse this entity's pre-2001 filings by form and date.",
|
|
488
|
+
when: 'A date range reaching before 2001-01-01 carries free-text terms with no entity scope — no pre-2001 full-text index exists, and nothing bounds a local scan',
|
|
489
|
+
recovery: 'Add ticker: or cik: entity scope to the query, drop the text terms to browse by form and date, or start the range at 2001-01-01.',
|
|
409
490
|
},
|
|
410
491
|
],
|
|
411
492
|
input: z.object({
|
|
@@ -419,11 +500,11 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
419
500
|
.describe('Full-text search query. Supports: exact phrases ("material weakness"), boolean operators (revenue OR income), exclusion (-preliminary), wildcard suffix (account*), entity targeting (ticker:AAPL or cik:320193 in the query). Terms are AND\'d by default.'),
|
|
420
501
|
])
|
|
421
502
|
.optional()
|
|
422
|
-
.describe('Full-text search query. Optional — omit (or pass "") to browse by form type and/or entity instead, e.g. every S-1 in a date window, or a company\'s filings via ticker:/cik:. A date range alone is not a valid search; pair it with forms or entity targeting.
|
|
503
|
+
.describe('Full-text search query. Optional — omit (or pass "") to browse by form type and/or entity instead, e.g. every S-1 in a date window, or a company\'s filings via ticker:/cik:. A date range alone is not a valid search; pair it with forms or entity targeting. The EFTS index that serves free text starts at 2001-01-01; a date range reaching earlier needs ticker:/cik: entity scope, which lets the tool read that entity\'s filings and match the terms locally (bounded to 50 documents, a few seconds at SEC\'s request rate), or drop the text terms to browse by form and date. When present, supports exact phrases ("material weakness"), boolean operators (revenue OR income), exclusion (-preliminary), wildcard suffix (account*), and entity targeting (ticker:AAPL or cik:320193 in the query); terms are AND\'d by default. The pre-2001 local scan honors the same phrase / OR / exclusion / wildcard syntax.'),
|
|
423
504
|
forms: z
|
|
424
505
|
.array(z.string())
|
|
425
506
|
.optional()
|
|
426
|
-
.describe('Filter to specific form types (e.g., ["10-K", "10-Q", "8-K"]). Without this, searches all form types. Note: "10-K" also matches amendments filed as 10-K/A. Ownership forms (3, 4, 5) are indexed by the reporting person (e.g., "LEVINSON ARTHUR D"), not the issuer — rows carry no transaction code, share count, or price. Use secedgar_get_insider_transactions to retrieve parsed ownership XML with person, relationship, transaction code, shares, and price.'),
|
|
507
|
+
.describe('Filter to specific form types (e.g., ["10-K", "10-Q", "8-K"]). Without this, searches all form types. Note: "10-K" also matches amendments filed as 10-K/A. SEC renamed the blockholder schedules on 2024-12-18 — filings before that date are "SC 13D"/"SC 13G", filings after are "SCHEDULE 13D"/"SCHEDULE 13G" — so a filter spanning that boundary must list both spellings. Ownership forms (3, 4, 5) are indexed by the reporting person (e.g., "LEVINSON ARTHUR D"), not the issuer — rows carry no transaction code, share count, or price. Use secedgar_get_insider_transactions to retrieve parsed ownership XML with person, relationship, transaction code, shares, and price.'),
|
|
427
508
|
start_date: z
|
|
428
509
|
.union([
|
|
429
510
|
z.literal(''),
|
|
@@ -451,7 +532,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
451
532
|
.min(0)
|
|
452
533
|
.max(9999)
|
|
453
534
|
.default(0)
|
|
454
|
-
.describe('Pagination offset. For sort=relevance, EDGAR pages server-side up to its 10,000-result cap.
|
|
535
|
+
.describe('Pagination offset. For sort=relevance on a 2001-onward search, EDGAR pages server-side up to its 10,000-result cap. Everywhere else the offset indexes the rows this call assembled and sorted: a single 100-row window for date sorts and entity targeting, the full matched set on a pre-2001 archive path, or both together on a range that crosses 2001-01-01. Offsets at or past those rows return nothing even when total is larger — switch to sort=relevance for deep pagination on a 2001-onward search, narrow the search (forms, dates, entity targeting), or query the dataframe. On a crossing range the two sides are assembled unevenly — the archive side contributes every row it matched, the full-text side one window of its total — so once the window runs out the rows jump to the pre-2001 era with the remaining full-text matches absent from the middle; search the 2001-onward era on its own to page through those.'),
|
|
455
536
|
sort: z
|
|
456
537
|
.enum(['filing_date_desc', 'filing_date_asc', 'relevance'])
|
|
457
538
|
.default('filing_date_desc')
|
|
@@ -460,10 +541,10 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
460
541
|
output: z.object({
|
|
461
542
|
total: z
|
|
462
543
|
.number()
|
|
463
|
-
.describe("Total matching filings. On the full-text (2001+) path this is capped at 10,000; entity targeting (ticker:/cik:) scopes server-side via the EFTS ciks param, so it is the entity's exact match count up to the cap. On a pre-2001 archive path it is the exact count within the scanned window (see total_is_exact)."),
|
|
544
|
+
.describe("Total matching filings, which can exceed the rows returned inline or materialized. On the full-text (2001+) path this is capped at 10,000; entity targeting (ticker:/cik:) scopes server-side via the EFTS ciks param, so it is the entity's exact match count up to the cap. On a pre-2001 archive path it is the exact count within the scanned window (see total_is_exact). On a range crossing 2001-01-01 it is the sum of both eras' counts."),
|
|
464
545
|
total_is_exact: z
|
|
465
546
|
.boolean()
|
|
466
|
-
.describe('False when total is a lower bound — the full-text path hit its 10,000 cap,
|
|
547
|
+
.describe('False when total is a lower bound — the full-text path hit its 10,000 cap, a pre-2001 archive scan hit its page/quarter cap before exhausting the range, or a pre-2001 local text scan hit its document cap (scan.capped).'),
|
|
467
548
|
results: z
|
|
468
549
|
.array(z
|
|
469
550
|
.object({
|
|
@@ -478,7 +559,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
478
559
|
period_ending: z
|
|
479
560
|
.string()
|
|
480
561
|
.optional()
|
|
481
|
-
.describe('Period the filing reports on (YYYY-MM-DD). Absent for filings without a reporting period (e.g., proxy statements, ownership reports) and for all pre-2001 archive-sourced rows (source submissions/full-index), which carry no period field.'),
|
|
562
|
+
.describe('Period the filing reports on (YYYY-MM-DD). Absent for filings without a reporting period (e.g., proxy statements, ownership reports) and for all pre-2001 archive-sourced rows (source submissions/full-index), which carry no period field. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
|
|
482
563
|
company_name: z
|
|
483
564
|
.string()
|
|
484
565
|
.describe('Filing entity, with ticker/CIK parentheticals stripped.'),
|
|
@@ -486,23 +567,23 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
486
567
|
ticker: z
|
|
487
568
|
.string()
|
|
488
569
|
.optional()
|
|
489
|
-
.describe('Primary ticker symbol parsed from the EFTS display name. Absent for private filers, foreign filers without a US listing, filings whose display name omits the ticker parenthetical, and all pre-2001 archive-sourced rows. For multi-class issuers (e.g., BRK-A / BRK-B), this is the first class listed.'),
|
|
570
|
+
.describe('Primary ticker symbol parsed from the EFTS display name. Absent for private filers, foreign filers without a US listing, filings whose display name omits the ticker parenthetical, and all pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only. For multi-class issuers (e.g., BRK-A / BRK-B), this is the first class listed.'),
|
|
490
571
|
file_description: z
|
|
491
572
|
.string()
|
|
492
573
|
.optional()
|
|
493
|
-
.describe('SEC-provided description of the matching document (e.g., "EX-99.1"). Absent when SEC published none, and for pre-2001 archive-sourced rows.'),
|
|
574
|
+
.describe('SEC-provided description of the matching document (e.g., "EX-99.1"). Absent when SEC published none, and for pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
|
|
494
575
|
sic: z
|
|
495
576
|
.string()
|
|
496
577
|
.optional()
|
|
497
|
-
.describe('SIC industry code for the filer. Absent for filers without a classification, and for pre-2001 archive-sourced rows.'),
|
|
578
|
+
.describe('SIC industry code for the filer. Absent for filers without a classification, and for pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
|
|
498
579
|
location: z
|
|
499
580
|
.string()
|
|
500
581
|
.optional()
|
|
501
|
-
.describe('Business location (state or country code). Absent when SEC has no location for this filer, and for pre-2001 archive-sourced rows.'),
|
|
582
|
+
.describe('Business location (state or country code). Absent when SEC has no location for this filer, and for pre-2001 archive-sourced rows. A range crossing 2001-01-01 returns both kinds of row together, so this field is populated on source=efts rows only.'),
|
|
502
583
|
source: z
|
|
503
584
|
.enum(['efts', 'submissions', 'full-index'])
|
|
504
585
|
.optional()
|
|
505
|
-
.describe('Which EDGAR backend served this row: "efts" (2001+ full-text index), "submissions" (a pre-2001 entity-scoped filing history), or "full-index" (a pre-2001 unscoped quarterly index browse). Provenance is carried into the canvas dataframe as a `source` column.'),
|
|
586
|
+
.describe('Which EDGAR backend served this row: "efts" (2001+ full-text index), "submissions" (a pre-2001 entity-scoped filing history), or "full-index" (a pre-2001 unscoped quarterly index browse). A date range crossing 2001-01-01 is split at the boundary and returns rows of two sources in one result set, so read this per row rather than per result. Provenance is carried into the canvas dataframe as a `source` column.'),
|
|
506
587
|
})
|
|
507
588
|
.describe('One matching filing hit.'))
|
|
508
589
|
.describe('Matching filings.'),
|
|
@@ -510,6 +591,21 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
510
591
|
.record(z.string(), z.number())
|
|
511
592
|
.optional()
|
|
512
593
|
.describe('Count of results by form type. Helps narrow follow-up searches.'),
|
|
594
|
+
scan: z
|
|
595
|
+
.object({
|
|
596
|
+
candidates: z
|
|
597
|
+
.number()
|
|
598
|
+
.describe('Filings the form + date pre-filter selected before any document was read.'),
|
|
599
|
+
scanned: z
|
|
600
|
+
.number()
|
|
601
|
+
.describe('Candidate documents actually fetched and matched against. Capped at 50 per call.'),
|
|
602
|
+
matched: z.number().describe('Scanned filings whose text satisfied the query terms.'),
|
|
603
|
+
capped: z
|
|
604
|
+
.boolean()
|
|
605
|
+
.describe('True when candidates exceeded the document cap, so the unscanned remainder may hold further matches — narrow the form or date filter to bring them into range.'),
|
|
606
|
+
})
|
|
607
|
+
.optional()
|
|
608
|
+
.describe("Present only on the pre-2001 entity-scoped free-text path, where no full-text index exists and terms are matched by reading documents. Reports the scan's shape so a partial read is never presented as a complete one. Each document read is the whole accession .txt — SEC's original flat-submission format concatenates every exhibit into one file, and pre-1997 filings expose no per-document URL at all — so a match may sit in an attached exhibit rather than the body of the requested form. Absent on every other path."),
|
|
513
609
|
dataset: z
|
|
514
610
|
.object({
|
|
515
611
|
name: z
|
|
@@ -543,46 +639,106 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
543
639
|
const { query, entityCik } = await resolveEntityTargeting(input.query ?? '', ctx);
|
|
544
640
|
const api = getEdgarApiService();
|
|
545
641
|
// Date-based routing at the EFTS full-text floor (2001-01-01). EFTS only
|
|
546
|
-
// indexes filings from 2001
|
|
547
|
-
// archives,
|
|
642
|
+
// indexes filings from 2001, so a range reaching earlier is served from the
|
|
643
|
+
// archives — entirely, or (when it also reaches 2001 onward) as the older half
|
|
644
|
+
// of a two-source merge split at the floor (#77, #87).
|
|
548
645
|
const startDate = input.start_date || undefined;
|
|
549
646
|
const endDate = input.end_date || undefined;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
throw ctx.fail('pre2001_full_text_unscoped',
|
|
647
|
+
const hasFreeText = query.trim().length > 0;
|
|
648
|
+
if (startDate && endDate && startDate < EFTS_FULLTEXT_FLOOR) {
|
|
649
|
+
const straddles = endDate >= EFTS_FULLTEXT_FLOOR;
|
|
650
|
+
const archiveEnd = straddles ? ARCHIVE_ERA_END : endDate;
|
|
651
|
+
// Free text before 2001 is matched by reading documents, which only an entity
|
|
652
|
+
// scope bounds. Unscoped, the pre-2001 half of the range cannot be served at
|
|
653
|
+
// all — reject rather than answer half a range and call it the answer.
|
|
654
|
+
if (hasFreeText && !entityCik) {
|
|
655
|
+
throw ctx.fail('pre2001_full_text_unscoped', `Full-text search is unavailable before ${EFTS_FULLTEXT_FLOOR} (where the EFTS index starts), so the ${startDate}..${archiveEnd} part of this range has no index to search. Add ticker:/cik: entity scope to match those filings by reading them, drop the text terms to browse by form and date, or start the range at ${EFTS_FULLTEXT_FLOOR}.`, { ...ctx.recoveryFor('pre2001_full_text_unscoped') });
|
|
559
656
|
}
|
|
657
|
+
let archive;
|
|
658
|
+
let scan;
|
|
560
659
|
if (entityCik) {
|
|
561
|
-
|
|
660
|
+
archive = await collectSubmissionsRows(ctx, {
|
|
562
661
|
entityCik,
|
|
563
662
|
forms: input.forms,
|
|
564
663
|
startDate,
|
|
565
|
-
endDate,
|
|
566
|
-
sort: input.sort,
|
|
567
|
-
limit: input.limit,
|
|
568
|
-
offset: input.offset,
|
|
664
|
+
endDate: archiveEnd,
|
|
569
665
|
});
|
|
666
|
+
if (hasFreeText) {
|
|
667
|
+
const scanned = await scanFilingText(ctx, sortRows(archive.rows, input.sort), parseLocalQuery(query));
|
|
668
|
+
archive = { rows: scanned.rows, scanTruncated: archive.scanTruncated };
|
|
669
|
+
scan = scanned.scan;
|
|
670
|
+
}
|
|
570
671
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
672
|
+
else {
|
|
673
|
+
// Unscoped + no free text → forms are guaranteed present by the missing_criteria guard.
|
|
674
|
+
archive = await collectFullIndexRows({
|
|
675
|
+
forms: input.forms ?? [],
|
|
676
|
+
startDate,
|
|
677
|
+
endDate: archiveEnd,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
const efts = straddles
|
|
681
|
+
? await fetchEftsRows({
|
|
682
|
+
query,
|
|
683
|
+
forms: input.forms,
|
|
684
|
+
entityCik,
|
|
685
|
+
startDate: EFTS_FULLTEXT_FLOOR,
|
|
686
|
+
endDate,
|
|
687
|
+
})
|
|
688
|
+
: undefined;
|
|
689
|
+
// `total` counts both eras' true match totals: the archive side's count is
|
|
690
|
+
// exact within what it scanned, while EFTS reports its own total independently
|
|
691
|
+
// of the single window fetched.
|
|
692
|
+
const archiveIncomplete = archive.scanTruncated || (scan?.capped ?? false);
|
|
693
|
+
const total = archive.rows.length + (efts?.total ?? 0);
|
|
694
|
+
const eftsWindowIncomplete = efts !== undefined && efts.total > efts.rows.length;
|
|
695
|
+
const formsNote = input.forms?.length ? `, forms ${input.forms.join(', ')}` : '';
|
|
696
|
+
const scopeNote = entityCik ? `CIK ${entityCik}` : 'full-index browse';
|
|
697
|
+
const archiveSource = entityCik
|
|
698
|
+
? 'the entity submissions history'
|
|
699
|
+
: 'the quarterly EDGAR full-index';
|
|
700
|
+
const scanNote = scan
|
|
701
|
+
? ` Read ${scan.scanned} of ${scan.candidates} candidate filings for the text terms${scan.capped ? ` (document cap ${DOCUMENT_SCAN_CAP})` : ''}; ${scan.matched} matched. Each read covers the whole accession .txt, so a match may sit in an attached exhibit rather than the body of the requested form.`
|
|
702
|
+
: '';
|
|
703
|
+
// The archive side contributes every row it matched; the full-text side contributes one
|
|
704
|
+
// window of a total it reports independently. When that window is short of its total,
|
|
705
|
+
// the merged list runs out of full-text rows and drops straight into the archive era —
|
|
706
|
+
// a gap in the middle of the date order that neither offset nor the dataframe reaches,
|
|
707
|
+
// since both address the rows this call assembled.
|
|
708
|
+
const windowNote = efts && eftsWindowIncomplete
|
|
709
|
+
? ` The ${EFTS_FULLTEXT_FLOOR}-onward side contributed one ${efts.rows.length}-row window of its ${efts.total} matches, so the merged rows jump from the end of that window straight to ${ARCHIVE_ERA_END} — search ${EFTS_FULLTEXT_FLOOR} to ${endDate} on its own to page through the rest.`
|
|
710
|
+
: '';
|
|
711
|
+
return await finishAssembledResult(ctx, {
|
|
712
|
+
matched: [...archive.rows, ...(efts?.rows ?? [])],
|
|
713
|
+
total,
|
|
714
|
+
totalIsExact: !archiveIncomplete && (efts?.totalIsExact ?? true),
|
|
576
715
|
sort: input.sort,
|
|
577
716
|
limit: input.limit,
|
|
578
717
|
offset: input.offset,
|
|
718
|
+
truncated: archiveIncomplete || eftsWindowIncomplete,
|
|
719
|
+
queryParams: {
|
|
720
|
+
query: hasFreeText ? query : undefined,
|
|
721
|
+
entity_cik: entityCik,
|
|
722
|
+
forms: input.forms,
|
|
723
|
+
start_date: startDate,
|
|
724
|
+
end_date: endDate,
|
|
725
|
+
source: straddles ? 'efts+archive' : entityCik ? 'submissions' : 'full-index',
|
|
726
|
+
},
|
|
727
|
+
effectiveQuery: straddles
|
|
728
|
+
? `${hasFreeText ? `${query} ` : ''}(split at ${EFTS_FULLTEXT_FLOOR}: ${scopeNote}${formsNote}, ${startDate} to ${endDate})`
|
|
729
|
+
: `${hasFreeText ? `${query} ` : ''}(pre-2001 archive: ${scopeNote}${formsNote}, ${startDate} to ${endDate})`,
|
|
730
|
+
coverageNote: straddles
|
|
731
|
+
? `Merged across the ${EFTS_FULLTEXT_FLOOR} boundary — filings from ${EFTS_FULLTEXT_FLOOR} on come from the full-text index, earlier ones from ${archiveSource}. Read each row's source; the EFTS-only fields (period_ending, ticker, file_description, sic, location) are null on archive rows.${windowNote}${scanNote}`
|
|
732
|
+
: `Served from ${archiveSource} — full-text search covers 2001-present only.${scanNote}`,
|
|
733
|
+
zeroHitCriteria: [
|
|
734
|
+
...(hasFreeText ? [`"${query}"`] : []),
|
|
735
|
+
...(entityCik ? [`CIK ${entityCik}`] : []),
|
|
736
|
+
...(input.forms?.length ? [`forms [${input.forms.join(', ')}]`] : []),
|
|
737
|
+
`dates ${startDate} to ${endDate}`,
|
|
738
|
+
].join(', '),
|
|
739
|
+
scan,
|
|
579
740
|
});
|
|
580
741
|
}
|
|
581
|
-
if (startDate && endDate && startDate < EFTS_FULLTEXT_FLOOR) {
|
|
582
|
-
// endDate ≥ 2001 (handled above) but startDate < 2001 → straddling. Reject
|
|
583
|
-
// with a split instruction; merged straddling output is a deferred follow-up.
|
|
584
|
-
throw ctx.fail('straddling_date_range', `The date range ${startDate}..${endDate} crosses 2001-01-01. Full-text search covers 2001-present; pre-2001 filings are served from the archives. Search the two eras separately: ${EFTS_FULLTEXT_FLOOR} onward, and before ${EFTS_FULLTEXT_FLOOR}.`, { ...ctx.recoveryFor('straddling_date_range') });
|
|
585
|
-
}
|
|
586
742
|
// EFTS scores by relevance and exposes no sort param. When the caller wants
|
|
587
743
|
// a date sort (the default) or entity filtering, we over-fetch the EFTS
|
|
588
744
|
// window and reorder/slice client-side. Pure relevance mode keeps the
|
|
@@ -623,7 +779,7 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
623
779
|
form: hit._source.form ?? undefined,
|
|
624
780
|
filing_date: hit._source.file_date,
|
|
625
781
|
period_ending: hit._source.period_ending ?? undefined,
|
|
626
|
-
company_name:
|
|
782
|
+
company_name: cleanDisplayName(displayName),
|
|
627
783
|
cik: hit._source.ciks?.[0] || '',
|
|
628
784
|
...(ticker !== null && { ticker }),
|
|
629
785
|
file_description: hit._source.file_description ?? undefined,
|
|
@@ -725,10 +881,10 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
725
881
|
};
|
|
726
882
|
},
|
|
727
883
|
format: (result) => {
|
|
728
|
-
const
|
|
884
|
+
const fromArchive = result.results.some((r) => r.source === 'submissions' || r.source === 'full-index');
|
|
729
885
|
const exactness = result.total_is_exact
|
|
730
886
|
? 'exact'
|
|
731
|
-
:
|
|
887
|
+
: fromArchive || result.scan
|
|
732
888
|
? 'partial — scan cap reached, more may exist'
|
|
733
889
|
: 'capped at 10,000';
|
|
734
890
|
const lines = [`Found ${result.total} filings (${exactness})`];
|
|
@@ -747,6 +903,12 @@ export const searchFilingsTool = tool('secedgar_search_filings', {
|
|
|
747
903
|
.join(', ');
|
|
748
904
|
lines.push(`\nForm distribution: ${dist}`);
|
|
749
905
|
}
|
|
906
|
+
if (result.scan) {
|
|
907
|
+
const cappedNote = result.scan.capped
|
|
908
|
+
? `Capped — the remaining ${result.scan.candidates - result.scan.scanned} went unread; narrow the form or date filter to reach them.`
|
|
909
|
+
: 'Not capped — every candidate was read.';
|
|
910
|
+
lines.push(`\nPre-2001 text scan: read ${result.scan.scanned} of ${result.scan.candidates} candidate filings, ${result.scan.matched} matched. ${cappedNote} Each read covers the whole accession .txt, so a match may sit in an attached exhibit rather than the body of the requested form.`);
|
|
911
|
+
}
|
|
750
912
|
if (result.dataset) {
|
|
751
913
|
const truncatedNote = result.dataset.truncated
|
|
752
914
|
? ' (truncated — more matches exist beyond the materialized set)'
|