Jaspers Hub

jaspers / research

pluginOfficialReviewed★ 0↓ 28

Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.

2.6 KB
1import type { BackendContext } from '@jaspers-ai/sdk'
2import { errorText } from './errors'
3import { analystIdOf, mergeAnalysts, parseAnalyst, serializeAnalyst, summaryOf, type Analyst, type AnalystInput } from './analysts'
4
5// Analysts on disk, through the plugin's files: the presets in this folder's analysts/, the user's
6// in the research data folder's analysts/. The merged list is the live value `analysts`, which the
7// room view reads, published again whenever the user's folder changes.
8
9/** Errors already announced, by analyst id, so a notice goes out when a file breaks, not on every save. */
10const announced = new Map<string, string>()
11
12export async function loadAnalysts(ctx: BackendContext): Promise<Analyst[]> {
13 const [presets, users] = await Promise.all([readFolder(ctx, 'plugin:analysts', 'preset'), readFolder(ctx, 'analysts', 'user')])
14 return mergeAnalysts(presets, users)
15}
16
17/** Publishes the list for the views; with `announce`, a file that newly fails says so in a notice. */
18export async function publishAnalysts(ctx: BackendContext, announce: boolean): Promise<Analyst[]> {
19 const analysts = await loadAnalysts(ctx)
20 ctx.live.set('analysts', analysts.map(summaryOf))
21 for (const analyst of analysts) {
22 if (analyst.error === null) {
23 announced.delete(analyst.id)
24 } else if (announced.get(analyst.id) !== analyst.error) {
25 announced.set(analyst.id, analyst.error)
26 if (announce) ctx.notify(`analysts/${analyst.id}.md: ${analyst.error}`)
27 }
28 }
29 return analysts
30}
31
32/** Writes one analyst, refusing what would not read back as a working one. */
33export async function saveAnalyst(ctx: BackendContext, input: AnalystInput): Promise<void> {
34 const text = serializeAnalyst(input)
35 const checked = parseAnalyst(input.id, text, 'user')
36 if (checked.error) throw new Error(`Not saved: ${checked.error}.`)
37 await ctx.files.write(`analysts/${input.id}.md`, text)
38}
39
40async function readFolder(ctx: BackendContext, folder: string, origin: Analyst['origin']): Promise<Analyst[]> {
41 const analysts: Analyst[] = []
42 for (const entry of await ctx.files.list(folder)) {
43 const name = entry.path.slice(entry.path.lastIndexOf('/') + 1)
44 // Editors leave hidden lock and swap files beside the ones being edited; they are not analysts.
45 const id = entry.dir || name.startsWith('.') ? null : analystIdOf(entry.path)
46 if (id === null) continue
47 try {
48 analysts.push(parseAnalyst(id, await ctx.files.read(entry.path), origin))
49 } catch (err) {
50 // Gone between the listing and the read, or unreadable: listed with why, not a broken list.
51 analysts.push({ ...parseAnalyst(id, '', origin), error: `could not be read: ${errorText(err, 'unreadable')}` })
52 }
53 }
54 return analysts
55}