TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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 // anonymous threads are readable only by the browser that created them; a thread without an52 // anon id (cookie could not be set) is not readable by anyone afterwards53 if (!s.userId && (!owner.anonId || s.anonId !== owner.anonId)) return null;54 const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt);55 return { session: s, messages };56}5758export async function listSessions(owner: { anonId: string | null; userId: string | null }, limit = 20) {59 const db = getDb();60 const where = owner.userId ? eq(researchSessions.userId, owner.userId) : owner.anonId ? and(eq(researchSessions.anonId, owner.anonId), sql`${researchSessions.userId} is null`) : null;61 if (!where) return [];62 return db.select().from(researchSessions).where(and(where, eq(researchSessions.archived, false))).orderBy(desc(researchSessions.updatedAt)).limit(limit);63}6465/** Run one research turn: persists the user message, streams the agent, persists the answer + tool trace. */66export async function* researchTurn(input: ResearchTurnInput): AsyncGenerator<ResearchEvent> {67 const db = getDb();68 const router = getRouter();69 let sessionId = input.sessionId;70 let history: ChatMessage[] = [];71 if (sessionId) {72 const loaded = await loadSession(sessionId, { anonId: input.anonId, userId: input.userId });73 if (!loaded) sessionId = null;74 else history = loaded.messages.slice(-MAX_HISTORY).map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));75 }76 if (!sessionId) {77 sessionId = newId('event').replace('evt_', 'rs_');78 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 });79 }80 yield { type: 'session', sessionId };81 await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'user', content: input.message });8283 let answer = '';84 let usdEst = 0;85 let model: string | null = null;86 const trace: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> = [];87 const pending = new Map<string, { name: string; input: unknown }>();88 const usage: Record<string, number> = {};89 try {90 const stream = router.runTools('research', {91 system: RESEARCH_SYSTEM,92 messages: [...history, { role: 'user', content: input.message }],93 tools: toolDefinitions(),94 execute: executeTool,95 maxIterations: 10,96 maxTokens: 6000,97 effort: 'medium',98 signal: input.signal,99 cost: { endpoint: 'research', userId: input.userId, metadata: { sessionId } },100 });101 for await (const d of stream as AsyncIterable<StreamDelta>) {102 if (d.type === 'text') {103 answer += d.text;104 yield d;105 } else if (d.type === 'thinking') {106 yield d;107 } else if (d.type === 'tool_call') {108 pending.set(d.id, { name: d.name, input: d.input });109 yield d;110 } else if (d.type === 'tool_result') {111 const summary = d.isError ? `error: ${JSON.stringify(d.output).slice(0, 120)}` : summarizeToolResult(d.name, d.output);112 trace.push({ name: d.name, input: pending.get(d.id)?.input, ok: !d.isError, ms: d.durationMs, summary });113 yield { type: 'tool_result', id: d.id, name: d.name, ok: !d.isError, ms: d.durationMs, summary, preview: preview(d.output) };114 } else if (d.type === 'usage') {115 usdEst += d.usdEst;116 model = d.model;117 for (const [k, v] of Object.entries(d.usage)) usage[k] = (usage[k] ?? 0) + v;118 }119 }120 } catch (err) {121 const e = err as { code?: string; message?: string };122 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');123 yield { type: 'error', message };124 answer = answer || `_${message}_`;125 }126 await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'assistant', content: answer, toolCalls: trace, usage, usdEst, model });127 await db.update(researchSessions).set({ messageCount: sql`${researchSessions.messageCount} + 2`, usdEst: sql`${researchSessions.usdEst} + ${usdEst}`, updatedAt: new Date(), model }).where(eq(researchSessions.id, sessionId));128 yield { type: 'done', usdEst, model };129}130131function preview(output: unknown): unknown {132 if (!output || typeof output !== 'object') return output;133 const o = output as Record<string, unknown>;134 const arrKey = ['results', 'sales', 'listings', 'items', 'points', 'categories', 'findings', 'variants', 'gainers'].find((k) => Array.isArray(o[k]));135 if (arrKey) return { [arrKey]: (o[arrKey] as unknown[]).slice(0, 5), truncated: (o[arrKey] as unknown[]).length > 5 };136 return Object.fromEntries(Object.entries(o).slice(0, 12));137}138