Research Agents: Agents you define that answer research chats from SEC filings, your documents, and the web, citing every passage.
| 1 | import type { StoredCompany } from './research.ts' |
| 2 | |
| 3 | // The companies whose SEC filings are in the store, as the view lists them: what the store holds of |
| 4 | // each, and how far the job reading it is. Pure, so it is tested. |
| 5 | |
| 6 | /** How far back a company is read unless the user picks its years: this year and the ten before. */ |
| 7 | export const DEFAULT_YEARS = 10 |
| 8 | /** The first year EDGAR holds filings for. */ |
| 9 | export const FIRST_YEAR = 1994 |
| 10 | const TICKER = /^[A-Z][A-Z0-9.-]{0,9}$/ |
| 11 | |
| 12 | /** How far a company's job got: filings listed, read (now or before), and failed. */ |
| 13 | export interface Progress { |
| 14 | total: number |
| 15 | done: number |
| 16 | failed: number |
| 17 | running: boolean |
| 18 | /** Why the ingest itself stopped, when it did not finish. */ |
| 19 | error: string | null |
| 20 | } |
| 21 | |
| 22 | /** A company as the view lists it. */ |
| 23 | export interface CompanyStatus { |
| 24 | ticker: string |
| 25 | name: string |
| 26 | /** Filings in the store. */ |
| 27 | filings: number |
| 28 | chunks: number |
| 29 | /** The kinds of its filings ever asked for and their years, by filing date. */ |
| 30 | reads: Reads |
| 31 | /** Chunks still waiting for an embedding: searchable by their words meanwhile. */ |
| 32 | unembedded: number |
| 33 | progress: Progress | null |
| 34 | } |
| 35 | |
| 36 | export function tickerOf(raw: string): string { |
| 37 | return companyOf(raw).ticker |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * A company as typed: a ticker, or a ticker and its SEC CIK (DFS:1393612) for one SEC no longer |
| 42 | * lists by ticker, like a company bought since. The ticker names its filings; the CIK finds them. |
| 43 | */ |
| 44 | export function companyOf(raw: string): { ticker: string; cik: number | null } { |
| 45 | const [name = '', cik] = raw |
| 46 | .trim() |
| 47 | .toUpperCase() |
| 48 | .split(/\s*:\s*/) |
| 49 | if (!TICKER.test(name) || (cik !== undefined && !/^\d{1,10}$/.test(cik))) { |
| 50 | throw new Error( |
| 51 | `${JSON.stringify(raw)} is not a ticker, like FLWS or BRK-B, or a ticker and CIK, like DFS:1393612.`, |
| 52 | ) |
| 53 | } |
| 54 | return { ticker: name, cik: cik === undefined ? null : Number(cik) } |
| 55 | } |
| 56 | |
| 57 | /** Every company the store was asked about, by ticker: its job, if one is open or just ended, as its progress. */ |
| 58 | export function companyStatuses(stored: StoredCompany[]): CompanyStatus[] { |
| 59 | return stored |
| 60 | .map((c) => ({ |
| 61 | ticker: c.ticker, |
| 62 | name: c.company ?? c.ticker, |
| 63 | filings: c.filings, |
| 64 | chunks: c.chunks, |
| 65 | reads: c.reads, |
| 66 | unembedded: c.unembedded, |
| 67 | progress: c.job |
| 68 | ? { |
| 69 | total: c.job.total, |
| 70 | done: c.job.done, |
| 71 | failed: c.job.failed, |
| 72 | running: c.job.state === 'queued' || c.job.state === 'listing' || c.job.state === 'reading', |
| 73 | error: c.job.error, |
| 74 | } |
| 75 | : null, |
| 76 | })) |
| 77 | .sort((a, b) => a.ticker.localeCompare(b.ticker)) |
| 78 | } |
| 79 | |
| 80 | /** Every year from `from` to `to`, refused when they are not years EDGAR has, in order. */ |
| 81 | export function yearRange(from: number, to: number, now: number): number[] { |
| 82 | if (!Number.isInteger(from) || !Number.isInteger(to)) throw new Error('Years are whole numbers, like 2016.') |
| 83 | if (from > to) throw new Error(`From ${from} is after to ${to}.`) |
| 84 | if (from < FIRST_YEAR || to > now) throw new Error(`Years run from ${FIRST_YEAR} to ${now}.`) |
| 85 | return Array.from({ length: to - from + 1 }, (_, i) => from + i) |
| 86 | } |
| 87 | |
| 88 | /** The years read unless the user picks: this year and the ten before. */ |
| 89 | export function defaultYears(now: number): number[] { |
| 90 | return yearRange(now - DEFAULT_YEARS, now, now) |
| 91 | } |
| 92 | |
| 93 | /** A company's years with more added: each once, ascending. */ |
| 94 | export function addYears(covered: number[], added: number[]): number[] { |
| 95 | return [...new Set([...covered, ...added])].sort((a, b) => a - b) |
| 96 | } |
| 97 | |
| 98 | /** Years as the row says them: runs joined, like 2016–2019, 2022–2026. */ |
| 99 | export function yearsText(years: number[]): string { |
| 100 | const runs: [number, number][] = [] |
| 101 | for (const year of years) { |
| 102 | const run = runs[runs.length - 1] |
| 103 | if (run && year === run[1] + 1) run[1] = year |
| 104 | else runs.push([year, year]) |
| 105 | } |
| 106 | return runs.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ') |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * The kinds of filing a company is read in by, each its forms with their amendments, grouped as |
| 111 | * edgartools groups them (PERIODIC_FORMS, CURRENT_REPORT_FORMS, PROXY_FORMS, REGISTRATION_FORMS) |
| 112 | * and by what a question reads them for. An answer reads in only the kinds it needs: of FLWS's 791 |
| 113 | * filings in 2016-2026, 40 are reports and 542 insiders' forms. Left out of every kind: the prospectus |
| 114 | * supplements and free writing prospectuses a bank files by the thousand (GS filed 7,741 424B2s in |
| 115 | * 2017-2018), comment letters, and the rest. |
| 116 | */ |
| 117 | export const CATEGORIES = { |
| 118 | reports: { |
| 119 | holds: 'annual and quarterly reports: the business, risk factors, MD&A, financial statements', |
| 120 | forms: ['10-K', '10-KT', '10-Q', '10-QT', '20-F', '40-F'], |
| 121 | }, |
| 122 | events: { |
| 123 | holds: 'current reports: results releases, deals, leadership changes, other material events', |
| 124 | forms: ['8-K', '6-K'], |
| 125 | }, |
| 126 | proxies: { |
| 127 | holds: 'proxy statements: executive pay, the board, shareholder votes, merger proxies', |
| 128 | forms: [ |
| 129 | 'DEF 14A', |
| 130 | 'DEFA14A', |
| 131 | 'DEFC14A', |
| 132 | 'DEFM14A', |
| 133 | 'DEFN14A', |
| 134 | 'DEFR14A', |
| 135 | 'DFAN14A', |
| 136 | 'DFRN14A', |
| 137 | 'PRE 14A', |
| 138 | 'PREC14A', |
| 139 | 'PREM14A', |
| 140 | 'PREN14A', |
| 141 | ], |
| 142 | }, |
| 143 | insiders: { |
| 144 | holds: "officers' and directors' holdings and trades, and planned sales", |
| 145 | forms: ['3', '4', '5', '144'], |
| 146 | }, |
| 147 | holders: { |
| 148 | holds: 'large holders: 5% owners and activists, and an investment manager’s own holdings', |
| 149 | forms: ['SC 13D', 'SC 13G', 'SCHEDULE 13D', 'SCHEDULE 13G', '13F-HR'], |
| 150 | }, |
| 151 | offerings: { |
| 152 | holds: 'registrations, prospectuses, and merger communications', |
| 153 | forms: [ |
| 154 | 'S-1', |
| 155 | 'S-3', |
| 156 | 'S-3ASR', |
| 157 | 'S-4', |
| 158 | 'S-8', |
| 159 | 'F-1', |
| 160 | 'F-3', |
| 161 | 'F-4', |
| 162 | 'DRS', |
| 163 | '424B1', |
| 164 | '424B3', |
| 165 | '424B4', |
| 166 | '424B5', |
| 167 | '425', |
| 168 | ], |
| 169 | }, |
| 170 | } as const satisfies Record<string, { holds: string; forms: readonly string[] }> |
| 171 | |
| 172 | export type Category = keyof typeof CATEGORIES |
| 173 | export const CATEGORY_NAMES = Object.keys(CATEGORIES) as Category[] |
| 174 | |
| 175 | /** The years each kind of a company's filings is read for, by filing date. */ |
| 176 | export type Reads = Partial<Record<Category, number[]>> |
| 177 | |
| 178 | /** The kind a filing of this form is, its amendments with it; null for a form no kind reads. */ |
| 179 | export function categoryOf(form: string): Category | null { |
| 180 | const base = form.trim().toUpperCase().replace(/\/A$/, '') |
| 181 | return CATEGORY_NAMES.find((c) => (CATEGORIES[c].forms as readonly string[]).includes(base)) ?? null |
| 182 | } |
| 183 | |
| 184 | /** Whether reading these kinds in these years reads a filing of this form filed on this date. */ |
| 185 | export function readsFiling(reads: Reads, form: string, filed: string): boolean { |
| 186 | const category = categoryOf(form) |
| 187 | return category !== null && (reads[category]?.includes(Number(filed.slice(0, 4))) ?? false) |
| 188 | } |
| 189 | |
| 190 | /** Every year any kind is read for, ascending. */ |
| 191 | export function readYears(reads: Reads): number[] { |
| 192 | return addYears([], Object.values(reads).flat()) |
| 193 | } |
| 194 | |
| 195 | /** What a company reads, kinds with the same years together: reports, events 2016–2026; insiders 2026. */ |
| 196 | export function readsText(reads: Reads): string { |
| 197 | const byYears = new Map<string, Category[]>() |
| 198 | for (const c of CATEGORY_NAMES) { |
| 199 | const years = reads[c] |
| 200 | if (!years?.length) continue |
| 201 | const text = yearsText(years) |
| 202 | byYears.set(text, [...(byYears.get(text) ?? []), c]) |
| 203 | } |
| 204 | return [...byYears].map(([years, kinds]) => `${kinds.join(', ')} ${years}`).join('; ') |
| 205 | } |
| 206 | |
| 207 | /** How long a company's filings count as the latest after SEC listed them. */ |
| 208 | export const CURRENT_FOR_MS = 15 * 60 * 1000 |
| 209 | |
| 210 | /** Whether SEC listed the company's filings within CURRENT_FOR_MS; one never listed is not current. */ |
| 211 | export function isCurrent(recentlyAsOf: string | null, now: Date): boolean { |
| 212 | if (!recentlyAsOf) return false |
| 213 | const at = Date.parse(recentlyAsOf) |
| 214 | return Number.isFinite(at) && now.getTime() - at <= CURRENT_FOR_MS |
| 215 | } |