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.

14 KB
1import { citationLink, numberCitations, numberKey, sourceTitle, toMarkers, type Audit, type Citation } from './citations.ts'
2
3// A research room and its thread, as data: the shapes on disk and in live values, and the pure work
4// on them the runner and the view share. How a room is named and numbered, what an analyst is shown
5// of the thread, where a tool chip sits, which old tool results make way, and what the orchestrator
6// reads back. No files and no model here, so all of it can be read in a test.
7
8export interface Room {
9 id: string
10 name: string
11 tickers: string[]
12 /** Analyst ids, in roster order. */
13 analysts: string[]
14 createdAt: string
15 updatedAt: string
16 /**
17 * The workspace the room belongs to: the one it was started on. It is listed, opened, and posted to
18 * from there only. A room made before rooms belonged to one is given one when the plugin starts.
19 */
20 workspace?: string
21}
22
23export interface Step {
24 /** The length of the message's text when the call was made: where its chip sits. */
25 at: number
26 tool: string
27 /** The arguments as JSON, cut at 200 characters. */
28 args: string
29 summary: string | null
30 /** The saved result's ref, like r7, once it came back. */
31 ref: string | null
32 isError: boolean
33}
34
35export type MessageStatus = 'queued' | 'working' | 'done' | 'stopped' | 'error'
36
37export interface UserMessage {
38 seq: number
39 role: 'user'
40 text: string
41 to: string[]
42 at: string
43 /** The workspace on screen as the user posted it, whose views the analysts read. */
44 workspace?: string
45}
46
47export interface AnalystMessage {
48 seq: number
49 role: 'analyst'
50 analyst: string
51 name: string
52 color: string | null
53 inReplyTo: number
54 status: MessageStatus
55 text: string
56 steps: Step[]
57 files: { path: string; size: number }[]
58 /** The sources the answer cites, numbered as in its text, with the server's verdict on each. */
59 citations: Citation[]
60 /** What the citation audit found, once the answer was done; null before. */
61 audit: Audit | null
62 turns: number
63 error: string | null
64 usage: { input: number; output: number }
65 startedAt: string | null
66 endedAt: string | null
67}
68
69export type Message = UserMessage | AnalystMessage
70
71/** What the room view publishes, for the orchestrator. */
72export interface RoomOutput {
73 room: string
74 name: string
75 tickers: string[]
76 analysts: { id: string; name: string; status: 'idle' | 'queued' | 'working' }[]
77 last: { seq: number; from: string; status: string; excerpt: string; files: string[] }[]
78 messages: number
79 files: number
80}
81
82/** Whether a call may reach a room: one of its own workspace's, or any when the call names no workspace. */
83export function onWorkspace(room: Room, workspace: string | null): boolean {
84 return workspace === null || room.workspace === workspace
85}
86
87/**
88 * The workspace for a room made before rooms belonged to one: the workspace its earliest message was
89 * posted from, among those still there, else `fallback`.
90 */
91export function claimWorkspace(messages: Message[], known: ReadonlySet<string>, fallback: string | null): string | null {
92 const first = [...messages]
93 .sort((a, b) => a.seq - b.seq)
94 .find((m): m is UserMessage => m.role === 'user' && typeof m.workspace === 'string' && known.has(m.workspace))
95 return first?.workspace ?? fallback
96}
97
98/** A tool result as the runner keeps it in the model's turns. */
99interface ToolTurn {
100 role: 'tool'
101 results: { callId: string; output: string; isError: boolean }[]
102}
103
104const SLUG_MAX = 40
105const ARGS_MAX = 200
106const EXCERPT = 300
107const OUTPUT_MAX = 4096
108const STUB_PREVIEW = 1200
109
110export function roomIdFor(name: string, random: string): string {
111 const slug = name
112 .toLowerCase()
113 .replace(/[^a-z0-9]+/g, '-')
114 .replace(/^-+|-+$/g, '')
115 .slice(0, SLUG_MAX)
116 .replace(/-+$/, '')
117 return `${slug || 'room'}-${random}`
118}
119
120export function seqFile(seq: number): string {
121 return `${String(seq).padStart(6, '0')}.json`
122}
123
124export function nextSeq(messages: Message[]): number {
125 return messages.reduce((max, m) => Math.max(max, m.seq), 0) + 1
126}
127
128/**
129 * What an analyst is shown of the thread, as the first user turn: the whole room, every message
130 * numbered and labeled with who said it, then the message for this analyst. Only the answer it is
131 * writing is left out. One labeled transcript keeps the analysts apart, which unlabeled history does not.
132 */
133export function transcript(input: {
134 messages: Message[]
135 names: Record<string, string>
136 analyst: string
137 message: UserMessage
138 /** The seq of the answer this analyst is writing. */
139 answer: number
140}): string {
141 const { messages, names, analyst, message, answer } = input
142 const blocks = messages.filter((m) => m.seq !== message.seq && m.seq !== answer).map((m) => messageLine(m, names))
143 const lines: string[] = []
144 if (blocks.length > 0) {
145 lines.push('The room so far:', '')
146 for (const block of blocks) lines.push(block, '')
147 }
148 lines.push(`New message for you, ${names[analyst] ?? analyst}:`, messageLine(message, names))
149 return lines.join('\n')
150}
151
152/** What an analyst has seen of each message, by seq, as `stateOf` puts it. */
153export function shownOf(messages: Message[]): Map<number, string> {
154 return new Map(messages.map((m) => [m.seq, stateOf(m)]))
155}
156
157/**
158 * What came into the room since the analyst last saw it, as a turn for its model: a message it has
159 * not seen, or an answer that has finished since it saw it being written. An answer still being
160 * written waits until it is done. What comes back is noted in `shown`, so each arrives once.
161 */
162export function roomNews(input: {
163 messages: Message[]
164 names: Record<string, string>
165 message: UserMessage
166 answer: number
167 shown: Map<number, string>
168}): string | null {
169 const { messages, names, message, answer, shown } = input
170 const fresh = messages.filter((m) => m.seq !== answer && stateOf(m) !== 'open' && shown.get(m.seq) !== stateOf(m))
171 if (fresh.length === 0) return null
172 for (const m of fresh) shown.set(m.seq, stateOf(m))
173 return [`Room update: these came in while you were working. You are still answering #${message.seq}.`, ...fresh.map((m) => messageLine(m, names))].join('\n\n')
174}
175
176/** A step for a call made when the message's text had this much in it. */
177export function stepFor(text: string, tool: string, input: Record<string, unknown>): Step {
178 return { at: text.length, tool, args: JSON.stringify(input).slice(0, ARGS_MAX), summary: null, ref: null, isError: false }
179}
180
181/** One line saying what a tool gave back, for its chip. */
182export function resultSummary(text: string, isError: boolean): string {
183 if (isError) return `Error: ${(text.split('\n')[0] ?? '').slice(0, 120)}`
184 const count = rowCount(text)
185 return count === null ? `${text.length.toLocaleString('en-US')} characters` : `${count} result${count === 1 ? '' : 's'}`
186}
187
188/**
189 * The turns with old, large tool results swapped for stubs until what is kept fits the budget. The
190 * newest `keepNewest` results and anything under `minSize` stay whole; a stub names its ref, so the
191 * model reads the rest with fetch_result instead of running the search again.
192 */
193export function evictResults<T extends { role: string }>(
194 turns: T[],
195 refs: Map<string, { ref: string; tool: string; args: string }>,
196 options: { budget: number; keepNewest: number; minSize: number },
197): T[] {
198 const results = turns.flatMap((turn) => (turn.role === 'tool' ? (turn as unknown as ToolTurn).results : []))
199 let kept = results.reduce((sum, r) => sum + (isStub(r.output) ? 0 : r.output.length), 0)
200 const protectedIds = new Set(results.slice(-options.keepNewest).map((r) => r.callId))
201 const evicted = new Map<string, string>()
202 for (const result of results) {
203 if (kept <= options.budget) break
204 if (protectedIds.has(result.callId) || isStub(result.output) || result.output.length < options.minSize) continue
205 const about = refs.get(result.callId)
206 if (!about) continue
207 evicted.set(
208 result.callId,
209 `[${about.ref} evicted: ${about.tool} ${about.args}. First ${STUB_PREVIEW} characters: ${result.output.slice(0, STUB_PREVIEW)} … Read the rest with fetch_result("${about.ref}", offset).]`,
210 )
211 kept -= result.output.length
212 }
213 if (evicted.size === 0) return turns
214 return turns.map((turn) => {
215 if (turn.role !== 'tool') return turn
216 const tool = turn as unknown as ToolTurn
217 if (!tool.results.some((r) => evicted.has(r.callId))) return turn
218 return { ...turn, results: tool.results.map((r) => (evicted.has(r.callId) ? { ...r, output: evicted.get(r.callId)! } : r)) }
219 })
220}
221
222/** What the orchestrator reads: who is in the room and busy, and the last three messages. Within 4 KB. */
223export function outputOf(room: Room, messages: Message[], roster: { id: string; name: string }[]): RoomOutput {
224 const analysts = roster.map(({ id, name }) => {
225 const theirs = messages.filter((m): m is AnalystMessage => m.role === 'analyst' && m.analyst === id)
226 const status = theirs.some((m) => m.status === 'working') ? 'working' : theirs.some((m) => m.status === 'queued') ? 'queued' : 'idle'
227 return { id, name, status: status as RoomOutput['analysts'][number]['status'] }
228 })
229 const files = messages.reduce((sum, m) => sum + (m.role === 'analyst' ? m.files.length : 0), 0)
230 for (const excerpt of [EXCERPT, 150, 60]) {
231 const output: RoomOutput = {
232 room: room.id,
233 name: room.name,
234 tickers: room.tickers,
235 analysts,
236 last: messages.slice(-3).map((m) => ({
237 seq: m.seq,
238 from: m.role === 'user' ? 'you' : m.name,
239 status: m.role === 'user' ? 'sent' : m.status,
240 excerpt: toMarkers(m.text).slice(0, excerpt),
241 files: m.role === 'user' ? [] : m.files.map((f) => f.path.slice(f.path.lastIndexOf('/') + 1)),
242 })),
243 messages: messages.length,
244 files,
245 }
246 if (JSON.stringify(output).length <= OUTPUT_MAX) return output
247 }
248 return { room: room.id, name: room.name, tickers: room.tickers, analysts, last: [], messages: messages.length, files }
249}
250
251/** The one line the model's map carries for a room. */
252export function roomSummary(output: RoomOutput): string {
253 const busy = output.analysts.filter((a) => a.status !== 'idle').map((a) => `${a.name} ${a.status}`)
254 const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
255 return [output.name, busy.length > 0 ? busy.join(', ') : 'idle', plural(output.messages, 'msg'), plural(output.files, 'file')].join(' · ')
256}
257
258/**
259 * The room's messages as text, for "what did they find?": every one, or the last few or one analyst's
260 * when asked, each whole, its citation tags shown as [n] markers and every source listed under it
261 * with the sec.gov link.
262 */
263export function threadText(messages: Message[], names: Record<string, string>, options: { last?: number; analyst?: string }): string {
264 const chosen = options.analyst ? messages.filter((m) => m.role === 'analyst' && m.analyst === options.analyst) : messages
265 return (options.last === undefined ? chosen : chosen.slice(-options.last))
266 .map((m) => {
267 if (m.role === 'user') return `#${m.seq} ${userLine(m, names)}`
268 const text = toMarkers(m.text).trim() || (m.error ? `(error: ${m.error})` : '(nothing yet)')
269 const sources = (m.citations ?? []).map((c) => `[${c.n}] ${sourceLine(c)}`)
270 return [`#${m.seq} [${m.name}]${statusMark(m)}: ${text}`, ...(sources.length > 0 ? [`Sources: ${sources.join(' ')}`] : [])].join('\n')
271 })
272 .join('\n\n')
273}
274
275/**
276 * An answer as the user copies it: what the analyst wrote after its last tool call, since the text
277 * before that narrates the research, with its tags as the [n] markers the view shows and the sources
278 * those markers point to listed under it. Empty until the answer is in: a draft still being checked
279 * may be repaired.
280 */
281export function answerText(message: AnalystMessage): string {
282 if (message.status === 'queued' || message.status === 'working') return ''
283 const at = message.steps.reduce((last, step) => Math.max(last, step.at), 0)
284 const answer = message.text.slice(at)
285 const numbered = numberCitations(message.text)
286 const text = toMarkers(answer, numbered).trim()
287 if (!text) return ''
288 const cited = new Set(numberCitations(answer).map((c) => numberKey(c.attrs)))
289 const sources = numbered
290 .filter((c) => cited.has(numberKey(c.attrs)))
291 .map((c) => {
292 const citation = (message.citations ?? []).find((r) => r.n === c.n)
293 const link = citation ? citationLink(citation) : null
294 return `- [${c.n}] ${sourceTitle(c, citation)}${citation?.verified === false ? ' (quote not found)' : ''}${link ? `: ${link}` : ''}`
295 })
296 return sources.length > 0 ? `${text}\n\n## Sources\n\n${sources.join('\n')}` : text
297}
298
299/** One source in a line: what it is, whether its quote was found, and where it opens. */
300function sourceLine(c: Citation): string {
301 const state = c.verified === true ? 'verified' : c.verified === false ? 'unverified' : 'unchecked'
302 return [c.title ?? `document ${c.documentId}`, state, citationLink(c) ?? ''].filter(Boolean).join(' · ')
303}
304
305function userLine(message: UserMessage, names: Record<string, string>): string {
306 return `[You → ${message.to.map((id) => names[id] ?? id).join(', ')}]: ${message.text}`
307}
308
309/** One message as an analyst reads it: its number, who, and what it says, or where it stands when it says nothing. */
310function messageLine(message: Message, names: Record<string, string>): string {
311 if (message.role === 'user') return `#${message.seq} ${userLine(message, names)}`
312 const text = message.text.trim()
313 if (!text) return `#${message.seq} [${message.name}] (${message.status}${message.error ? `: ${message.error}` : ''})`
314 return `#${message.seq} [${message.name}]${statusMark(message)}: ${text}`
315}
316
317/** Sent for the user's words; for an answer, open while it is queued or being written, final after. */
318function stateOf(message: Message): 'sent' | 'open' | 'final' {
319 if (message.role === 'user') return 'sent'
320 return message.status === 'queued' || message.status === 'working' ? 'open' : 'final'
321}
322
323function statusMark(message: AnalystMessage): string {
324 return message.status === 'done' ? '' : ` (${message.status})`
325}
326
327function isStub(output: string): boolean {
328 return /^\[r\d+ evicted: /.test(output)
329}
330
331function rowCount(text: string): number | null {
332 try {
333 const value: unknown = JSON.parse(text)
334 if (Array.isArray(value)) return value.length
335 if (typeof value === 'object' && value !== null) {
336 for (const key of ['rows', 'results', 'items', 'data', 'companies', 'filings']) {
337 const rows = (value as Record<string, unknown>)[key]
338 if (Array.isArray(rows)) return rows.length
339 }
340 }
341 } catch {
342 // Prose, not JSON.
343 }
344 return null
345}