import type { Hono } from 'hono'; import { loadConfig } from '@agi-cli/sdk'; import { getDb } from '@agi-cli/database'; import { sessions, messages, messageParts } from '@agi-cli/database/schema'; import { desc, eq, and, asc, count } from 'drizzle-orm'; import type { ProviderId } from '@agi-cli/sdk'; import { isProviderId } from '@agi-cli/sdk'; import { serializeError } from '../runtime/errors/api-error.ts'; import { logger } from '@agi-cli/sdk'; import { publish } from '../events/bus.ts'; export function registerResearchRoutes(app: Hono) { app.get('/v1/sessions/:parentId/research', async (c) => { const parentId = c.req.param('parentId'); const projectRoot = c.req.query('project') || process.cwd(); const cfg = await loadConfig(projectRoot); const db = await getDb(cfg.projectRoot); const parentRows = await db .select() .from(sessions) .where(eq(sessions.id, parentId)) .limit(1); if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) { return c.json({ error: 'Parent session not found' }, 404); } const researchRows = await db .select({ id: sessions.id, title: sessions.title, createdAt: sessions.createdAt, lastActiveAt: sessions.lastActiveAt, provider: sessions.provider, model: sessions.model, totalInputTokens: sessions.totalInputTokens, totalOutputTokens: sessions.totalOutputTokens, totalCachedTokens: sessions.totalCachedTokens, totalCacheCreationTokens: sessions.totalCacheCreationTokens, }) .from(sessions) .where( and( eq(sessions.parentSessionId, parentId), eq(sessions.sessionType, 'research'), ), ) .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt)); const sessionsWithCounts = await Promise.all( researchRows.map(async (row) => { const msgCount = await db .select({ count: count() }) .from(messages) .where(eq(messages.sessionId, row.id)); return { ...row, messageCount: msgCount[0]?.count ?? 0, }; }), ); return c.json({ sessions: sessionsWithCounts }); }); app.post('/v1/sessions/:parentId/research', async (c) => { const parentId = c.req.param('parentId'); const projectRoot = c.req.query('project') || process.cwd(); const cfg = await loadConfig(projectRoot); const db = await getDb(cfg.projectRoot); const body = (await c.req.json().catch(() => ({}))) as Record< string, unknown >; const parentRows = await db .select() .from(sessions) .where(eq(sessions.id, parentId)) .limit(1); if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) { return c.json({ error: 'Parent session not found' }, 404); } const parent = parentRows[0]; const providerCandidate = typeof body.provider === 'string' ? body.provider : undefined; const provider: ProviderId = (() => { if (providerCandidate && isProviderId(providerCandidate)) return providerCandidate; return parent.provider as ProviderId; })(); const modelCandidate = typeof body.model === 'string' ? body.model.trim() : undefined; const model = modelCandidate?.length ? modelCandidate : parent.model; const id = crypto.randomUUID(); const now = Date.now(); const title = typeof body.title === 'string' ? body.title : null; const row = { id, title, agent: 'research', provider, model, projectPath: cfg.projectRoot, createdAt: now, lastActiveAt: now, parentSessionId: parentId, sessionType: 'research', totalInputTokens: null, totalOutputTokens: null, totalCachedTokens: null, totalCacheCreationTokens: null, totalReasoningTokens: null, totalToolTimeMs: null, toolCountsJson: null, }; try { await db.insert(sessions).values(row); publish({ type: 'session.created', sessionId: id, payload: row }); return c.json({ session: row, parentSessionId: parentId }, 201); } catch (err) { logger.error('Failed to create research session', err); const errorResponse = serializeError(err); return c.json(errorResponse, errorResponse.error.status || 400); } }); app.delete('/v1/research/:researchId', async (c) => { const researchId = c.req.param('researchId'); const projectRoot = c.req.query('project') || process.cwd(); const cfg = await loadConfig(projectRoot); const db = await getDb(cfg.projectRoot); const rows = await db .select() .from(sessions) .where(eq(sessions.id, researchId)) .limit(1); if (!rows.length) { return c.json({ error: 'Research session not found' }, 404); } const session = rows[0]; if (session.projectPath !== cfg.projectRoot) { return c.json( { error: 'Research session not found in this project' }, 404, ); } if (session.sessionType !== 'research') { return c.json({ error: 'Session is not a research session' }, 400); } await db.delete(sessions).where(eq(sessions.id, researchId)); publish({ type: 'session.deleted', sessionId: researchId, payload: { id: researchId }, }); return c.json({ success: true }); }); app.post('/v1/sessions/:parentId/inject', async (c) => { const parentId = c.req.param('parentId'); const projectRoot = c.req.query('project') || process.cwd(); const cfg = await loadConfig(projectRoot); const db = await getDb(cfg.projectRoot); const body = (await c.req.json().catch(() => ({}))) as Record< string, unknown >; const researchSessionId = typeof body.researchSessionId === 'string' ? body.researchSessionId : ''; const label = typeof body.label === 'string' ? body.label : 'Research context'; if (!researchSessionId) { return c.json({ error: 'researchSessionId is required' }, 400); } const [parentRows, researchRows] = await Promise.all([ db.select().from(sessions).where(eq(sessions.id, parentId)).limit(1), db .select() .from(sessions) .where(eq(sessions.id, researchSessionId)) .limit(1), ]); if (!parentRows.length || parentRows[0].projectPath !== cfg.projectRoot) { return c.json({ error: 'Parent session not found' }, 404); } if (!researchRows.length || researchRows[0].sessionType !== 'research') { return c.json({ error: 'Research session not found' }, 404); } const _researchSession = researchRows[0]; const researchMessages = await db .select({ id: messages.id, role: messages.role, createdAt: messages.createdAt, }) .from(messages) .where(eq(messages.sessionId, researchSessionId)) .orderBy(asc(messages.createdAt)); let contextContent = ''; for (const msg of researchMessages) { if (msg.role === 'user' || msg.role === 'assistant') { const parts = await db .select({ type: messageParts.type, content: messageParts.content }) .from(messageParts) .where(eq(messageParts.messageId, msg.id)) .orderBy(asc(messageParts.index)); for (const part of parts) { if (part.type === 'text' && part.content) { contextContent += `[${msg.role}]: ${part.content}\n\n`; } } } } const injectedContext = `\n${contextContent}`; // Return the content to the client instead of creating a system message // The client will store it in zustand and include it in the next user message return c.json({ content: injectedContext, label, sessionId: researchSessionId, parentSessionId: parentId, tokenEstimate: Math.ceil(injectedContext.length / 4), }); }); app.post('/v1/research/:researchId/export', async (c) => { const researchId = c.req.param('researchId'); const projectRoot = c.req.query('project') || process.cwd(); const cfg = await loadConfig(projectRoot); const db = await getDb(cfg.projectRoot); const body = (await c.req.json().catch(() => ({}))) as Record< string, unknown >; const researchRows = await db .select() .from(sessions) .where(eq(sessions.id, researchId)) .limit(1); if (!researchRows.length || researchRows[0].sessionType !== 'research') { return c.json({ error: 'Research session not found' }, 404); } const researchSession = researchRows[0]; if (researchSession.projectPath !== cfg.projectRoot) { return c.json({ error: 'Research session not in this project' }, 404); } const providerCandidate = typeof body.provider === 'string' ? body.provider : undefined; const provider: ProviderId = (() => { if (providerCandidate && isProviderId(providerCandidate)) return providerCandidate; return cfg.defaults.provider; })(); const modelCandidate = typeof body.model === 'string' ? body.model.trim() : undefined; const model = modelCandidate?.length ? modelCandidate : cfg.defaults.model; const agentCandidate = typeof body.agent === 'string' ? body.agent.trim() : undefined; const agent = agentCandidate?.length ? agentCandidate : cfg.defaults.agent; const researchMessages = await db .select({ id: messages.id, role: messages.role, createdAt: messages.createdAt, }) .from(messages) .where(eq(messages.sessionId, researchId)) .orderBy(asc(messages.createdAt)); let contextContent = ''; for (const msg of researchMessages) { if (msg.role === 'user' || msg.role === 'assistant') { const parts = await db .select({ type: messageParts.type, content: messageParts.content }) .from(messageParts) .where(eq(messageParts.messageId, msg.id)) .orderBy(asc(messageParts.index)); for (const part of parts) { if (part.type === 'text' && part.content) { contextContent += `[${msg.role}]: ${part.content}\n\n`; } } } } const injectedContext = `\n${contextContent}`; const newSessionId = crypto.randomUUID(); const now = Date.now(); await db.insert(sessions).values({ id: newSessionId, title: researchSession.title ? `From: ${researchSession.title}` : null, agent, provider, model, projectPath: cfg.projectRoot, createdAt: now, lastActiveAt: now, parentSessionId: null, sessionType: 'main', totalInputTokens: null, totalOutputTokens: null, totalCachedTokens: null, totalCacheCreationTokens: null, totalReasoningTokens: null, totalToolTimeMs: null, toolCountsJson: null, }); const msgId = crypto.randomUUID(); const partId = crypto.randomUUID(); await db.insert(messages).values({ id: msgId, sessionId: newSessionId, role: 'system', status: 'complete', agent, provider, model, createdAt: now, completedAt: now, }); await db.insert(messageParts).values({ id: partId, messageId: msgId, index: 0, type: 'text', content: injectedContext, agent, provider, model, }); publish({ type: 'session.created', sessionId: newSessionId, payload: { id: newSessionId }, }); const newSession = await db .select() .from(sessions) .where(eq(sessions.id, newSessionId)) .limit(1); return c.json( { newSession: newSession[0], injectedContext, }, 201, ); }); }