scai
Version:
> **AI-powered CLI for local code analysis, commit message suggestions, and natural-language queries.** 100% local, private, GDPR-friendly, made in Denmark/EU with ❤️.
208 lines (207 loc) • 9.36 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 { preFileSearchCheckStep } from "./preFileSearchCheckStep.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";
/* ───────────────────────── helpers ───────────────────────── */
function startTimer() {
const start = Date.now();
return () => Date.now() - start;
}
function logLine(phase, step, ms, desc) {
// Clear current line (removes leftover spinner)
process.stdout.write('\r\x1b[K');
const suffix = desc ? ` — ${desc}` : "";
const timing = typeof ms === "number" ? ` (${ms}ms)` : "";
console.log(`[AGENT] ${phase} :: ${step}${suffix}${timing}`);
}
/** Helper to display messages to the user from the agent */
function userOutput(message) {
console.log(`[USER OUTPUT] ${message}`);
}
/** Helper to display messages with phase context */
function userPhaseOutput(phase, message) {
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(); // spinner for user feedback
this.runCount = 0;
this.maxRuns = 2;
this.context = context;
this.query = context.initContext?.userQuery ?? "";
}
/* ───────────── step executor ───────────── */
async executeStep(step, input) {
const stop = startTimer();
if (input.context) {
input.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 },
context: input.context
};
}
try {
// Update spinner text for user
this.spinner.update(`Running step: ${step.action}`);
const output = await mod.run({
query: step.description ?? input.query,
content: input.data ?? input.content,
context: input.context
});
if (!output) {
throw new Error(`Module "${mod.name}" returned empty output`);
}
logLine("EXECUTE", step.action, stop(), step.description);
return {
query: step.description ?? input.query,
data: output.data,
context: input.context
};
}
catch (err) {
logLine("EXECUTE", step.action, stop(), "failed");
throw err;
}
}
/* ───────────── main run ───────────── */
async run() {
this.runCount++;
const stopRun = startTimer();
logLine("RUN", `start #${this.runCount}`);
logInputOutput("GlobalContext (structured)", "input", this.context);
this.spinner.start(); // start spinner at beginning
/* BOOT */
{
const t = startTimer();
await understandIntentStep.run({ context: this.context });
logLine("BOOT", "understandIntent", t());
}
/* ROUTE */
{
const t = startTimer();
await planResolverStep.run(this.context);
logLine("ROUTE", "planResolver", t());
}
const routing = this.context.analysis?.routingDecision;
if (routing?.decision === "final-answer" && routing.answer) {
this.spinner.stop();
logLine("RUN", "complete", stopRun());
userOutput("All input/output logs can be found at ~/.scai/input_output.log");
return {
query: this.query,
data: {
finalAnswer: routing.answer,
source: "planResolver"
},
context: this.context
};
}
// =====================================================
// INFORMATION ACQUISITION PHASE
// Purpose: gather raw information, no interpretation
// =====================================================
{
const t = startTimer();
await preFileSearchCheckStep(this.context);
logLine("PRECHECK", "preFileSearch", t());
}
{
const t = startTimer();
await infoPlanGen.run(this.context);
logLine("PLAN", "infoPlanGen", t());
}
const infoPlan = this.context.analysis?.planSuggestion?.plan ?? { steps: [] };
let stepIO = {
query: this.query,
context: this.context
};
for (const step of infoPlan.steps.filter(s => s.groups?.includes("info"))) {
stepIO = await this.executeStep(step, stepIO);
}
// =====================================================
// ANALYSIS PHASE
// Purpose: understand what we have and what is being asked
// =====================================================
{
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 === "loopAgain" && this.runCount < this.maxRuns) {
this.resetInitContextForLoop();
return this.run();
}
// =====================================================
// TRANSFORM PHASE
// Purpose: produce concrete changes or artifacts
// =====================================================
{
const t = startTimer();
await transformPlanGenStep.run(this.context);
logLine("PLAN", "transformPlanGen", t());
}
const transformPlan = this.context.analysis?.planSuggestion?.plan ?? { steps: [] };
for (const step of transformPlan.steps.filter(s => s.groups?.includes("transform"))) {
stepIO = await this.executeStep(step, stepIO);
}
// =====================================================
// FINALIZE PHASE
// Purpose: commit results and respond to the user
// =====================================================
{
const t = startTimer();
await finalPlanGenStep.run(this.context);
logLine("PLAN", "finalPlanGen", t());
}
const finalPlan = this.context.analysis?.planSuggestion?.plan ?? { steps: [] };
for (const step of finalPlan.steps.filter(s => s.groups?.includes("finalize"))) {
stepIO = await this.executeStep(step, stepIO);
}
this.spinner.stop(); // stop spinner at the end
userOutput("All input/output logs can be found at ~/.scai/input_output.log");
logLine("RUN", "complete", stopRun());
return stepIO;
}
/* ───────────── helpers ───────────── */
resetInitContextForLoop() {
if (this.context.initContext) {
this.context.initContext.relatedFiles = [];
}
}
}