import type { BackendContext } from '@jaspers-ai/sdk' import { loadAgents, publishAgents, saveAgent } from './agent-files.ts' import { firstAgent, type Agent } from './agents.ts' import { savePage } from './doc-files.ts' import { companiesUsed, firstTurn, interrupted, newConversationId, recipients, tagged, type AgentMessage, type Chat, type UserMessage, } from './chat.ts' import { errorText } from './errors.ts' import { research, type Hit } from './research.ts' import { companies, keepCurrent, readCompany } from './sec-files.ts' import { runAgent, type RunnerContext } from './runner.ts' // The conversations: each kept as chats/.json in the plugin's data folder and held here once read. // A view shows one conversation, the one its panel names, and a panel placed anew starts a new one; // chat/ is the live value it shows. A message starts one job per agent it goes to, keyed // /, which writes the agent's answer into its conversation as it grows. const FOLDER = 'chats' const chats = new Map() /** Writes go one after another, so a later state never lands under an earlier one. */ let writes: Promise = Promise.resolve() async function load(ctx: BackendContext, id: string): Promise { const held = chats.get(id) if (held) return held const chat = interrupted(JSON.parse(await ctx.files.read(`${FOLDER}/${id}.json`)) as Chat) // Another call may have loaded it while this one read. const raced = chats.get(id) if (raced) return raced chats.set(id, chat) return chat } function commit(ctx: BackendContext, id: string, chat: Chat): void { chats.set(id, chat) ctx.live.set(`chat/${id}`, chat) writes = writes .then(() => ctx.files.write(`${FOLDER}/${id}.json`, JSON.stringify(chat))) .catch((err: unknown) => console.warn(`conversation ${id}: ${errorText(err, 'not saved')}`)) } function checkId(id: string): string { if (!/^[\w-]+$/.test(id)) throw new Error(`No conversation ${id}.`) return id } /** Publishes a panel's conversation and says which: the one it names while that is kept, else a new one. */ export async function openConversation(ctx: BackendContext, wanted?: string): Promise { if (wanted && /^[\w-]+$/.test(wanted)) { try { ctx.live.set(`chat/${wanted}`, await load(ctx, wanted)) return wanted } catch { // Its file is gone: the panel starts again. } } const now = new Date().toISOString() const id = newConversationId(Date.now(), Math.random()) commit(ctx, id, { messages: [], lastTo: [], created: now, updated: now }) return id } /** * Notes a file the user attached in a conversation, before the next message: whoever answers that * message is told it was just attached, since attaching and then asking is asking about it. */ export async function attach(ctx: BackendContext, rawId: string, doc: string, name: string): Promise { const id = checkId(rawId) const chat = await load(ctx, id) const at = new Date().toISOString() const before = (chat.messages.at(-1)?.seq ?? 0) + 1 commit(ctx, id, { ...chat, attached: [...(chat.attached ?? []), { doc, name, at, before }], updated: at }) } export async function post(ctx: BackendContext, rawId: string, text: string): Promise { const id = checkId(rawId) const words = text.trim() if (!words) throw new Error('Write a message.') const agents = await usableAgents(ctx) const chat = await load(ctx, id) const to = recipients(words, agents, chat.lastTo) const at = new Date().toISOString() let seq = (chat.messages.at(-1)?.seq ?? 0) + 1 const asked: UserMessage = { seq, role: 'user', text: words, to, at } const answers: AgentMessage[] = to.map((agent) => ({ seq: ++seq, role: 'agent', agent, inReplyTo: asked.seq, status: 'working', text: '', searches: [], usage: { input: 0, output: 0 }, error: null, at, })) const named = tagged(words, agents) commit(ctx, id, { ...chat, messages: [...chat.messages, asked, ...answers], lastTo: named.length > 0 ? named : chat.lastTo, updated: at, }) const names = new Map(agents.map((a) => [a.id, a.name])) const turn = firstTurn(chats.get(id)!.messages, asked.seq, names, chat.attached) for (const answer of answers) { const agent = agents.find((a) => a.id === answer.agent)! ctx.jobs.start(`${id}/${answer.seq}`, async (signal) => { await runAgent(runnerContext(ctx, id), agent, answer, turn, async (message) => replace(ctx, id, message), signal) }) } return `Sent to ${to.map((a) => `@${a}`).join(', ')}.` } /** The agents that read, one made first when there are none, so a message always has someone to answer it. */ async function usableAgents(ctx: BackendContext): Promise { const all = await loadAgents(ctx) const usable = all.filter((a) => a.error === null) if (usable.length > 0) return usable await saveAgent(ctx, firstAgent(all.map((a) => a.id))) return (await publishAgents(ctx)).filter((a) => a.error === null) } /** Stops every answer still being written in a conversation. */ export function stop(ctx: BackendContext, rawId: string): string { const prefix = `${checkId(rawId)}/` const running = ctx.jobs.running().filter((key) => key.startsWith(prefix)) for (const key of running) ctx.jobs.abort(key, 'Stopped.') return running.length > 0 ? 'Stopped.' : 'Nothing was running.' } function replace(ctx: BackendContext, id: string, message: AgentMessage): void { const chat = chats.get(id) // A message no longer in its conversation has nowhere to go. const same = (m: Chat['messages'][number]): boolean => m.seq === message.seq && m.role === 'agent' && m.agent === message.agent && m.at === message.at if (!chat?.messages.some(same)) return commit(ctx, id, { ...chat, messages: chat.messages.map((m) => (same(m) ? message : m)) }) } /** The companies every conversation held here touches, each once: what the status poller asks the store about. */ export function openTickers(): string[] { const out = new Set() for (const chat of chats.values()) for (const ticker of companiesUsed(chat.messages)) out.add(ticker) return [...out] } function runnerContext(ctx: BackendContext, id: string): RunnerContext { return { llm: ctx.llm, search: (args, signal) => research(ctx, 'search', args, signal), fetchPage: (url, signal) => savePage(ctx, url, signal), searchWeb: (query, signal) => ctx.web.search(query, { signal }), // The store holds every account's companies: the agent is told of the ones this conversation touches. holdings: async (signal) => { const held = await companies(ctx, companiesUsed(chats.get(id)?.messages ?? []), signal) return held.map((c) => ({ ticker: c.ticker, name: c.company ?? c.ticker, reads: c.reads })) }, addCompany: (ticker, years, categories, signal, progress) => readCompany(ctx, ticker, years, categories, signal, progress), keepCurrent: (ticker, signal, progress) => keepCurrent(ctx, ticker, signal, progress), skills: ctx.skills, } }