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
JavaScript
// 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]"];
}