UNPKG

scai

Version:

> **A local-first AI CLI for understanding, querying, and iterating on large codebases.** > **100% local • No token costs • No cloud • No prompt injection • Private by design**

282 lines (259 loc) 10.4 kB
// File: src/pipeline/modules/finalAnswerModule.ts import { logInputOutput } from "../../utils/promptLogHelper.js"; import { generate } from "../../lib/generate.js"; import { cleanupModule } from "./cleanupModule.js"; import chalk from "chalk"; const MAX_CODE_CHARS = 3000; const MAX_FILES = 4; export const finalAnswerModule = { name: "finalAnswer", description: "Produces the final grounded answer, prioritizing intent, targets, and rationale over file listings.", groups: ["finalize"], run: async (input) => { const { query } = input; const context = input.context; if (!context) { throw new Error("[finalAnswerModule] No context provided"); } const analysis = context.analysis ?? {}; const fileAnalysis = analysis.fileAnalysis ?? {}; const combinedAnalysis = await ensureCombinedAnalysis(context, query); const focus = analysis.focus; const rationale = focus?.rationale ?? "No explicit rationale provided."; const transformedFiles = context.execution?.codeTransformArtifacts?.files ?? []; // -------------------------------------------------- // 1️⃣ Collect semantic files and excerpts // -------------------------------------------------- const meaningfulFiles = Object.entries(fileAnalysis) .filter(([path, fa]) => fa?.intent === "relevant" && (!focus?.relevantFiles || focus.relevantFiles.includes(path))) .map(([path, fa]) => ({ path, analysis: fa })) .slice(0, MAX_FILES); // Collect analyzed files for output (prefer executed task steps from this run). const analyzedFiles = resolveAnalyzedFilesForOutput(context, meaningfulFiles.map(f => f.path)); // -------------------------------------------------- // 2️⃣ Collect supporting code snippets from working files // -------------------------------------------------- const workingFiles = context.workingFiles ?? []; const codeSnippets = meaningfulFiles .map(({ path }) => { const wf = workingFiles.find(f => f.path === path); if (!wf?.code) return null; return { path, code: sliceCode(wf.code), }; }) .filter(Boolean); // -------------------------------------------------- // 3️⃣ Build structured evidence sections // -------------------------------------------------- const semanticSection = meaningfulFiles.length > 0 ? meaningfulFiles.map(({ path, analysis }) => { const excerptsSection = analysis.excerpts?.map(ex => ` Excerpt: Description: ${ex.description} ${ex.startLine !== undefined ? `StartLine: ${ex.startLine}` : ""} ${ex.endLine !== undefined ? `EndLine: ${ex.endLine}` : ""} Symbols: ${ex.symbols?.join(", ") ?? "[none]"} Code: ${sliceCode(ex.code)} `.trim()).join("\n\n") ?? "[No excerpts available]"; return ` ${formatProposedChanges(path, analysis)} Supporting Excerpts: ${excerptsSection} `.trim(); }).join("\n\n") : "[No meaningful semantic changes identified]"; const combinedSection = combinedAnalysis ? ` Shared patterns: ${combinedAnalysis.sharedPatterns?.map(p => `- ${p}`).join("\n") || "[none]"} Architecture summary: ${combinedAnalysis.architectureSummary ?? "[none]"} Hotspots: ${combinedAnalysis.hotspots?.map(h => `- ${h}`).join("\n") || "[none]"} `.trim() : "[No combined analysis available]"; const codeSection = transformedFiles.length > 0 ? transformedFiles.map(f => ` FILE: ${f.filePath} ---------------------------------------- ${sliceCode(f.content)} `).join("\n\n") : codeSnippets.length > 0 ? codeSnippets.map(f => ` FILE: ${f.path} ---------------------------------------- ${f.code} `).join("\n\n") : "[No source code required for explanation]"; // -------------------------------------------------- // 4️⃣ Prompt // -------------------------------------------------- const prompt = ` You are an AI assistant providing the final answer to the user. This is the final stage of a structured analysis pipeline. User query: ${query} Rationale for focus: ${rationale} Analyzed files: ${analyzedFiles.join("\n")} ==================== PROPOSED CHANGES ==================== ${semanticSection} ==================== COMBINED ANALYSIS ==================== ${combinedSection} ==================== SOURCE CODE (LIMITED) ==================== ${codeSection} ==================== INSTRUCTIONS ==================== - Answer the user's question directly. - Prioritize intent, targets, and rationale over file listings. - Refer to files only when they materially support the explanation. - If no changes or conclusions can be drawn, say so explicitly. - Do NOT speculate. - Do NOT suggest further changes. - Be concise, grounded, and outcome-focused. `.trim(); // -------------------------------------------------- // 5️⃣ Generate final answer // -------------------------------------------------- const aiResponse = await generate({ query, content: prompt }); // ✅ Prepend analyzed files to finalText so user sees them const finalText = typeof aiResponse.data === "string" ? `Analyzed files:\n${analyzedFiles.join("\n")}\n\n${aiResponse.data}` : `Analyzed files:\n${analyzedFiles.join("\n")}\n\n${JSON.stringify(aiResponse.data, null, 2)}`; context.analysis || (context.analysis = {}); context.analysis.finalAnswer = finalText; logInputOutput("finalAnswerModule", "output", { data: aiResponse.data, analyzedFiles, }); console.log(chalk.yellow(`\n\n[FINAL ANSWER]\n${finalText}\n`)); return { query, content: finalText, data: { response: aiResponse.data, analyzedFiles, }, context, }; }, }; /* ------------------------- Helpers ---------------------------- */ function formatProposedChanges(path, analysis) { const proposed = analysis.proposedChanges; return ` FILE: ${path} Summary: ${proposed?.summary ?? "[no summary]"} Targets: ${proposed?.targets?.map(t => `- ${t}`).join("\n") || "[none]"} Rationale: ${proposed?.rationale ?? "[no rationale]"} `.trim(); } function sliceCode(code) { if (code.length <= MAX_CODE_CHARS) return code; return (code.slice(0, Math.floor(MAX_CODE_CHARS * 0.6)) + "\n/* … truncated … */\n" + code.slice(-Math.floor(MAX_CODE_CHARS * 0.4))); } async function ensureCombinedAnalysis(context, query) { context.analysis || (context.analysis = {}); if (context.analysis.combinedAnalysis) { return context.analysis.combinedAnalysis; } const analyzedFiles = Object.entries(context.analysis.fileAnalysis ?? {}) .filter(([_, analysis]) => analysis.semanticAnalyzed) .map(([path, analysis]) => [path, analysis]); if (analyzedFiles.length < 2) { return undefined; } const filtered = {}; for (const [path, analysis] of analyzedFiles) { filtered[path] = analysis; } const combined = await analyzeCombined(filtered, query); context.analysis.combinedAnalysis = combined; return combined; } async function analyzeCombined(fileAnalysis, query) { const prompt = ` You are given semantic analysis for multiple files that are all relevant to a user query. User query: "${query}" Per-file analysis: ${JSON.stringify(fileAnalysis, null, 2)} Task: - Identify shared patterns across these files - Summarize high-level architecture relevant to the query - List any cross-cutting hotspots or risks Return STRICT JSON: { "sharedPatterns"?: string[], "architectureSummary"?: string, "hotspots"?: string[] } `.trim(); try { const ai = await generate({ query: "cross-file analysis", content: prompt }); const cleaned = await cleanupModule.run({ query, content: ai.data }); const data = typeof cleaned.data === "object" && cleaned.data ? cleaned.data : JSON.parse(String(cleaned.content ?? "{}")); return { sharedPatterns: data.sharedPatterns ?? [], architectureSummary: data.architectureSummary ?? "[unparsed]", hotspots: data.hotspots ?? [], }; } catch (err) { console.warn("[finalAnswerModule] Failed to parse combined analysis:", err); return { sharedPatterns: [], architectureSummary: "[unparsed]", hotspots: [], }; } } function resolveAnalyzedFilesForOutput(context, fallbackMeaningfulFiles) { const transformedFiles = (context.execution?.codeTransformArtifacts?.files ?? []) .map(file => file.filePath) .filter((filePath, index, all) => !!filePath && all.indexOf(filePath) === index); const researchTouchedFiles = (context.analysis?.researchArtifacts?.touchedFiles ?? []) .filter((filePath, index, all) => !!filePath && all.indexOf(filePath) === index); const semanticAnalyzedFiles = Object.entries(context.analysis?.fileAnalysis ?? {}) .filter(([_, analysis]) => analysis?.semanticAnalyzed) .map(([filePath]) => filePath); const taskStepFiles = (context.task?.taskSteps ?? []) .filter(step => step.status === "completed" && !!step.filePath && !step.filePath.startsWith("__research__/")) .map(step => step.filePath) .filter((filePath, index, all) => all.indexOf(filePath) === index); const artifactPriority = Array.from(new Set([ ...transformedFiles, ...researchTouchedFiles, ...semanticAnalyzedFiles, ...taskStepFiles, ])).filter(filePath => !!filePath && !filePath.startsWith("__research__/")); if (artifactPriority.length > 0) { return artifactPriority.slice(0, MAX_FILES); } if (fallbackMeaningfulFiles.length > 0) { return fallbackMeaningfulFiles.slice(0, MAX_FILES); } const selected = context.analysis?.focus?.selectedFiles ?? []; if (selected.length > 0) { return selected.slice(0, MAX_FILES); } return ["[none]"]; }