/** * Assist COO — proxies to the agent runner's Orchestrator. * * The Orchestrator (Claude Sonnet 4.6, Tier B) has full tool access: * Gitea — read repos, files, issues, commits * Coolify — app status, deploy logs, trigger deploys * Web search, memory, agent spawning * * This route loads project-specific context (PRD, phases, apps, sessions) * and injects it as knowledge_context into the orchestrator's system prompt. */ import { NextRequest } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com'; // --------------------------------------------------------------------------- // Context loader — everything the COO needs to know about the project // --------------------------------------------------------------------------- async function buildKnowledgeContext(projectId: string, email: string): Promise { const [projectRows, phaseRows, sessionRows] = await Promise.all([ query<{ data: Record }>( `SELECT p.data FROM fs_projects p JOIN fs_users u ON u.id = p.user_id WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, [projectId, email] ).catch(() => [] as { data: Record }[]), query<{ phase: string; title: string; summary: string }>( `SELECT phase, title, summary FROM atlas_phases WHERE project_id = $1 ORDER BY saved_at ASC`, [projectId] ).catch(() => [] as { phase: string; title: string; summary: string }[]), query<{ task: string; status: string }>( `SELECT data->>'task' as task, data->>'status' as status FROM fs_sessions WHERE data->>'projectId' = $1 ORDER BY created_at DESC LIMIT 8`, [projectId] ).catch(() => [] as { task: string; status: string }[]), ]); const d = projectRows[0]?.data ?? {}; const name = (d.name as string) ?? 'Unknown Project'; const vision = (d.productVision as string) ?? (d.vision as string) ?? ''; const giteaRepo = (d.giteaRepo as string) ?? ''; const prd = (d.prd as string) ?? ''; const architecture = d.architecture as Record | null ?? null; const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? []; const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? ''; const theiaUrl = (d.theiaWorkspaceUrl as string) ?? ''; const lines: string[] = []; // COO persona — injected so the orchestrator knows its role for this session lines.push(`## Your role for this conversation You are the personal AI COO for "${name}" — a trusted executive partner to the founder. The founder talks to you. You figure out what needs to happen and get it done. You delegate to specialist agents (Coder, PM, Marketing) when work is needed. Operating principles: - Use your tools proactively. Don't guess — check Gitea for what's been built, check Coolify for app status. - Before delegating any work: state the scope in plain English and confirm with the founder. - Be brief. No preamble, no "Great question!". - You decide the technical approach — never ask the founder to choose. - Be honest when you're uncertain or when data isn't available. - Do NOT spawn agents on the protected platform repos (vibn-frontend, theia-code-os, vibn-agent-runner, vibn-api, master-ai).`); // Project identity lines.push(`\n## Project: ${name}`); if (vision) lines.push(`Vision: ${vision}`); if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`); if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`); if (theiaUrl) lines.push(`Theia IDE: ${theiaUrl}`); // Architecture document if (architecture) { const archApps = (architecture.apps as Array<{ name: string; type: string; description: string }> ?? []) .map(a => ` - ${a.name} (${a.type}): ${a.description}`).join('\n'); const archInfra = (architecture.infrastructure as Array<{ name: string; reason: string }> ?? []) .map(i => ` - ${i.name}: ${i.reason}`).join('\n'); lines.push(`\n## Technical Architecture\nSummary: ${architecture.summary ?? ''}\n\nApps:\n${archApps}\n\nInfrastructure:\n${archInfra}`); } // PRD or discovery phases if (prd) { // Claude Sonnet has a 200k token context — pass the full PRD, no truncation needed lines.push(`\n## Product Requirements Document\n${prd}`); } else if (phaseRows.length > 0) { lines.push(`\n## Discovery phases completed (${phaseRows.length})`); for (const p of phaseRows) { lines.push(`- ${p.title}: ${p.summary}`); } lines.push(`(PRD not yet finalized — Atlas discovery is in progress)`); } else { lines.push(`\n## Product discovery: not yet started`); } // Deployed apps if (apps.length > 0) { lines.push(`\n## Deployed apps`); for (const a of apps) { const url = a.domain ? `https://${a.domain}` : '(no domain yet)'; const uuid = a.coolifyServiceUuid ? ` [uuid: ${a.coolifyServiceUuid}]` : ''; lines.push(`- ${a.name} → ${url}${uuid}`); } } // Recent agent work const validSessions = sessionRows.filter(s => s.task); if (validSessions.length > 0) { lines.push(`\n## Recent agent sessions (what's been worked on)`); for (const s of validSessions) { lines.push(`- [${s.status ?? 'unknown'}] ${s.task}`); } } return lines.join('\n'); } // --------------------------------------------------------------------------- // POST handler // --------------------------------------------------------------------------- export async function POST( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { const { projectId } = await params; const session = await getServerSession(authOptions); if (!session?.user?.email) { return new Response('Unauthorized', { status: 401 }); } const { message, history = [] } = await req.json() as { message: string; history: Array<{ role: 'user' | 'model'; content: string }>; }; if (!message?.trim()) { return new Response('Message required', { status: 400 }); } // Load project context (best-effort) let knowledgeContext = ''; try { knowledgeContext = await buildKnowledgeContext(projectId, session.user.email); } catch { /* proceed without — orchestrator still works */ } // Convert history: frontend uses "model", orchestrator uses "assistant" const llmHistory = history .filter(h => h.content?.trim()) .map(h => ({ role: (h.role === 'model' ? 'assistant' : 'user') as 'assistant' | 'user', content: h.content, })); // Call the orchestrator on the agent runner let orchRes: Response; try { orchRes = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, // Scoped session per project so in-memory context persists within a browser session session_id: `coo_${projectId}_${session.user.email.split('@')[0]}`, history: llmHistory, knowledge_context: knowledgeContext, }), }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return new Response(`Agent runner unreachable: ${msg}`, { status: 502 }); } if (!orchRes.ok) { const err = await orchRes.text(); return new Response(`Orchestrator error: ${err}`, { status: 502 }); } const result = await orchRes.json() as { reply?: string; error?: string }; if (result.error) { return new Response(result.error, { status: 500 }); } const reply = result.reply ?? '(no response)'; // Return as a streaming response — single chunk (orchestrator is non-streaming) const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(reply)); controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); }