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.

9.2 KB
1import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
2
3// Analysts are files: frontmatter saying who one is and what it may use, then the instructions it
4// works by. This is the reading and the writing of those files, and the merge of the plugin's presets
5// with the user's own, pure so the format can be read in a test. A file that breaks a rule still
6// becomes an analyst, carrying the error, so the room can show the user what to fix.
7
8export interface Analyst {
9 id: string
10 name: string
11 description: string
12 /** A model on the configured provider; null for the app's own. */
13 model: string | null
14 /** Connection ids whose tools it gets; empty for every ready connection. */
15 connections: string[]
16 /** Tool names it is limited to on those connections; empty for all of them. */
17 tools: string[]
18 maxTurns: number
19 /** #rrggbb for its square, or null. */
20 color: string | null
21 instructions: string
22 origin: 'preset' | 'user'
23 /** Why the file is not usable, worded for the user; null when it is. */
24 error: string | null
25}
26
27/** What save-analyst writes: an analyst without what reading one adds. */
28export interface AnalystInput {
29 id: string
30 name: string
31 description: string
32 instructions: string
33 model?: string | null
34 connections?: string[]
35 tools?: string[]
36 maxTurns?: number
37 color?: string | null
38}
39
40export const ANALYST_ID = /^[a-z0-9][a-z0-9-]*$/
41export const INSTRUCTIONS_MAX = 10_000
42const NAME_MAX = 80
43const DESCRIPTION_MAX = 500
44const MODEL_MAX = 200
45const MAX_TURNS_DEFAULT = 50
46const MAX_TURNS_LIMIT = 200
47const COLOR = /^#[0-9a-fA-F]{6}$/
48const FIELDS = ['name', 'description', 'model', 'connections', 'tools', 'max-turns', 'color']
49/** The frontmatter: a --- line, anything, and the next line that is only ---. */
50const FRONTMATTER = /^---\n([\s\S]*?)^---[ \t]*$/m
51
52export function parseAnalyst(id: string, text: string, origin: Analyst['origin']): Analyst {
53 const analyst: Analyst = {
54 id,
55 name: id,
56 description: '',
57 model: null,
58 connections: [],
59 tools: [],
60 maxTurns: MAX_TURNS_DEFAULT,
61 color: null,
62 instructions: '',
63 origin,
64 error: null,
65 }
66 try {
67 if (!ANALYST_ID.test(id)) throw new Error('the file name has to be lower case letters, digits, and dashes, then .md')
68 const normalized = text.replace(/^/, '').replace(/\r\n?/g, '\n')
69 const match = FRONTMATTER.exec(normalized)
70 if (!match || match.index !== 0) throw new Error('the file has to start with a --- line, then the frontmatter, then a closing --- line')
71 const data = frontmatter(match[1] ?? '')
72 const unknown = Object.keys(data).filter((key) => !FIELDS.includes(key))
73 if (unknown.length > 0) throw new Error(`unknown field ${unknown.join(', ')}; the fields are ${FIELDS.join(', ')}`)
74 analyst.name = textField(data['name'], 'name', NAME_MAX, true)
75 analyst.description = textField(data['description'], 'description', DESCRIPTION_MAX, false)
76 analyst.model = textField(data['model'], 'model', MODEL_MAX, false) || null
77 analyst.connections = names(data['connections'], 'connections')
78 analyst.tools = names(data['tools'], 'tools')
79 analyst.maxTurns = turns(data['max-turns'])
80 analyst.color = color(data['color'])
81 const body = normalized.slice(match[0].length).trim()
82 if (body === '') throw new Error('the instructions, the text after the frontmatter, are empty')
83 if (body.length > INSTRUCTIONS_MAX) throw new Error(`the instructions are ${body.length} characters; the most is ${INSTRUCTIONS_MAX}`)
84 analyst.instructions = body
85 } catch (err) {
86 analyst.error = err instanceof Error ? err.message : String(err)
87 }
88 return analyst
89}
90
91export function serializeAnalyst(input: AnalystInput): string {
92 const front: Record<string, unknown> = { name: input.name.trim() }
93 if (input.description.trim()) front['description'] = input.description.trim()
94 if (input.model?.trim()) front['model'] = input.model.trim()
95 if (input.connections?.length) front['connections'] = input.connections
96 if (input.tools?.length) front['tools'] = input.tools
97 if (input.maxTurns !== undefined && input.maxTurns !== MAX_TURNS_DEFAULT) front['max-turns'] = input.maxTurns
98 if (input.color) front['color'] = input.color
99 return `---\n${stringifyYaml(front, { lineWidth: 0 }).trimEnd()}\n---\n\n${input.instructions.trim()}\n`
100}
101
102/** The presets with the user's files over them by id, sorted by name for the roster. */
103export function mergeAnalysts(presets: Analyst[], users: Analyst[]): Analyst[] {
104 const byId = new Map<string, Analyst>()
105 for (const analyst of presets) byId.set(analyst.id, analyst)
106 for (const analyst of users) byId.set(analyst.id, analyst)
107 return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
108}
109
110/** The id a file gives an analyst, or null for a file that is not one. */
111export function analystIdOf(path: string): string | null {
112 const name = path.slice(path.lastIndexOf('/') + 1)
113 return name.endsWith('.md') ? name.slice(0, -'.md'.length) : null
114}
115
116/** An analyst as a list shows it: everything but the instructions. */
117export function summaryOf(analyst: Analyst): Omit<Analyst, 'instructions'> {
118 const { instructions: _instructions, ...rest } = analyst
119 return rest
120}
121
122function frontmatter(source: string): Record<string, unknown> {
123 let data: unknown
124 try {
125 data = source.trim() === '' ? {} : parseYaml(source)
126 } catch (err) {
127 throw new Error(`the frontmatter is not valid YAML: ${(err instanceof Error ? err.message : String(err)).split('\n')[0]}`)
128 }
129 if (typeof data !== 'object' || data === null || Array.isArray(data)) throw new Error('the frontmatter has to be key: value lines')
130 return data as Record<string, unknown>
131}
132
133function textField(value: unknown, field: string, max: number, required: boolean): string {
134 if (value === undefined || value === null || value === '') {
135 if (required) throw new Error(`${field} is missing`)
136 return ''
137 }
138 if (typeof value !== 'string' && typeof value !== 'number') throw new Error(`${field} has to be text`)
139 const text = String(value).trim()
140 if (required && text === '') throw new Error(`${field} is missing`)
141 if (text.length > max) throw new Error(`${field} is ${text.length} characters; the most is ${max}`)
142 return text
143}
144
145function names(value: unknown, field: string): string[] {
146 if (value === undefined || value === null || value === '') return []
147 const list: unknown = typeof value === 'string' ? [value] : value
148 if (!Array.isArray(list) || !list.every((item) => typeof item === 'string' && item.trim() !== '')) {
149 throw new Error(`${field} has to be a list of names, like [research/jaspers]`)
150 }
151 return [...new Set(list.map((item: string) => item.trim()))]
152}
153
154function turns(value: unknown): number {
155 if (value === undefined || value === null || value === '') return MAX_TURNS_DEFAULT
156 if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > MAX_TURNS_LIMIT) {
157 throw new Error(`max-turns has to be a whole number from 1 to ${MAX_TURNS_LIMIT}`)
158 }
159 return value
160}
161
162function color(value: unknown): string | null {
163 if (value === undefined || value === null || value === '') return null
164 if (typeof value !== 'string' || !COLOR.test(value)) throw new Error('color has to be #rrggbb in quotes, like "#1E3A5F"')
165 return value
166}
167
168/** Words that say what someone is rather than which one; a loose name is matched without them. */
169const GENERIC = new Set(['analyst', 'analysts', 'researcher', 'research', 'agent', 'expert', 'specialist', 'the', 'a', 'an'])
170
171/**
172 * The analysts a model asked for, found the forgiving way, since models name them loosely: the exact
173 * id, then the name or its slug ("Risk Analyst", "fact checker"), then the one analyst whose id words
174 * are all in the request ("ownership-researcher" is ownership), the most specific when several are.
175 * What matches nothing, or only a broken file, comes back as unmatched instead of failing the rest.
176 */
177export function resolveAnalysts(all: Analyst[], requested: string[]): { ids: string[]; unmatched: string[] } {
178 const usable = all.filter((a) => a.error === null)
179 const ids: string[] = []
180 const unmatched: string[] = []
181 for (const raw of requested) {
182 const wanted = raw.trim()
183 if (!wanted) continue
184 const found = matchAnalyst(usable, wanted)
185 if (found === null) unmatched.push(wanted)
186 else if (!ids.includes(found)) ids.push(found)
187 }
188 return { ids, unmatched }
189}
190
191function matchAnalyst(usable: Analyst[], wanted: string): string | null {
192 const exact = usable.find((a) => a.id === wanted)
193 if (exact) return exact.id
194 const byName = usable.filter((a) => a.name.toLowerCase() === wanted.toLowerCase() || a.id === words(wanted).join('-'))
195 if (byName.length === 1) return byName[0]!.id
196 const asked = new Set(words(wanted).filter((w) => !GENERIC.has(w)))
197 if (asked.size === 0) return null
198 const covered = usable.filter((a) => {
199 const own = words(a.id).filter((w) => !GENERIC.has(w))
200 return own.length > 0 && own.every((w) => asked.has(w))
201 })
202 if (covered.length === 0) return null
203 const most = Math.max(...covered.map((a) => words(a.id).length))
204 const best = covered.filter((a) => words(a.id).length === most)
205 return best.length === 1 ? best[0]!.id : null
206}
207
208function words(text: string): string[] {
209 return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
210}