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**
188 lines (184 loc) • 7.67 kB
JavaScript
// File: src/agents/steps/understandIntentStep.ts
import { generate } from "../lib/generate.js";
import { logInputOutput } from "../utils/promptLogHelper.js";
import { extractFileReferences } from "../utils/extractFileReferences.js";
export const understandIntentStep = {
name: "understandIntent",
description: "Analyze the user query and determine its intent, type, and the appropriate task category.",
/**
* Run the step
*/
run: async (input) => {
const { context } = input;
const rawQuery = (context.initContext?.userQuery ?? "").trim();
// Fast-path for command-like probes (e.g. "/test") to avoid unnecessary model calls.
if (rawQuery.startsWith("/")) {
context.analysis ?? (context.analysis = {});
context.analysis.intent = {
intent: "Handle command-like query input",
intentCategory: "request",
normalizedQuery: rawQuery,
confidence: 0.9,
targetFiles: extractFileReferences(rawQuery),
targetSymbols: extractLikelySymbols(rawQuery),
};
return;
}
const prompt = `
You are an AI assistant whose job is to determine the user's intent.
User Query:
${context.initContext?.userQuery}
Return a STRICT JSON object with the following fields:
{
"intent": "short sentence summarizing the user's intent",
"intentCategory": "one of: question, request, codingTask, refactorTask, explanation, debugging, planning, writing, docsAndComments",
"normalizedQuery": "a cleaned and direct restatement of the user query",
"confidence": 0-1, // float
"targetFiles": ["optional explicit filenames/paths from user query"],
"targetSymbols": ["optional function/class/identifier targets from user query"]
}
Do not include commentary. Emit ONLY valid JSON.
`.trim();
try {
const genInput = {
query: rawQuery,
content: prompt
};
const genOutput = await generate(genInput);
let raw = genOutput.data;
if (typeof raw !== "string")
raw = JSON.stringify(raw ?? "{}");
logInputOutput("understandIntent", "output", raw);
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
parsed = {
intent: "unknown",
intentCategory: "",
normalizedQuery: context.initContext?.userQuery,
confidence: 0.3
};
}
// ---------------------
// Fallback for invalid / "other" categories
// ---------------------
let category = parsed.intentCategory?.trim();
if (!category || category.toLowerCase() === "other") {
const q = parsed.normalizedQuery?.toLowerCase() ?? "";
if (q.includes("move") || q.includes("append") || q.includes("remove") || q.includes("insert")) {
category = "refactorTask";
}
else if (q.includes("comment") || q.includes("documentation") || q.includes("jsdoc") || q.includes("docstring")) {
category = "docsAndComments";
}
else if (q.includes("sentence") || q.includes("file")) {
category = "explanation";
}
else {
category = "request"; // safe default
}
}
category = normalizeIntentCategory(category);
const intentConfidence = normalizeConfidence(parsed.confidence, 0.5);
const llmTargetFiles = Array.isArray(parsed.targetFiles) ? parsed.targetFiles : [];
const llmTargetSymbols = Array.isArray(parsed.targetSymbols) ? parsed.targetSymbols : [];
// If model confidence is high and it already provided explicit targets,
// avoid merging raw-query regex matches (pasted logs can add heavy noise).
// Example:
// - high confidence + targetFiles=["fileIndex.ts"] => keep only that target
// - low confidence or no targets => merge heuristic extraction from raw query
const shouldMergeHeuristics = intentConfidence < 0.6 ||
(llmTargetFiles.length === 0 && llmTargetSymbols.length === 0);
const targetFiles = mergeUniqueCaseInsensitive(llmTargetFiles, shouldMergeHeuristics ? extractFileReferences(rawQuery) : []);
const targetSymbols = mergeUniqueCaseInsensitive(llmTargetSymbols, shouldMergeHeuristics ? extractLikelySymbols(rawQuery) : []);
// Ensure the analysis object exists
context.analysis ?? (context.analysis = {});
// Store intent inside a dedicated object
context.analysis.intent = {
intent: parsed.intent,
intentCategory: category,
normalizedQuery: parsed.normalizedQuery ?? rawQuery,
confidence: intentConfidence,
targetFiles,
targetSymbols,
};
}
catch (err) {
console.error("understandIntent error:", err);
// Ensure the analysis object exists
context.analysis ?? (context.analysis = {});
context.analysis.intent = {
intent: "unknown",
intentCategory: "request",
normalizedQuery: rawQuery,
confidence: 0.0,
targetFiles: extractFileReferences(rawQuery),
targetSymbols: extractLikelySymbols(rawQuery),
};
}
}
};
function mergeUniqueCaseInsensitive(primary, secondary) {
const out = [];
const seen = new Set();
for (const token of [...primary, ...secondary]) {
if (typeof token !== "string")
continue;
const trimmed = token.trim();
if (!trimmed)
continue;
const key = trimmed.toLowerCase();
if (seen.has(key))
continue;
seen.add(key);
out.push(trimmed);
}
return out;
}
function extractLikelySymbols(query) {
const matches = query.match(/[A-Za-z_][A-Za-z0-9_]{2,}/g) ?? [];
const out = new Set();
for (const token of matches) {
const looksLikeSymbol = /[A-Z]/.test(token) ||
token.includes("_") ||
token.endsWith("Step") ||
token.endsWith("Module") ||
token.endsWith("Cmd");
if (!looksLikeSymbol)
continue;
out.add(token);
}
return Array.from(out);
}
function normalizeConfidence(value, fallback = 0.5) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.min(1, value));
}
function normalizeIntentCategory(raw) {
const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!value)
return "request";
const directMap = {
question: "question",
request: "request",
codingtask: "codingTask",
refactortask: "refactorTask",
explanation: "explanation",
debugging: "debugging",
planning: "planning",
writing: "writing",
docsandcomments: "docsAndComments",
docsandcomment: "docsAndComments",
docs: "docsAndComments",
comments: "docsAndComments",
comment: "docsAndComments",
analysis: "question",
analyze: "question",
architecture: "explanation",
};
return directMap[value] ?? "request";
}