Files
vibn-frontend/app/api/projects/[projectId]/advisor/route.ts
Mark Henderson 8f95270b12 feat: Assist COO routes through Orchestrator on agent runner
The advisor route now proxies to /orchestrator/chat on agents.vibnai.com
instead of calling Gemini directly. The Orchestrator (Claude Sonnet 4.6)
has full tool access — Gitea, Coolify, web search, memory, agent spawning.

- Build project knowledge_context from DB (name, vision, repo, PRD,
  phases, apps, recent sessions) and inject as COO persona + data
- Convert frontend history format (model→assistant) for the orchestrator
- Return orchestrator reply as streaming text response
- Session scoped per project for in-memory context persistence

Made-with: Cursor
2026-03-09 22:32:01 -07:00

194 lines
7.6 KiB
TypeScript

/**
* 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<string> {
const [projectRows, phaseRows, sessionRows] = await Promise.all([
query<{ data: Record<string, unknown> }>(
`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<string, unknown> }[]),
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 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}`);
// PRD or discovery phases
if (prd) {
lines.push(`\n## Product Requirements Document\n${prd.slice(0, 2500)}${prd.length > 2500 ? '\n[truncated — ask the founder for more detail]' : ''}`);
} 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' },
});
}