Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.
| 1 | import { defineConnection, definePlugin, defineSource, defineView } from '@jaspers-ai/sdk' |
| 2 | import { z } from 'zod' |
| 3 | import { loadAnalysts, publishAnalysts, saveAnalyst } from './analyst-files' |
| 4 | import { summaryOf, type AnalystInput } from './analysts' |
| 5 | import { errorText } from './errors' |
| 6 | import { INSTRUCTIONS } from './instructions' |
| 7 | import { roomSummary, type RoomOutput } from './room' |
| 8 | import { checkRoomHere, claimRooms, createRoom, listRooms, markInterrupted, openRoom, post, readThread, stop, updateRoom } from './rooms' |
| 9 | import { 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 | |
| 16 | const ROOM_ID = z.string().describe('The room id, as research__rooms and the room view output name it.') |
| 17 | |
| 18 | const 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. */ |
| 27 | const 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 | |
| 37 | const 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 | |
| 46 | const 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 | |
| 53 | const 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 | |
| 59 | const 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 | |
| 66 | const 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 | |
| 77 | const 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 | |
| 88 | const 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 | |
| 100 | const 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 | |
| 115 | const 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 | |
| 137 | const 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. */ |
| 151 | const 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 | |
| 158 | export 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 | }) |