UNPKG

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
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 === "transform"; // planning only in 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`); }