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 ❤️.

239 lines (229 loc) 11.1 kB
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; }, };