195 lines
7.5 KiB
TypeScript
195 lines
7.5 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) {
|
|
// 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' },
|
|
});
|
|
}
|