Jaspers Hub

jaspers / agents

pluginOfficialReviewed★ 0↓ 1

Research Agents: Lets users define research agents that answer chat questions from SEC filings, uploaded documents, and the web, with every figure cited.

7.6 KB
1import 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. */
7export const DEFAULT_YEARS = 10
8/** The first year EDGAR holds filings for. */
9export const FIRST_YEAR = 1994
10const 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. */
13export 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. */
23export 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
36export 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 */
44export 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. */
58export 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. */
81export 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. */
89export 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. */
94export 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. */
99export 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 */
117export 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
172export type Category = keyof typeof CATEGORIES
173export 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. */
176export 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. */
179export 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. */
185export 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. */
191export 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. */
196export 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. */
208export 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. */
211export 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}