1import 'server-only';2import { getDb, researchSessions, researchMessages, eq, desc, and, sql } from '@rareindex/database';3import { newId } from '@rareindex/shared';4import { getRouter, type ChatMessage, type StreamDelta } from '@rareindex/ai';5import { executeTool, summarizeToolResult, toolDefinitions } from './research-tools';67export const RESEARCH_SYSTEM = `You are RareIndex Research, the analyst interface of RareIndex.io — a market-data terminal for collectibles (trading cards, sports cards, comics, video games, sneakers, watches, LEGO, toys, coins, art…).89Rules:101. Answer ONLY from tool results. Never state a price, count, return, population or date that did not come from a tool in this conversation. If tools return nothing, say "Data unavailable" and explain what RareIndex does or does not track yet (use platform_coverage).112. Start by calling the right tools; chain them (search_assets → get_asset → get_asset_sales…). Prefer one well-parameterised call over many.123. Every figure you cite must carry its context: sample size, confidence label, as-of date, and the currency (USD unless stated). Listing prices are asks, not sales. RIV is an estimate.134. Cite assets as markdown links: [Title](/asset/slug); categories as [Name](/markets/slug); indices as [TICKER](/rareindex/TICKER).145. Use compact markdown tables for comparisons (≤ 8 rows). Keep prose short and analytical. No investment advice; describe data, not recommendations.156. Windows: 7d/30d/90d/1y. If the user asks for a horizon RareIndex cannot support, say so.167. You may explain methodology briefly (RIV = ensemble of latest/median/volume-weighted/trimmed/exponentially-weighted prices with outlier flags; indices are chain-linked from constituent valuations and published only above a minimum breadth).`;1718export const SUGGESTED_PROMPTS = [19 'Find Pokémon cards under $5,000 that have increased more than 30% in the last year.',20 'Compare RARE-WATCH versus RARE-TCG over the last 90 days.',21 'Find sealed Nintendo games with fewer than 20 public sales in the last year.',22 'Which collectible category has the strongest momentum this month?',23 'What are the highest verified sales tracked by RareIndex?',24 'How much data does RareIndex currently cover?',25];2627export interface ResearchTurnInput {28 sessionId: string | null;29 anonId: string | null;30 userId: string | null;31 message: string;32 signal?: AbortSignal;33}3435export type ResearchEvent =36 | { type: 'session'; sessionId: string }37 | { type: 'text'; text: string }38 | { type: 'thinking'; text: string }39 | { type: 'tool_call'; id: string; name: string; input: unknown }40 | { type: 'tool_result'; id: string; name: string; ok: boolean; ms: number; summary: string; preview: unknown }41 | { type: 'done'; usdEst: number; model: string | null }42 | { type: 'error'; message: string };4344const MAX_HISTORY = 16;4546export async function loadSession(sessionId: string, owner: { anonId: string | null; userId: string | null }) {47 const db = getDb();48 const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1);49 if (!s) return null;50 if (s.userId && s.userId !== owner.userId) return null;51 if (!s.userId && s.anonId && s.anonId !== owner.anonId) return null;52 const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt);53 return { session: s, messages };54}5556export async function listSessions(owner: { anonId: string | null; userId: string | null }, limit = 20) {57 const db = getDb();58 const where = owner.userId ? eq(researchSessions.userId, owner.userId) : owner.anonId ? and(eq(researchSessions.anonId, owner.anonId), sql`${researchSessions.userId} is null`) : null;59 if (!where) return [];60 return db.select().from(researchSessions).where(and(where, eq(researchSessions.archived, false))).orderBy(desc(researchSessions.updatedAt)).limit(limit);61}6263/** Run one research turn: persists the user message, streams the agent, persists the answer + tool trace. */64export async function* researchTurn(input: ResearchTurnInput): AsyncGenerator<ResearchEvent> {65 const db = getDb();66 const router = getRouter();67 let sessionId = input.sessionId;68 let history: ChatMessage[] = [];69 if (sessionId) {70 const loaded = await loadSession(sessionId, { anonId: input.anonId, userId: input.userId });71 if (!loaded) sessionId = null;72 else history = loaded.messages.slice(-MAX_HISTORY).map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));73 }74 if (!sessionId) {75 sessionId = newId('event').replace('evt_', 'rs_');76 await db.insert(researchSessions).values({ id: sessionId, userId: input.userId, anonId: input.anonId, title: input.message.slice(0, 80), model: router.configured ? router.modelFor('research') : null });77 }78 yield { type: 'session', sessionId };79 await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'user', content: input.message });8081 let answer = '';82 let usdEst = 0;83 let model: string | null = null;84 const trace: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> = [];85 const pending = new Map<string, { name: string; input: unknown }>();86 const usage: Record<string, number> = {};87 try {88 const stream = router.runTools('research', {89 system: RESEARCH_SYSTEM,90 messages: [...history, { role: 'user', content: input.message }],91 tools: toolDefinitions(),92 execute: executeTool,93 maxIterations: 10,94 maxTokens: 6000,95 effort: 'medium',96 signal: input.signal,97 cost: { endpoint: 'research', userId: input.userId, metadata: { sessionId } },98 });99 for await (const d of stream as AsyncIterable<StreamDelta>) {100 if (d.type === 'text') {101 answer += d.text;102 yield d;103 } else if (d.type === 'thinking') {104 yield d;105 } else if (d.type === 'tool_call') {106 pending.set(d.id, { name: d.name, input: d.input });107 yield d;108 } else if (d.type === 'tool_result') {109 const summary = d.isError ? `error: ${JSON.stringify(d.output).slice(0, 120)}` : summarizeToolResult(d.name, d.output);110 trace.push({ name: d.name, input: pending.get(d.id)?.input, ok: !d.isError, ms: d.durationMs, summary });111 yield { type: 'tool_result', id: d.id, name: d.name, ok: !d.isError, ms: d.durationMs, summary, preview: preview(d.output) };112 } else if (d.type === 'usage') {113 usdEst += d.usdEst;114 model = d.model;115 for (const [k, v] of Object.entries(d.usage)) usage[k] = (usage[k] ?? 0) + v;116 }117 }118 } catch (err) {119 const e = err as { code?: string; message?: string };120 const message = e?.code === 'ai_not_configured' ? 'AI provider not configured on this server.' : e?.code === 'ai_refusal' ? `The model declined this request${e.message ? `: ${e.message}` : '.'}` : (e?.message ?? 'Unexpected error');121 yield { type: 'error', message };122 answer = answer || `_${message}_`;123 }124 await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'assistant', content: answer, toolCalls: trace, usage, usdEst, model });125 await db.update(researchSessions).set({ messageCount: sql`${researchSessions.messageCount} + 2`, usdEst: sql`${researchSessions.usdEst} + ${usdEst}`, updatedAt: new Date(), model }).where(eq(researchSessions.id, sessionId));126 yield { type: 'done', usdEst, model };127}128129function preview(output: unknown): unknown {130 if (!output || typeof output !== 'object') return output;131 const o = output as Record<string, unknown>;132 const arrKey = ['results', 'sales', 'listings', 'items', 'points', 'categories', 'findings', 'variants', 'gainers'].find((k) => Array.isArray(o[k]));133 if (arrKey) return { [arrKey]: (o[arrKey] as unknown[]).slice(0, 5), truncated: (o[arrKey] as unknown[]).length > 5 };134 return Object.fromEntries(Object.entries(o).slice(0, 12));135}136