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.

10 KB
1import { defineConnection, definePlugin, defineSource, defineView } from '@jaspers-ai/sdk'
2import { z } from 'zod'
3import { loadAnalysts, publishAnalysts, saveAnalyst } from './analyst-files'
4import { summaryOf, type AnalystInput } from './analysts'
5import { errorText } from './errors'
6import { INSTRUCTIONS } from './instructions'
7import { roomSummary, type RoomOutput } from './room'
8import { checkRoomHere, claimRooms, createRoom, listRooms, markInterrupted, openRoom, post, readThread, stop, updateRoom } from './rooms'
9import { RoomView } from './RoomView'
10
11// Research rooms: a thread and a roster of analysts who research in parallel over the Jaspers research
12// MCP (its own connection, research/jaspers, on the user's sign-in with Jaspers), reading what the other views on the user's workspace show. The user talks to a room through
13// the orchestrator, whose tools are these sources; the analysts run as jobs in this plugin's host,
14// and the room view renders the thread from live values.
15
16const ROOM_ID = z.string().describe('The room id, as research__rooms and the room view output name it.')
17
18const rooms = defineSource({
19 description:
20 'The research rooms on this workspace, most recently used first: id, name, tickers, analysts, and who is working. A room belongs to the workspace it was started on. A research room is for questions to research in depth in filings and data; finding or filtering companies is the screener\'s job. To start a new room, do not call this: call research__analysts for the analyst ids, then place_view { view: "research/room", state: { start: { name, analysts, tickers, ask } } } and the view creates it. Start it with one analyst, the best fit for the question, unless the user explicitly asks for more than one.',
21 input: z.object({}),
22 ttlMs: 0,
23 run: async (_args, ctx) => listRooms(ctx),
24})
25
26/** For the view: a room is started by placing the view with state.start, which runs this. */
27const createRoomSource = defineSource({
28 description: 'Create a research room. The room view runs this for state.start.',
29 input: z.object({ name: z.string(), analysts: z.array(z.string()), tickers: z.array(z.string()).optional() }),
30 internal: true,
31 run: async (args, ctx) => {
32 const { room, unmatched } = await createRoom(ctx, args as { name: string; analysts: string[]; tickers?: string[] })
33 return { room: room.id, name: room.name, analysts: room.analysts, unmatched }
34 },
35})
36
37const updateRoomSource = defineSource({
38 description: "Change a research room's name, its whole analysts list, or its tickers.",
39 input: z.object({ room: ROOM_ID, name: z.string().optional(), analysts: z.array(z.string()).optional(), tickers: z.array(z.string()).optional() }),
40 run: async (args, ctx) => {
41 const room = await updateRoom(ctx, args as { room: string; name?: string; analysts?: string[]; tickers?: string[] })
42 return `Room ${room.id} is ${room.name}, with ${room.analysts.join(', ') || 'no analysts'}${room.tickers.length ? ` on ${room.tickers.join(', ')}` : ''}.`
43 },
44})
45
46const postSource = defineSource({
47 description:
48 "Put the user's message into an open research room for the named analysts. room is the id in the room view's state.room; text is the user's own words, pointing at a view as they did (\"the companies in my screener\"), since the analysts read what the views on this workspace show; to is analyst ids (an analyst not yet in the room joins it). Returns at once; the answers arrive in the room. Never answer the research question yourself. With no room open yet, start one instead: place_view { view: 'research/room', state: { start: { name, analysts, tickers, ask } } }, where ask is this message and analysts is one id from research__analysts, the best fit, unless the user explicitly asked for more than one. Not for finding or filtering companies: that is the screener.",
49 input: z.object({ room: ROOM_ID, text: z.string(), to: z.array(z.string()) }),
50 run: async (args, ctx) => post(ctx, args as { room: string; text: string; to: string[] }),
51})
52
53const stopSource = defineSource({
54 description: "Stop a research room's analysts, or one of them. Partial answers are kept.",
55 input: z.object({ room: ROOM_ID, analyst: z.string().optional() }),
56 run: async (args, ctx) => stop(ctx, args as { room: string; analyst?: string }),
57})
58
59const thread = defineSource({
60 description: "Read back a research room's messages as text, every one whole with all its sources, for when the user asks what the analysts found. last limits it to the most recent few; analyst to one analyst's.",
61 input: z.object({ room: ROOM_ID, last: z.number().int().positive().optional(), analyst: z.string().optional() }),
62 ttlMs: 0,
63 run: async (args, ctx) => readThread(ctx, args as { room: string; last?: number; analyst?: string }),
64})
65
66const openFile = defineSource({
67 description: "Open a file an analyst made in a research room, like output/FLWS Debt Waterfall.xlsx, in its default app.",
68 input: z.object({ room: ROOM_ID, path: z.string() }),
69 run: async (args, ctx) => {
70 const { room, path } = args as { room: string; path: string }
71 await checkRoomHere(ctx, room)
72 await ctx.files.open(`rooms/${room}/sandbox/${path}`)
73 return `Opened ${path}.`
74 },
75})
76
77const loadRoom = defineSource({
78 description: 'Load a research room for its view.',
79 input: z.object({ room: ROOM_ID }),
80 internal: true,
81 ttlMs: 0,
82 run: async (args, ctx) => {
83 const live = await openRoom(ctx, (args as { room: string }).room)
84 return `Loaded ${live.room.id}.`
85 },
86})
87
88const reveal = defineSource({
89 description: "Show a research room's folder, or one file in it, in the Finder.",
90 input: z.object({ room: ROOM_ID, path: z.string().optional() }),
91 internal: true,
92 run: async (args, ctx) => {
93 const { room, path } = args as { room: string; path?: string }
94 await checkRoomHere(ctx, room)
95 await ctx.files.reveal(path ? `rooms/${room}/sandbox/${path}` : `rooms/${room}`)
96 return 'Shown.'
97 },
98})
99
100const analysts = defineSource({
101 description:
102 'The research analysts: id, name, description, model, connections, and any error in the file. With id, that analyst whole, instructions included.',
103 input: z.object({ id: z.string().optional() }),
104 ttlMs: 0,
105 run: async (args, ctx) => {
106 const { id } = args as { id?: string }
107 const list = await loadAnalysts(ctx)
108 if (id === undefined) return list.map(summaryOf)
109 const analyst = list.find((a) => a.id === id)
110 if (!analyst) throw new Error(`No analyst ${id}. The analysts are ${list.map((a) => a.id).join(', ')}.`)
111 return analyst
112 },
113})
114
115const saveAnalystSource = defineSource({
116 description:
117 "Create or change a research analyst, saved as analysts/<id>.md in the research folder. id is lower case letters, digits, and dashes; a preset's id replaces that preset. instructions say, in the second person, how the analyst works. To change one, read it with research__analysts { id } first and send it back whole.",
118 input: z.object({
119 id: z.string(),
120 name: z.string(),
121 description: z.string(),
122 instructions: z.string(),
123 model: z.string().optional(),
124 connections: z.array(z.string()).optional(),
125 tools: z.array(z.string()).optional(),
126 maxTurns: z.number().int().optional(),
127 color: z.string().optional(),
128 }),
129 run: async (args, ctx) => {
130 const input = args as AnalystInput
131 await saveAnalyst(ctx, input)
132 await publishAnalysts(ctx, false)
133 return `Saved ${input.name} as analysts/${input.id}.md.`
134 },
135})
136
137const Output = z.object({
138 room: z.string().nullable(),
139 name: z.string().optional(),
140 tickers: z.array(z.string()).optional(),
141 analysts: z.array(z.object({ id: z.string(), name: z.string(), status: z.string() })).optional(),
142 last: z.array(z.object({ seq: z.number(), from: z.string(), status: z.string(), excerpt: z.string(), files: z.array(z.string()) })).optional(),
143 messages: z.number().optional(),
144 files: z.number().optional(),
145 available: z.number().optional(),
146 starting: z.string().optional(),
147 error: z.string().optional(),
148})
149
150/** A room to make as the view opens, and optionally the first question for its analysts. */
151const Start = z.object({
152 name: z.string().describe('What the room is about, like "FLWS refinancing".'),
153 analysts: z.array(z.string()).describe('Analyst ids, from research__analysts: one, the best fit for the question, unless the user explicitly asked for more.'),
154 tickers: z.array(z.string()).optional(),
155 ask: z.string().optional().describe("The user's first question, in their words, posted to every analyst once the room exists."),
156})
157
158export default definePlugin({
159 id: 'research',
160 connections: {
161 // The server signs its users in through Jaspers' Account: the terminal connects it on its own sign-in
162 // with Jaspers and sends that token as a bearer on every call. Nothing to paste, and nothing in the URL.
163 jaspers: defineConnection({
164 url: 'https://analyst-api.jsprai.com/mcp/open',
165 auth: 'oauth',
166 tools: [
167 'search_filings',
168 'keyword_search_filings',
169 'fetch_filings',
170 'fetch_chunk',
171 'query_filings',
172 'aggregate_filings',
173 'get_distinct_values',
174 'count_filings',
175 'get_company_status',
176 'get_guide',
177 'show_citations',
178 ],
179 }),
180 },
181 capabilities: ['llm', 'tools', 'files', 'state'],
182 async start(ctx) {
183 await markInterrupted(ctx)
184 await claimRooms(ctx)
185 await publishAnalysts(ctx, false)
186 ctx.files.watch('analysts', () => {
187 publishAnalysts(ctx, true).catch((err: unknown) => console.warn(`analysts: ${errorText(err, 'failed')}`))
188 })
189 },
190 sources: {
191 rooms,
192 'create-room': createRoomSource,
193 'update-room': updateRoomSource,
194 post: postSource,
195 stop: stopSource,
196 thread,
197 'open-file': openFile,
198 'load-room': loadRoom,
199 reveal,
200 analysts,
201 'save-analyst': saveAnalystSource,
202 },
203 views: {
204 room: defineView(RoomView, {
205 title: 'Research room',
206 state: z.object({
207 room: z.string().optional().describe('The id of an existing room, as research__rooms lists it. Never made up.'),
208 start: Start.optional(),
209 }),
210 output: Output,
211 instructions: INSTRUCTIONS,
212 summarize: (_state: unknown, output: (Partial<RoomOutput> & { room: string | null; available?: number; starting?: string; error?: string }) | null) =>
213 output?.error
214 ? output.error
215 : output?.starting
216 ? `Starting ${output.starting}…`
217 : output?.room
218 ? roomSummary(output as RoomOutput)
219 : `No room open · ${output?.available ?? 0} analysts`,
220 }),
221 },
222})