/** * @module custom_cil-agent * @audience installer * @layer api-handler * @stability experimental * * CIL-aware conversational agent endpoint. * * Wraps the core agent-runner with CIL-specific tooling: * - RAG restricted to cil_chunk embeddings (semantic + contract vectors) * - Built-in tools: chunk_lookup, blast_radius, hash_validate, doc_gap_report * - Stateless (no thread persistence) or stateful (thread_id provided) * * Actions: * POST ?action=ask { question, project_id?, thread_id? } — one-shot Q&A * POST ?action=chat { message, thread_id, project_id? } — multi-turn (requires thread) * GET ?action=tools — — list available CIL tools * POST ?action=tool { tool, args } — invoke a CIL tool directly */ import { createHandler } from './_shared/middleware' import { adminDb } from './_shared/db' // ─── TOOL DEFINITIONS ───────────────────────────────────────────────────────── // These are surfaced to the LLM as available tools. const CIL_TOOLS = [ { name: 'chunk_lookup', description: 'Search for a chunk by name or purpose. Returns chunk_name, file_path, macro, micro, input_spec, output_spec, side_effects, stability.', parameters: { type: 'object', properties: { q: { type: 'string', description: 'Chunk name or description of what you are looking for' }, project_id: { type: 'string', description: 'Optional: scope to a specific project UUID' }, }, required: ['q'], }, }, { name: 'get_contract', description: 'Get the full contract for a specific chunk by its ID.', parameters: { type: 'object', properties: { chunk_id: { type: 'string', description: 'UUID of the cil_chunk item' }, }, required: ['chunk_id'], }, }, { name: 'blast_radius', description: 'Find all chunks that call a given chunk (BFS caller traversal). Use before refactoring.', parameters: { type: 'object', properties: { chunk_id: { type: 'string', description: 'UUID of the chunk to check callers for' }, max_depth: { type: 'number', description: 'Maximum traversal depth (default 3)' }, }, required: ['chunk_id'], }, }, { name: 'hash_validate', description: 'Check whether a chunk\'s stored hash is current or has drifted (code was changed without updating CIL).', parameters: { type: 'object', properties: { chunk_id: { type: 'string', description: 'UUID of the chunk to validate' }, }, required: ['chunk_id'], }, }, { name: 'doc_gap_report', description: 'List undocumented chunks in a project and overall documentation coverage.', parameters: { type: 'object', properties: { project_id: { type: 'string', description: 'UUID of the cil_project' }, }, required: ['project_id'], }, }, { name: 'project_status', description: 'Get the full pipeline status for a project: file count, chunk count, embedding coverage, doc coverage, link counts.', parameters: { type: 'object', properties: { project_id: { type: 'string', description: 'UUID of the cil_project' }, }, required: ['project_id'], }, }, ] // ─── TOOL DISPATCHER ────────────────────────────────────────────────────────── async function dispatchTool( toolName: string, args: Record, accountId: string ): Promise { const base = process.env.URL || 'http://localhost:8888' switch (toolName) { case 'chunk_lookup': { const params = new URLSearchParams({ action: 'search', q: args.q, mode: 'hybrid', limit: '5' }) if (args.project_id) params.set('project_id', args.project_id) const res = await fetch(`${base}/.netlify/functions/custom_cil-graph?${params}`) const data: any = await res.json() return { results: data?.results || data?.data?.results || [] } } case 'get_contract': { const res = await fetch(`${base}/.netlify/functions/custom_cil-graph?action=get_chunk_contract&chunk_id=${args.chunk_id}`) const data: any = await res.json() return data?.contract || data?.data?.contract || {} } case 'blast_radius': { const depth = args.max_depth || 3 const res = await fetch(`${base}/.netlify/functions/custom_cil-graph?action=get_blast_radius&chunk_id=${args.chunk_id}&max_depth=${depth}`) const data: any = await res.json() return { affected: data?.affected || data?.data?.affected || [], depth } } case 'hash_validate': { // Fetch the chunk to get its stored hash + code_content const { data: chunk } = await adminDb .from('items').select('id, data').eq('id', args.chunk_id).maybeSingle() if (!chunk) return { status: 'not_found', chunk_id: args.chunk_id } const codeContent = chunk.data?.code_content || '' if (!codeContent) return { status: 'no_code_content', chunk_id: args.chunk_id } const res = await fetch(`${base}/.netlify/functions/custom_cil-validate?action=validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunks: [{ chunk_id: args.chunk_id, chunk_name: chunk.data?.chunk_name || '', content: codeContent }], }), }) const data: any = await res.json() const result = (data?.results || data?.data?.results)?.[0] return result || { status: 'unknown', chunk_id: args.chunk_id } } case 'doc_gap_report': { const res = await fetch(`${base}/.netlify/functions/custom_cil-doc?action=gap_report&project_id=${args.project_id}`) const data: any = await res.json() return data?.data || data } case 'project_status': { const pid = args.project_id const [projectRes, embedRes, callmapRes] = await Promise.allSettled([ fetch(`${base}/.netlify/functions/custom_cil-project?action=get&id=${pid}`).then(r => r.json()), fetch(`${base}/.netlify/functions/custom_cil-embeddings?action=status&project_id=${pid}`).then(r => r.json()), fetch(`${base}/.netlify/functions/custom_cil-callmap?action=status&project_id=${pid}`).then(r => r.json()), ]) const project = projectRes.status === 'fulfilled' ? (projectRes.value?.project || projectRes.value?.data?.project) : null const embed = embedRes.status === 'fulfilled' ? (embedRes.value?.data || embedRes.value) : null const callmap = callmapRes.status === 'fulfilled' ? (callmapRes.value?.data || callmapRes.value) : null return { project_id: pid, file_count: project?.data?.file_count || 0, chunk_count: project?.data?.chunk_count || 0, embedding_coverage_pct: embed?.coverage_pct || 0, embedded_chunks: embed?.embedded_chunks || 0, import_links: callmap?.import_links || 0, call_links: callmap?.call_links || 0, total_ingestion_cost_usd: project?.data?.total_ingestion_cost_usd || 0, last_ingested_at: project?.data?.last_ingested_at || null, } } default: return { error: `Unknown tool: ${toolName}` } } } // ─── LLM CALL ───────────────────────────────────────────────────────────────── async function callLLM( messages: { role: string; content: string }[], tools: any[], model: string ): Promise<{ content: string; tool_calls?: any[] }> { const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' if (!apiKey) { return { content: '[No API key configured — set OPENAI_API_KEY to enable the CIL assistant]', } } const body: any = { model, temperature: 0.2, max_tokens: 1500, messages, } if (tools.length > 0) { body.tools = tools.map(t => ({ type: 'function', function: t })) body.tool_choice = 'auto' } const res = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify(body), }) if (!res.ok) { const errText = await res.text() throw new Error(`LLM API error ${res.status}: ${errText.slice(0, 200)}`) } const j: any = await res.json() const choice = j.choices?.[0] return { content: choice?.message?.content || '', tool_calls: choice?.message?.tool_calls || undefined, } } // ─── SEMANTIC RETRIEVAL FOR RAG ─────────────────────────────────────────────── async function retrieveChunkContext( query: string, projectId: string | null, accountId: string ): Promise { const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' if (!apiKey) return '' try { const embedRes = await fetch(`${baseUrl}/embeddings`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: 'text-embedding-3-small', input: query }), }) if (!embedRes.ok) return '' const ej: any = await embedRes.json() const vec = ej.data?.[0]?.embedding if (!vec) return '' const { data: matches } = await adminDb.rpc('match_embeddings', { query_embedding: `[${vec.join(',')}]`, match_count: 8, filter_vector_type: 'semantic', similarity_threshold: 0.2, filter_item_types: ['cil_chunk'], }) if (!matches?.length) return '' const chunkIds = [...new Set((matches as any[]).map((m: any) => m.document_id))] let chunkQuery = adminDb .from('items') .select('id, data') .in('id', chunkIds) if (projectId) { chunkQuery = chunkQuery.filter('data->>project_id', 'eq', projectId) } const { data: chunks } = await chunkQuery const chunkMap = new Map((chunks || []).map((c: any) => [c.id, c])) const contextParts: string[] = [] for (const m of (matches as any[])) { const chunk = chunkMap.get(m.document_id) if (!chunk) continue const d = chunk.data || {} const parts = [ `## ${d.chunk_name || 'unknown'} (${d.chunk_type || ''}) — ${d.file_path || ''}`, ] if (d.macro) parts.push(`Purpose: ${d.macro}`) if (d.micro) parts.push(`Detail: ${d.micro}`) if (d.input_spec) parts.push(`Inputs: ${d.input_spec}`) if (d.output_spec) parts.push(`Returns: ${d.output_spec}`) if (d.side_effects) parts.push(`Side effects: ${d.side_effects}`) if (d.stability) parts.push(`Stability: ${d.stability}`) parts.push(`Similarity: ${(m.similarity * 100).toFixed(0)}%`) contextParts.push(parts.join('\n')) } return contextParts.join('\n\n') } catch { return '' } } // ─── SYSTEM PROMPT ──────────────────────────────────────────────────────────── const SYSTEM_PROMPT = `You are a Code Intelligence Layer assistant. You answer questions about this codebase using retrieved chunk contracts. Each chunk record contains: chunk_name, file_path, macro (purpose), micro (implementation detail), input_spec, output_spec, side_effects, and stability. Rules: - Ground your answer in the retrieved chunk contracts — do not invent APIs. - Cite chunk_name and file_path when referencing a specific function. - If no relevant chunk is found, say so explicitly. - For refactoring questions, always mention callers / blast radius if relevant. - Use the available tools to look up specifics you are not sure about. - Keep answers concise. Use markdown code blocks for examples.` // ─── MAIN INFERENCE LOOP ────────────────────────────────────────────────────── async function runCilInference( question: string, projectId: string | null, history: { role: string; content: string }[], accountId: string, model = 'gpt-4o-mini' ): Promise<{ answer: string; sources: string[]; tool_calls_made: string[] }> { // RAG: retrieve relevant chunk context const ragContext = await retrieveChunkContext(question, projectId, accountId) const messages: { role: string; content: string | any[] }[] = [ { role: 'system', content: SYSTEM_PROMPT }, ] if (ragContext) { messages.push({ role: 'system', content: `## Retrieved Chunk Contracts (from CIL index)\n\n${ragContext}`, }) } // Include conversation history for (const msg of history) { messages.push(msg) } messages.push({ role: 'user', content: question }) const toolCallsMade: string[] = [] const MAX_TOOL_ITERS = 4 for (let iter = 0; iter < MAX_TOOL_ITERS; iter++) { const response = await callLLM(messages as any, CIL_TOOLS, model) if (!response.tool_calls?.length) { // Final text answer const sources: string[] = [] if (ragContext) { const nameMatches = ragContext.match(/## (\w[\w.]+)/g) || [] sources.push(...nameMatches.map(m => m.replace('## ', '')).slice(0, 5)) } return { answer: response.content, sources, tool_calls_made: toolCallsMade } } // Execute tool calls messages.push({ role: 'assistant', content: response.content || '', ...{ tool_calls: response.tool_calls } } as any) for (const tc of response.tool_calls) { const toolName = tc.function?.name || tc.name || '' const toolArgs = (() => { try { return JSON.parse(tc.function?.arguments || '{}') } catch { return {} } })() toolCallsMade.push(toolName) let result: any try { result = await dispatchTool(toolName, toolArgs, accountId) } catch (err) { result = { error: err instanceof Error ? err.message : String(err) } } messages.push({ role: 'tool', content: JSON.stringify(result), ...({ tool_call_id: tc.id }), } as any) } } return { answer: 'I exhausted my tool call budget. Please try a more specific question.', sources: [], tool_calls_made: toolCallsMade } } // ─── RESOLVE ACCOUNT ID ─────────────────────────────────────────────────────── async function getPlatformAccountId(): Promise { const { data } = await adminDb.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle() return data?.id || '' } // ─── HANDLER ────────────────────────────────────────────────────────────────── export const handler = createHandler(async (ctx, body) => { const action = ctx.query?.action || 'ask' const accountId = ctx.accountId || await getPlatformAccountId() // ── LIST TOOLS ──────────────────────────────────────────────────────────────── if (action === 'tools') { return { tools: CIL_TOOLS } } // ── DIRECT TOOL INVOCATION ──────────────────────────────────────────────────── if (action === 'tool') { const toolName = body?.tool const toolArgs = body?.args || {} if (!toolName) throw new Error('tool name is required') const result = await dispatchTool(toolName, toolArgs, accountId) return { tool: toolName, result } } // ── ONE-SHOT ASK ────────────────────────────────────────────────────────────── if (action === 'ask') { const question = body?.question || body?.q const projectId = body?.project_id || ctx.query?.project_id || null const model = body?.model || 'gpt-4o-mini' if (!question) throw new Error('question is required') const { answer, sources, tool_calls_made } = await runCilInference( question, projectId, [], accountId, model ) return { question, answer, sources, tool_calls_made } } // ── MULTI-TURN CHAT ─────────────────────────────────────────────────────────── if (action === 'chat') { const message = body?.message const threadId = body?.thread_id const projectId = body?.project_id || null const model = body?.model || 'gpt-4o-mini' if (!message) throw new Error('message is required') // Load recent conversation history from thread if provided let history: { role: string; content: string }[] = [] if (threadId) { const { data: messages } = await adminDb .from('messages') .select('data, content') .eq('thread_id', threadId) .order('created_at', { ascending: false }) .limit(10) history = ((messages || []).reverse()).map((m: any) => ({ role: m.data?.message_type === 'human' ? 'user' : 'assistant', content: m.content || '', })) } const { answer, sources, tool_calls_made } = await runCilInference( message, projectId, history, accountId, model ) // Persist messages to thread if provided if (threadId) { const [humanTypeRes, agentTypeRes] = await Promise.all([ adminDb.from('types').select('id').eq('slug', 'message').maybeSingle(), adminDb.from('types').select('id').eq('slug', 'message').maybeSingle(), ]) const messageTypeId = humanTypeRes.data?.id || agentTypeRes.data?.id if (messageTypeId) { await adminDb.from('messages').insert([ { type_id: messageTypeId, thread_id: threadId, content: message, data: { message_type: 'human' }, account_id: accountId, created_at: new Date().toISOString(), }, { type_id: messageTypeId, thread_id: threadId, content: answer, data: { message_type: 'agent', sources, tool_calls_made }, account_id: accountId, created_at: new Date().toISOString(), }, ]) } } return { message, answer, sources, tool_calls_made, thread_id: threadId } } throw new Error(`Unknown action: ${action}. Valid: ask, chat, tool, tools`) })