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 ❤️.
239 lines (229 loc) • 11.1 kB
JavaScript
import { generate } from "../../lib/generate.js";
import { cleanupModule } from "./cleanupModule.js";
import { logInputOutput } from "../../utils/promptLogHelper.js";
import { splitCodeIntoChunks, countTokens } from "../../utils/splitCodeIntoChunk.js";
import chalk from "chalk";
// ───────────── Token limits ─────────────
const SINGLE_SHOT_TOKEN_LIMIT = 2500; // keep large enough for small files
const CHUNK_TOKEN_LIMIT = 2200; // chunked transformation limit
function stripCodeFences(text) {
const lines = text.split("\n");
while (lines.length && /^```/.test(lines[0].trim()))
lines.shift();
while (lines.length && /^```/.test(lines[lines.length - 1].trim()))
lines.pop();
return lines.join("\n");
}
function isSuspiciousChunkOutput(text, originalChunk) {
const trimmed = text.trim();
if (!trimmed)
return true;
if (/here is|transformed|updated code|explanation/i.test(trimmed))
return true;
if (trimmed.startsWith("{") && trimmed.endsWith("}"))
return true;
if (trimmed.length < originalChunk.trim().length * 0.3)
return true;
return false;
}
export const codeTransformModule = {
name: "codeTransform",
description: "Transforms a single file specified in the current plan step based on user instruction.",
groups: ["transform"],
run: async (input) => {
var _a, _b, _c;
const query = typeof input.query === "string" ? input.query : String(input.query ?? "");
const context = input.context;
if (!context) {
return { query, data: { files: [], errors: ["No context provided"] } };
}
const workingFiles = context.workingFiles ?? [];
const step = context.currentStep;
const targetFile = step?.targetFile;
if (!targetFile) {
return { query, data: { files: [], errors: ["No targetFile specified in current plan step"] } };
}
const file = workingFiles.find(f => f.path === targetFile);
if (!file || typeof file.code !== "string") {
return { query, data: { files: [], errors: [`Target file not found or missing code: ${targetFile}`] } };
}
const normalizedQuery = context.analysis?.intent?.normalizedQuery ?? query;
const fileAnalysis = context.analysis?.fileAnalysis?.[file.path];
const proposed = fileAnalysis?.proposedChanges;
const proposedChangesBlock = proposed
? `
Proposed changes for this file:
- Summary: ${proposed.summary}
- Scope: ${proposed.scope}
${proposed.targets ? `- Targets: ${proposed.targets.join(", ")}` : ""}
${proposed.rationale ? `- Rationale: ${proposed.rationale}` : ""}
`.trim()
: "";
const outputs = [];
const perFileErrors = [];
const tokenCount = countTokens(file.code);
context.execution || (context.execution = {});
(_a = context.execution).codeTransformArtifacts || (_a.codeTransformArtifacts = { files: [] });
// ───────────── SMALL FILE ─────────────
if (tokenCount <= SINGLE_SHOT_TOKEN_LIMIT) {
logInputOutput("codeTransform", "output", {
file: file.path,
tokenCount,
message: "Starting small file transformation",
});
const prompt = `
You are a precise code transformation assistant.
User instruction (normalized):
${normalizedQuery}
${proposedChangesBlock}
File to transform:
---\nFILE: ${file.path}\n${file.code}
Rules:
- If scope is "none", return the file UNCHANGED.
- Otherwise, apply ONLY the proposed changes described above.
- You MUST return the FULL rewritten file content.
- Do NOT return diffs or partial snippets.
JSON schema:
{
"files": [
{
"filePath": "<path>",
"content": "<FULL rewritten file content>",
"notes": "<optional>"
}
],
"errors": []
}
`.trim();
try {
logInputOutput("codeTransform", "output", { file: file.path, message: "Sending prompt to LLM" });
const llmResponse = await generate({ content: prompt, query });
logInputOutput("codeTransform", "output", {
file: file.path,
message: "Received LLM response",
responseSnippet: String(llmResponse.data ?? "").slice(0, 200),
});
const cleaned = await cleanupModule.run({ query, content: llmResponse.data });
let finalContent;
let notes = undefined;
if (typeof cleaned.data === 'string') {
// Raw code returned
finalContent = cleaned.data;
console.debug(chalk.yellow(` - [cleanupModule] Using raw code output for ${file.path}`));
}
else if (cleaned.data &&
typeof cleaned.data === 'object' &&
Array.isArray(cleaned.data.files)) {
// Structured JSON returned
const structured = cleaned.data;
const out = structured.files.find(f => f.filePath === file.path);
finalContent = out?.content ?? file.code;
notes = out?.notes;
perFileErrors.push(...(structured.errors ?? []));
}
else {
// Fallback
finalContent = file.code;
}
outputs.push({ filePath: file.path, content: finalContent, notes });
// ensure artifacts are updated
context.execution.codeTransformArtifacts.files =
context.execution.codeTransformArtifacts.files.filter(f => f.filePath !== file.path);
context.execution.codeTransformArtifacts.files.push(outputs[0]);
context.plan || (context.plan = {});
(_b = context.plan).touchedFiles || (_b.touchedFiles = []);
if (!context.plan.touchedFiles.includes(file.path))
context.plan.touchedFiles.push(file.path);
const output = { query, data: { files: outputs, errors: perFileErrors } };
logInputOutput("codeTransform", "output", context.execution.codeTransformArtifacts.files);
return output;
}
catch (err) {
const finalContent = file.code;
outputs.push({ filePath: file.path, content: finalContent });
perFileErrors.push(`LLM call or cleanup failed: ${err.message}`);
context.execution.codeTransformArtifacts.files.push(outputs[0]);
const output = { query, data: { files: outputs, errors: perFileErrors } };
return output;
}
}
// ───────────── LARGE FILE (chunked) ─────────────
const chunks = splitCodeIntoChunks(file.code, CHUNK_TOKEN_LIMIT);
console.warn(chalk.yellow([
"",
"⚠️ Large file detected — falling back to chunked transformation",
` File: ${file.path}`,
` Estimated tokens: ${tokenCount}`,
` Chunks: ${chunks.length}`,
" Note: Chunked refactors may be less accurate or inconsistent.",
" If results look wrong, try reducing scope or refactoring manually.",
"",
].join("\n")));
const transformedChunks = [];
logInputOutput("codeTransform", "output", { file: file.path, chunkCount: chunks.length, message: "Starting chunked transformation" });
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
process.stdout.write("\r\x1b[K");
console.log(` - Processing chunk ${i + 1} of ${chunks.length} for ${file.path}...`);
logInputOutput("codeTransform", "output", {
file: file.path,
chunkIndex: i + 1,
chunkLength: chunk.length,
message: "Processing chunk",
});
const prompt = `
You are a precise and conservative code transformation assistant.
User instruction (normalized):
${normalizedQuery}
IMPORTANT SAFETY RULES:
- If this chunk contains an UNFINISHED or UNCLOSED construct
(for example: an opening "{", "(", "[", "function", "class", or similar
that is not clearly closed within this chunk),
then you MUST return the chunk UNCHANGED.
- Do NOT invent missing braces, code, or structure.
- Do NOT attempt to "fix" incomplete syntax.
- When in doubt, return the chunk EXACTLY as given.
Transformation rules:
- Apply the instruction ONLY if it is clearly relevant to this chunk.
- If no change is needed, return the chunk UNCHANGED.
- You MUST return the FULL chunk content.
- Do NOT return diffs, explanations, or partial snippets.
FILE: ${file.path}
CHUNK ${i + 1} / ${chunks.length}
---
${chunk}
`.trim();
try {
const llmResponse = await generate({ content: prompt, query });
const raw = String(llmResponse.data ?? "");
const stripped = stripCodeFences(raw);
if (isSuspiciousChunkOutput(stripped, chunk)) {
transformedChunks.push(chunk);
perFileErrors.push(`Chunk ${i + 1} suspicious; original preserved.`);
logInputOutput("codeTransform", "output", { file: file.path, chunkIndex: i + 1, message: "Suspicious output, original chunk preserved" });
}
else {
transformedChunks.push(stripped);
logInputOutput("codeTransform", "output", { file: file.path, chunkIndex: i + 1, message: "Chunk transformed successfully" });
}
}
catch (err) {
transformedChunks.push(chunk);
perFileErrors.push(`Chunk ${i + 1} failed; original preserved. Error: ${err.message}`);
logInputOutput("codeTransform", "output", { file: file.path, chunkIndex: i + 1, error: err.message, message: "Chunk transformation failed, original preserved" });
}
}
const finalContent = transformedChunks.join("\n");
outputs.push({ filePath: file.path, content: finalContent });
context.execution.codeTransformArtifacts.files =
context.execution.codeTransformArtifacts.files.filter(f => f.filePath !== file.path);
context.execution.codeTransformArtifacts.files.push(outputs[0]);
context.plan || (context.plan = {});
(_c = context.plan).touchedFiles || (_c.touchedFiles = []);
if (!context.plan.touchedFiles.includes(file.path))
context.plan.touchedFiles.push(file.path);
const output = { query, data: { files: outputs, errors: perFileErrors } };
logInputOutput("codeTransform", "output", context.execution.codeTransformArtifacts.files);
return output;
},
};