Jaspers Hub

jaspers / screener-mcp

pluginOfficialReviewed★ 0↓ 33

Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.

19 KB
1import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
2import { useBridge, useData, usePanelState, usePublish, usePublishText, type Bridge, type PanelRef } from '@jaspers-ai/sdk'
3import { ColumnsDialog } from './Columns'
4import { EvidencePane, type Evidenced } from './Evidence'
5import { FilterBar, FilterDialog, type Listed } from './Filters'
6import { Funnel } from './Funnel'
7import { QualitativeBar, RunProgress } from './Qualitative'
8import { Table } from './Table'
9import {
10 DEFAULT_COLUMNS,
11 DEFAULT_SORT,
12 echoOf,
13 evidenceText,
14 fitOutput,
15 followArgs,
16 inSync,
17 mergeRun,
18 needsStart,
19 overLimit,
20 passedTickers,
21 publishedFilters,
22 readFields,
23 readRun,
24 readRunText,
25 readScreen,
26 readStart,
27 screenArgs,
28 shownColumns,
29 stageOf,
30 type Criterion,
31 type Echo,
32 type Filter,
33 type Run,
34 type RunSnapshot,
35 type Sort,
36 type State,
37 type Verdict,
38} from './screener'
39import './styles.css'
40
41// The view. Everything it shows comes out of the panel's state: the filters, the list of tickers,
42// the sort, the page and the columns go into the screen source's arguments, so changing any re-runs
43// it, and the criteria and the run drive the qualitative stage. What is on screen is published
44// back, which is how the orchestrator reads the table without being told what is in it.
45
46/** One server page, the most the server sends. The table scrolls it; Prev and Next ask for the next one. */
47const PAGE = 200
48/**
49 * How long one read of a run waits on the server while the run goes on, and so how often the run on
50 * screen is brought up to date. The server holds the call that long and answers with the run as it is
51 * then; it tells of each company as it finishes only in progress notices, which do not reach a view.
52 */
53const POLL_SECONDS = 3
54const FIELDS = { topic: 'fields' }
55const NONE: never[] = []
56
57/** One get_qualitative_screen answer, read: its results are a dataset's rows in main, the rest its meta. */
58async function readRunFrom(bridge: Bridge, args: Record<string, unknown>): Promise<RunSnapshot> {
59 const result = await bridge.runSource('screener-mcp/qualitative', args, { fresh: true })
60 return result.kind === 'dataset' ? readRun(result.meta, await bridge.datasetRows(result.datasetId)) : readRunText(result.text)
61}
62
63function sleep(ms: number): Promise<void> {
64 return new Promise((resolve) => setTimeout(resolve, ms))
65}
66
67/**
68 * The panel's state arrives a round trip after the frame mounts, and the screen is nothing without
69 * it: a run with the default filters would ask for the whole universe and publish it as what the
70 * user is looking at, for the moment before the real filters land. So the table is a component of
71 * its own, mounted once the state is here and handed it as the initial value of every key, which
72 * makes its first source run the right one.
73 */
74export function ScreenerView({ panel }: { panel: PanelRef }): ReactElement {
75 const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as Partial<State> | undefined
76 if (state === undefined) {
77 return (
78 <div className="sc-root">
79 <div className="sc-bar">
80 <span className="sc-status">Loading…</span>
81 </div>
82 </div>
83 )
84 }
85 return <Screener panel={panel} initial={state} />
86}
87
88function Screener({ panel, initial }: { panel: PanelRef; initial: Partial<State> }): ReactElement {
89 const bridge = useBridge()
90 const ref = useMemo(() => ({ id: panel.id, workspaceId: panel.workspaceId }), [panel.id, panel.workspaceId])
91 // The list and the sort change together with the page, in one write (write, below), so these four are only read here.
92 const [filters] = usePanelState<Filter[]>(panel, 'filters', initial.filters ?? NONE)
93 const [tickers] = usePanelState<string[]>(panel, 'tickers', initial.tickers ?? NONE)
94 const [includeUnlisted] = usePanelState<boolean>(panel, 'includeUnlisted', initial.includeUnlisted ?? false)
95 const [sort] = usePanelState<Sort>(panel, 'sort', initial.sort ?? DEFAULT_SORT)
96 const [page, setPage] = usePanelState<number>(panel, 'page', initial.page ?? 0)
97 const [columns, setColumns] = usePanelState<string[]>(panel, 'columns', initial.columns ?? DEFAULT_COLUMNS)
98 const [criteria, setCriteria] = usePanelState<Criterion[]>(panel, 'criteria', initial.criteria ?? NONE)
99 // A key that holds null reads as its initial value, so "no run" is the initial value: a run that
100 // is in the state arrives with the first push, a moment later, and nothing acts on its absence.
101 const [run, setRun] = usePanelState<Run | null>(panel, 'run', null)
102 const [verdicts, setVerdicts] = usePanelState<Verdict[]>(panel, 'verdicts', initial.verdicts ?? NONE)
103 const [open, setOpen] = usePanelState<string>(panel, 'open', initial.open ?? '')
104 const [dialog, setDialog] = useState<'filters' | 'columns' | null>(null)
105
106 // The connections' statuses, so a run asked for while the server is still connecting starts once
107 // it is ready instead of failing.
108 const connections = useData('connections') as Record<string, { status?: string }> | undefined
109 const connectionsKey = JSON.stringify(Object.values(connections ?? {}).map((c) => c.status))
110
111 // The fields are the server's: what can be filtered, how, and in which unit a number reads.
112 const guide = useData('screener-mcp/guide', FIELDS)
113 const fields = useMemo(() => readFields(guide.data), [guide.data])
114 const fieldMap = useMemo(() => new Map(fields.map((field) => [field.field, field])), [fields])
115 const fieldsError = guide.error ?? (guide.text !== undefined ? 'This server\'s guide does not list its fields as rows.' : null)
116
117 // The screen. The server keeps the list in a session, so what one call sends depends on what the
118 // session already holds (screenArgs). The arguments are made when what is asked for changes, not
119 // when an answer lands: the session id an answer brings is for the next call and must not cause one.
120 const session = useRef<string | null>(initial.sessionId ?? null)
121 const echo = useRef<Echo | null>(null)
122 const [again, setAgain] = useState(0)
123 const asked = JSON.stringify([filters, tickers, includeUnlisted, sort, page, columns, again])
124 const args = useMemo(
125 () => screenArgs({ filters, tickers, includeUnlisted, sort, page, columns, sessionId: session.current }, echo.current, PAGE),
126 [asked],
127 )
128 const { data, loading, error, meta } = useData('screener-mcp/screen', args)
129 const screen = useMemo(() => readScreen(meta), [meta])
130 const rows = data ?? NONE
131 /** The session holds the list on screen and no call is out: what a run would freeze is what the user sees. */
132 const settled = !loading && !error && screen !== null && inSync({ filters, tickers, includeUnlisted, sessionId: screen.sessionId }, echoOf(screen))
133
134 useEffect(() => {
135 if (!screen?.sessionId) return
136 echo.current = echoOf(screen)
137 if (session.current === screen.sessionId) return
138 session.current = screen.sessionId
139 void bridge.setState(ref, ['sessionId'], screen.sessionId)
140 }, [bridge, ref, screen])
141
142 // A session the server no longer has (another server, a database set up again): start a new one.
143 useEffect(() => {
144 if (!error || !session.current || !/No screening session/.test(error)) return
145 session.current = null
146 echo.current = null
147 void bridge.setState(ref, ['sessionId'], null)
148 setAgain((n) => n + 1)
149 }, [bridge, ref, error])
150
151 // A list that got shorter than the page it is on, as when the orchestrator sets filters and not the page.
152 useEffect(() => {
153 if (screen && !loading && page > 0 && page * PAGE >= screen.count) setPage(0)
154 }, [screen, loading, page, setPage])
155
156 /**
157 * Several keys in one write, so a change of two things is one re-run and not two. The rest of the
158 * state is read from the app at that moment, not from this render: a run's id that landed a
159 * moment ago must not be written over by what the view knew before it.
160 */
161 async function write(patch: Partial<State>): Promise<void> {
162 const current = (await bridge.get(`workspaces/${ref.workspaceId}/panels/${ref.id}/state`)) as Partial<State> | undefined
163 await bridge.setState(ref, [], { ...current, ...patch })
164 }
165
166 /** A new list starts at its first page, whichever control asked for it. */
167 function changeListed(next: Listed): void {
168 void write({ ...next, page: 0 })
169 }
170
171 /** A failed start is asked for again; a run that could not be read is read again. */
172 function retry(): void {
173 if (!run?.id) {
174 setRun({})
175 return
176 }
177 setRun({ id: run.id, status: 'running' })
178 setReadAgain((n) => n + 1)
179 }
180
181 function toggleSort(field: string): void {
182 void write({ sort: sort.field === field && sort.dir === 'desc' ? { field, dir: 'asc' } : { field, dir: 'desc' }, page: 0 })
183 }
184
185 // The qualitative stage. A `run` with no id is the request (needsStart); the view starts it over
186 // the session's list, once that list is the one on screen. It costs model calls for every company,
187 // so nothing here may start one twice: the latch holds from the call until the run's id is in the state.
188 const starting = useRef(false)
189 useEffect(() => {
190 if (run === null || run.id) starting.current = false
191 if (!needsStart(criteria, run) || starting.current || !screen || !settled) return
192 if (!screen.ready || !screen.sessionId) {
193 setRun({ status: 'error', error: overLimit(screen) })
194 return
195 }
196 starting.current = true
197 void (async () => {
198 try {
199 const result = await bridge.runSource('screener-mcp/qualitative-start', { session_id: screen.sessionId, criteria })
200 const started = readStart(result.kind === 'text' ? result.text : JSON.stringify(result.meta))
201 // Written whether or not the view is still mounted: a run that was started must not be lost.
202 setRun({ id: started.runId, status: 'running' })
203 } catch (err) {
204 starting.current = false
205 const text = err instanceof Error ? err.message : String(err)
206 // Not ready yet: the request stays as it is and this runs again when a connection changes.
207 if (/connection_unavailable/.test(text) && /connecting/.test(text)) return
208 setRun({ status: 'error', error: text })
209 }
210 })()
211 }, [bridge, criteria, run, screen, settled, setRun, connectionsKey])
212
213 // A run is followed until it is done. The first read is the whole run, at once, so a panel that
214 // comes back to a run shows where it is; each one after waits on the server a few seconds and asks
215 // only for the companies not yet over (followArgs), so the loop is one small request in flight at a
216 // time, never a burst, and a company's answers are fetched once. Results come without evidence:
217 // that is asked for one company at a time, below.
218 const [snapshot, setSnapshot] = useState<RunSnapshot | null>(null)
219 const [readAgain, setReadAgain] = useState(0)
220 /** Each opened company's evidence, and the answer it was read for. */
221 const evidence = useRef(new Map<string, { key: string; found: Evidenced }>())
222 const runId = run?.id
223 const status = useRef(run?.status)
224 status.current = run?.status
225 const chosen = useRef(verdicts)
226 chosen.current = verdicts
227 useEffect(() => {
228 if (!runId) {
229 setSnapshot(null)
230 evidence.current.clear()
231 return
232 }
233 let live = true
234 void (async () => {
235 let seen: RunSnapshot | null = null
236 while (live) {
237 const asked = Date.now()
238 let next: RunSnapshot
239 try {
240 const read = await readRunFrom(bridge, followArgs(runId, seen, POLL_SECONDS))
241 if (!live) return
242 next = seen ? mergeRun(seen, read) : read
243 // A run that ended while it was followed is read whole once more: the reads before this one each held some of its companies.
244 if (seen && next.status === 'done') {
245 next = await readRunFrom(bridge, followArgs(runId, null, 0))
246 if (!live) return
247 }
248 } catch (err) {
249 if (!live) return
250 const text = err instanceof Error ? err.message : String(err)
251 if (/connection_unavailable/.test(text) && /connecting/.test(text)) return
252 setRun({ id: runId, status: 'error', error: text })
253 return
254 }
255 setSnapshot(next)
256 const now = next.status === 'done' ? 'done' : 'running'
257 // A run that finishes shows who passed: until then the rows were only the list being read, and
258 // left as they are they read as the answer. A verdict filter somebody already chose stays.
259 if (status.current !== now && now === 'done' && chosen.current.length === 0) void write({ run: { id: runId, status: now }, verdicts: ['pass'] })
260 else if (status.current !== now) setRun({ id: runId, status: now })
261 if (next.status === 'done') return
262 // The pace is the server's, which holds each read after the first. One that answers sooner is not asked again at once.
263 if (seen) await sleep(POLL_SECONDS * 1000 - (Date.now() - asked))
264 seen = next
265 }
266 })()
267 return () => {
268 live = false
269 }
270 }, [bridge, runId, readAgain, setRun, connectionsKey])
271
272 // The open company's evidence: one call for that company, when its row is opened and again when
273 // its answer changes. Quotes stay here, not in state: a company's worth is most of the output
274 // budget, and a list's worth is far past it.
275 const [shown, setShown] = useState<{ ticker: string; found: Evidenced | null; error: string | null } | null>(null)
276 const openResult = open ? snapshot?.results.find((result) => result.ticker === open) : undefined
277 const openKey = open && runId && snapshot ? `${runId}|${open}|${openResult?.status ?? ''}|${openResult?.verdict ?? ''}` : ''
278 useEffect(() => {
279 if (!openKey || !runId) {
280 setShown(null)
281 return
282 }
283 const kept = evidence.current.get(open)
284 setShown({ ticker: open, found: kept?.found ?? null, error: null })
285 if (kept?.key === openKey) return
286 let live = true
287 void (async () => {
288 try {
289 const read = await readRunFrom(bridge, { run_id: runId, tickers: [open], evidence: true })
290 if (!live) return
291 const company = read.results[0]
292 if (!company) throw new Error(`The run has no company with the ticker ${open}.`)
293 evidence.current.set(open, { key: openKey, found: { company, citations: read.citations } })
294 setShown({ ticker: open, found: { company, citations: read.citations }, error: null })
295 } catch (err) {
296 if (live) setShown({ ticker: open, found: null, error: err instanceof Error ? err.message : String(err) })
297 }
298 })()
299 return () => {
300 live = false
301 }
302 }, [bridge, runId, open, openKey])
303
304 const results = useMemo(() => new Map((snapshot?.results ?? []).map((result) => [result.cik, result])), [snapshot])
305 const visible = useMemo(() => {
306 if (!snapshot || verdicts.length === 0) return rows
307 return rows.filter((row) => {
308 const verdict = results.get(Number(row.cik))?.verdict
309 return verdict !== null && verdict !== undefined && verdicts.includes(verdict)
310 })
311 }, [rows, snapshot, results, verdicts])
312
313 usePublish(panel, {
314 ...fitOutput({
315 session_id: screen?.sessionId ?? null,
316 universe: screen?.universe ?? 0,
317 count: screen?.count ?? 0,
318 applied_filters: publishedFilters(screen),
319 tickers: visible.map((row) => row.ticker).filter((ticker): ticker is string => typeof ticker === 'string'),
320 unknown_tickers: screen?.unknownTickers ?? [],
321 sort,
322 qualitative_ready: screen?.ready ?? false,
323 qualitative_max: screen?.max ?? 0,
324 qualitative: run
325 ? {
326 run_id: run.id ?? null,
327 status: stageOf(criteria, run),
328 progress: snapshot?.progress ?? null,
329 ...(snapshot?.phase ? { earnings_calls: snapshot.phase } : {}),
330 ...(snapshot ? { passed: passedTickers(snapshot) } : {}),
331 ...(run.error ? { error: run.error } : {}),
332 }
333 : undefined,
334 open,
335 }),
336 })
337 // The open company's evidence as text, for a model to quote: every quote carries its [^id].
338 usePublishText(panel, shown?.found && runId ? evidenceText(shown.found.company, snapshot?.criteria ?? criteria, shown.found.citations, runId) : null)
339
340 const hasNext = (page + 1) * PAGE < (screen?.count ?? 0)
341 const listed: Listed = { filters, tickers, includeUnlisted }
342
343 return (
344 <div className="sc-root">
345 <div className="sc-bar sc-top">
346 <FilterBar listed={listed} fields={fieldMap} onChange={changeListed} onOpen={() => setDialog('filters')} onColumns={() => setDialog('columns')} />
347 <span className="sc-count">
348 {visible.length} shown of {(screen?.count ?? 0).toLocaleString('en-US')}
349 </span>
350 </div>
351
352 <Funnel listed={listed} screen={screen} loading={loading} fields={fieldMap}>
353 <RunProgress criteria={criteria} run={run} snapshot={snapshot} />
354 </Funnel>
355
356 <QualitativeBar
357 criteria={criteria}
358 onCriteria={setCriteria}
359 run={run}
360 snapshot={snapshot}
361 screen={screen}
362 settled={settled}
363 verdicts={verdicts}
364 onVerdicts={setVerdicts}
365 onRun={() => setRun({})}
366 onRetry={retry}
367 onClear={() => void write({ run: null, verdicts: [], open: '' })}
368 />
369
370 <Table
371 rows={visible}
372 columns={shownColumns(filters, sort, columns)}
373 fields={fieldMap}
374 sort={sort}
375 onSort={toggleSort}
376 loading={loading && data === undefined}
377 stale={loading && data !== undefined}
378 empty={snapshot && verdicts.length > 0 && rows.length > 0 ? `No company on this page is ${verdicts.join(' or ')}. The verdict buttons above show the others.` : 'No companies match.'}
379 run={snapshot ? { criteria: snapshot.criteria, results } : null}
380 open={open}
381 onOpen={(ticker) => setOpen(ticker === open ? '' : ticker)}
382 />
383
384 {open && runId && (
385 <EvidencePane
386 ticker={open}
387 evidence={shown?.ticker === open ? shown.found : null}
388 error={shown?.ticker === open ? shown.error : null}
389 questions={snapshot?.criteria ?? criteria}
390 onClose={() => setOpen('')}
391 onLink={(url) => void bridge.openLink(url)}
392 />
393 )}
394
395 <div className="sc-bar sc-foot">
396 <button type="button" className="sc-btn" disabled={page === 0} onClick={() => setPage(page - 1)}>
397 Prev
398 </button>
399 <button type="button" className="sc-btn" disabled={!hasNext} onClick={() => setPage(page + 1)}>
400 Next
401 </button>
402 <span className="sc-status">Page {page + 1}</span>
403 {/* The source's own words, which is how a refused filter or a server that is not running reaches the user. */}
404 <span className={error ? 'sc-error' : 'sc-status'} style={{ marginLeft: 'auto' }}>
405 {error ?? (loading ? 'Loading…' : '')}
406 </span>
407 </div>
408
409 {dialog === 'filters' && (
410 <FilterDialog
411 listed={listed}
412 fields={fields}
413 fieldsError={fieldsError}
414 onApply={(next) => {
415 changeListed(next)
416 setDialog(null)
417 }}
418 onClose={() => setDialog(null)}
419 />
420 )}
421 {dialog === 'columns' && (
422 <ColumnsDialog
423 columns={columns}
424 fields={fields}
425 fieldsError={fieldsError}
426 onApply={(next) => {
427 setColumns(next)
428 setDialog(null)
429 }}
430 onClose={() => setDialog(null)}
431 />
432 )}
433 </div>
434 )
435}