Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.
| 1 | import type { BackendContext } from '@jaspers-ai/sdk' |
| 2 | import { errorText } from './errors' |
| 3 | import { 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. */ |
| 10 | const announced = new Map<string, string>() |
| 11 | |
| 12 | export 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. */ |
| 18 | export 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. */ |
| 33 | export 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 | |
| 40 | async 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 | } |