scai
Version:
> **AI-powered CLI for local code analysis, commit message suggestions, and natural-language queries.** > **100% local • No token cost • Private by design • GDPR-friendly** — made in Denmark/EU with ❤️.
338 lines (337 loc) • 15.5 kB
JavaScript
import { builtInModules } from "../pipeline/registry/moduleRegistry.js";
import { logInputOutput } from "../utils/promptLogHelper.js";
import { planResolverStep } from "./planResolverStep.js";
import { infoPlanGen } from "./infoPlanGenStep.js";
import { understandIntentStep } from "./understandIntentStep.js";
import { structuralAnalysisStep } from "./structuralAnalysisStep.js";
import { contextReviewStep } from "./contextReviewStep.js";
import { planTargetFilesStep } from "./planTargetFilesStep.js";
import { validationAnalysisStep } from "./validationAnalysisStep.js";
import { semanticAnalysisStep } from "./semanticAnalysisStep.js";
import { selectRelevantSourcesStep } from "./selectRelevantSourcesStep.js";
import { transformPlanGenStep } from "./transformPlanGenStep.js";
import { finalPlanGenStep } from "./finalPlanGenStep.js";
import { Spinner } from "../lib/spinner.js";
import { getDbForRepo } from "../db/client.js";
import { writeFileStep } from "./writeFileStep.js";
import { resolveExecutionModeStep } from "./resolveExecutionModeStep.js";
import { preFileSearchCheckStep } from "./preFileSearchCheckStep.js";
/* ───────────────────────── helpers ───────────────────────── */
let activeSpinner = null;
/**
* Called implicitly by MainAgent via side-effect:
* the first spinner.start() will register itself here.
*/
function registerSpinner(spinner) {
activeSpinner = spinner;
}
function startTimer() {
const start = Date.now();
return () => Date.now() - start;
}
function withSpinnerPaused(fn) {
if (!activeSpinner) {
fn();
return;
}
const wasRunning = typeof activeSpinner.isRunning === "function"
? activeSpinner.isRunning()
: true;
if (wasRunning)
activeSpinner.stop();
try {
fn();
}
finally {
if (wasRunning)
activeSpinner.start();
}
}
function logLine(phase, step, ms, desc) {
withSpinnerPaused(() => {
process.stdout.write('\r\x1b[K');
const suffix = desc ? ` — ${desc}` : "";
const timing = typeof ms === "number" ? ` (${ms}ms)` : "";
console.log(`[AGENT] ${phase} :: ${step}${suffix}${timing}`);
});
}
function userOutput(message) {
withSpinnerPaused(() => {
console.log(`[USER OUTPUT] ${message}`);
});
}
function userPhaseOutput(phase, message) {
withSpinnerPaused(() => {
console.log(`[USER OUTPUT] [${phase}] ${message}`);
});
}
/* ───────────────────────── registry ───────────────────────── */
const MODULE_REGISTRY = Object.fromEntries(Object.entries(builtInModules).map(([name, mod]) => [name, mod]));
function resolveModuleForAction(action) {
return MODULE_REGISTRY[action];
}
/* ───────────────────────── agent ───────────────────────── */
export class MainAgent {
constructor(context) {
this.spinner = new Spinner();
this.runCount = 0;
this.maxRuns = 2;
this.context = context;
this.query = context.initContext?.userQuery ?? "";
}
/* ───────────── step executor ───────────── */
async executeStep(step, input) {
const stop = startTimer();
this.context.currentStep = step;
const mod = resolveModuleForAction(step.action);
if (!mod) {
logLine("EXECUTE", step.action, stop(), "skipped (missing module)");
return { query: input.query, content: input.content, data: { skipped: true } };
}
try {
this.spinner.update(`Running step: ${step.action}`);
const output = await mod.run({
query: step.description ?? input.query,
content: input.data ?? input.content,
context: this.context
});
if (!output)
throw new Error(`Module "${mod.name}" returned empty output`);
logLine("EXECUTE", step.action, stop());
return { query: step.description ?? input.query, data: output.data };
}
catch (err) {
logLine("EXECUTE", step.action, stop(), "failed");
throw err;
}
}
/* ───────────────────────── execution gates ───────────────────────── */
canExecutePhase(phase) {
const mode = this.context.executionControl?.mode ?? "transform";
switch (phase) {
case "analysis":
return mode !== "explain"; // only analyze if not explain
case "planning":
return mode === "analyze" || mode === "transform";
case "transform":
case "write":
return mode === "transform"; // write only in transform
case "explain":
return mode === "explain"; // only explain mode runs explain
default:
return false;
}
}
/* ───────────── main run ───────────── */
async run() {
var _a, _b;
this.runCount++;
const stopRun = startTimer();
logLine("RUN", `start #${this.runCount}`);
logInputOutput("GlobalContext (structured)", "input", this.context);
this.spinner.start();
/* ================= BOOT ================= */
{
const t1 = startTimer();
await understandIntentStep.run({ context: this.context });
logLine("BOOT", "understandIntent", t1());
const t2 = startTimer();
await resolveExecutionModeStep.run(this.context);
logLine("BOOT", "resolveExecutionMode", t2(), `mode=${this.context.executionControl?.mode}`);
// Ensure executionControl exists first
(_a = this.context).executionControl ?? (_a.executionControl = {
mode: "transform", // default mode if missing
rationale: "Default execution mode",
confidence: 1,
constraints: {
allowAnalysis: true,
allowPlanning: true,
allowFileWrites: false
}
});
// Ensure constraints are set for consistency
switch (this.context.executionControl?.mode) {
case "explain":
this.context.executionControl.constraints = {
allowAnalysis: false,
allowPlanning: false,
allowFileWrites: false
};
break;
case "analyze":
this.context.executionControl.constraints = {
allowAnalysis: true,
allowPlanning: true,
allowFileWrites: false
};
break;
case "transform":
this.context.executionControl.constraints = {
allowAnalysis: true,
allowPlanning: true,
allowFileWrites: true
};
break;
default:
(_b = this.context.executionControl).constraints ?? (_b.constraints = {
allowAnalysis: true,
allowPlanning: true,
allowFileWrites: false
});
}
// Task tracking
const db = getDbForRepo();
const now = new Date().toISOString();
const userQuery = this.context.initContext?.userQuery ?? "unknown query";
const result = db.prepare(`
INSERT INTO tasks (initial_query, created_at, updated_at)
VALUES (?, ?, ?)
`).run(userQuery, now, now);
this.taskId = result.lastInsertRowid;
logLine("TASK", `created task id=${this.taskId}"`);
}
/* ================= INFORMATION ACQUISITION ================= */
let t = startTimer();
await preFileSearchCheckStep(this.context);
logLine("PRECHECK", "preFileSearch", t());
// -------------------- EXPLAIN MODE HANDLING --------------------
if (this.canExecutePhase("explain")) {
const explainMod = resolveModuleForAction("explain");
if (!explainMod)
throw new Error("Explain module not found");
const explainOutput = await explainMod.run({
query: this.query,
context: this.context
});
logLine("MODE", "explain", undefined, "returning AI-generated explanation after preFileSearchCheck");
this.spinner.stop();
return explainOutput;
}
// -------------------- SUMMARIZE FOLDERCAPSULES FOR PLANNING --------------------
logFolderCapsulesSummary(this.context);
/* ================= FAST-PATH CHECK ================= */
{
await planResolverStep.run(this.context);
const routing = this.context.analysis?.routingDecision;
if (routing?.decision === "final-answer" && routing.answer) {
logLine("ROUTING", "fastPathHit", undefined, "returning final answer early");
return { query: this.query, data: { finalAnswer: routing.answer, source: "planResolver" } };
}
}
// -------------------- PLANNING --------------------
if (this.canExecutePhase("planning")) {
t = startTimer();
await infoPlanGen.run(this.context);
logLine("PLAN", "infoPlanGen", t());
const infoPlan = this.context.analysis?.planSuggestion?.plan ?? { steps: [] };
let stepIO = { query: this.query };
for (const step of infoPlan.steps.filter(s => s.groups?.includes("info"))) {
stepIO = await this.executeStep(step, stepIO);
}
}
/* ================= ANALYSIS PHASE ================= */
if (this.canExecutePhase("analysis")) {
let t = startTimer();
await selectRelevantSourcesStep.run({ query: this.query, context: this.context });
logLine("ANALYSIS", "selectRelevantSources", t());
t = startTimer();
await structuralAnalysisStep.run({ query: this.query, context: this.context });
logLine("ANALYSIS", "structuralAnalysis", t());
t = startTimer();
await semanticAnalysisStep.run({ query: this.query, context: this.context });
logLine("ANALYSIS", "semanticAnalysis", t());
t = startTimer();
await planTargetFilesStep.run({ query: this.query, context: this.context });
logLine("ANALYSIS", "planTargetFiles", t());
t = startTimer();
await validationAnalysisStep.run({ query: this.query, context: this.context });
logLine("VALIDATE", "validationAnalysis", t());
}
const review = await contextReviewStep(this.context);
if (review.decision === "gatherData") {
if (this.runCount >= this.maxRuns) {
logLine("ROUTING", "gatherData", undefined, "max runs reached, proceeding anyway");
}
else {
logLine("ROUTING", "gatherData", undefined, review.reason);
this.runCount++;
this.resetInitContextForLoop();
return this.run();
}
}
/* ================= TRANSFORM PHASE ================= */
if (this.canExecutePhase("transform")) {
let t = startTimer();
await transformPlanGenStep.run(this.context);
logLine("PLAN", "transformPlanGen", t());
const transformSteps = (this.context.analysis?.planSuggestion?.plan?.steps ?? [])
.filter(s => s.groups?.includes("transform"));
let stepIO = { query: this.query };
for (const step of transformSteps) {
stepIO = await this.executeStep(step, stepIO);
}
/* ================= WRITE FILES ================= */
if (this.canExecutePhase("write")) {
const tWrite = startTimer();
stepIO = await writeFileStep.run({
query: this.query,
context: this.context,
data: stepIO.data
});
logLine("EXECUTE", "writeFile", tWrite());
}
}
/* ================= FINALIZE PHASE ================= */
{
const t = startTimer();
await finalPlanGenStep.run(this.context);
logLine("PLAN", "finalPlanGen", t());
const finalPlan = this.context.analysis?.planSuggestion?.plan ?? { steps: [] };
let stepIO = { query: this.query };
for (const step of finalPlan.steps.filter(s => s.groups?.includes("finalize"))) {
stepIO = await this.executeStep(step, stepIO);
}
}
this.spinner.stop();
userOutput("All input/output logs can be found at ~/.scai/input_output.log");
logLine("RUN", "complete", stopRun());
/* console.log("\n[DEBUG] Final MainAgent context:");
console.dir(this.context, { depth: null, colors: true }); */
return { query: this.query, data: {} };
}
resetInitContextForLoop() {
if (this.context.initContext)
this.context.initContext.relatedFiles = [];
}
}
/* ───────────── FOLDER CAPSULES SUMMARY HELPER ───────────── */
export function logFolderCapsulesSummary(context) {
const capsules = context.initContext?.folderCapsules;
if (!capsules?.length)
return;
// Ensure analysis exists
context.analysis ?? (context.analysis = {});
// Helper: truncate text to max length
const truncate = (text, max = 120) => text.length > max ? text.slice(0, max - 1) + "…" : text;
// Build human-readable summary (1–2 lines per folder)
const humanReadable = capsules.map(fc => {
const conf = Math.round((fc.confidence ?? 0) * 100) / 100; // round to 2 decimals
const header = `- ${fc.path} (${fc.stats?.fileCount ?? 0} files, conf ${conf})`;
// Line 2: summary + optional first key file reason
const summaryText = fc.summary ? truncate(fc.summary.trim()) : "";
const key = fc.keyFiles?.[0];
let body = "";
if (summaryText || key) {
body += " ";
if (summaryText)
body += summaryText;
if (key) {
const keyHint = `Key: ${key.path.split("/").pop()} — ${truncate(key.reason)}`;
body += summaryText ? ` [${keyHint}]` : keyHint;
}
}
return body ? `${header}\n${body}` : header;
}).join("\n\n"); // extra newline for readability
logInputOutput('FolderCapsule summarized', 'output', context.analysis.folderCapsulesHuman = humanReadable);
logLine("BOOT", "folderCapsulesSummary", undefined, `📂 ${capsules.length} folders summarized`);
}