scai
Version:
> AI-powered CLI tool for commit messages **and** pull request reviews ā using local models.
179 lines (178 loc) ⢠6.65 kB
JavaScript
import fs from 'fs';
import path from 'path';
import readline from 'readline';
import { searchFiles, queryFiles, getFunctionsForFiles } from '../db/fileIndex.js';
import { sanitizeQueryForFts } from '../utils/sanitizeQuery.js';
import { generate } from '../lib/generate.js';
import { buildContextualPrompt } from '../utils/buildContextualPrompt.js';
import { generateFocusedFileTree } from '../utils/fileTree.js';
import { log } from '../utils/log.js';
import { PROMPT_LOG_PATH, SCAI_HOME, RELATED_FILES_LIMIT, MAX_SUMMARY_LINES, getIndexDir, MAX_FUNCTION_LINES } from '../constants.js';
import chalk from 'chalk';
export async function runAskCommand(query) {
if (!query) {
query = await promptOnce('š¬ Ask your question:\n');
}
query = query.trim();
if (!query) {
console.error('ā No question provided.\nš Usage: scai ask "your question"');
return;
}
console.log(`š Using index root: ${getIndexDir()}`);
console.log(`š Searching for: "${query}"\n`);
// š© STEP 1: Semantic Search
const start = Date.now();
const semanticResults = await searchFiles(query, RELATED_FILES_LIMIT); // RankedFile[]
const duration = Date.now() - start;
console.log(`ā±ļø searchFiles took ${duration}ms and returned ${semanticResults.length} result(s)`);
semanticResults.forEach((file, i) => {
console.log(` ${i + 1}. š Path: ${file.path} | Score: ${file.score?.toFixed(3) ?? 'n/a'}`);
});
// š© STEP 1.5: Fallback FTS search
const safeQuery = sanitizeQueryForFts(query);
const fallbackResults = queryFiles(safeQuery, 10); // FileRow[]
fallbackResults.forEach((file, i) => {
console.log(` ${i + 1}. š Fallback Match: ${file.path}`);
});
// š© STEP 2: Merge results
const seen = new Set();
const combinedResults = [];
for (const file of semanticResults) {
const resolved = path.resolve(file.path);
seen.add(resolved);
combinedResults.push(file);
}
for (const file of fallbackResults) {
const resolved = path.resolve(file.path);
if (!seen.has(resolved)) {
seen.add(resolved);
combinedResults.push({
id: file.id,
path: file.path,
summary: file.summary || '',
score: 0.0,
sim: 0,
bm25: 0
});
}
}
// š© STEP 3: Log results
if (combinedResults.length) {
console.log('\nš Final Related Files:');
combinedResults.forEach((f, i) => {
console.log(` ${i + 1}. ${f.path} (${f.score?.toFixed(3) ?? 'fallback'})`);
});
}
else {
console.log('ā ļø No similar files found. Using query only.');
}
// š© STEP 4: Load top file code + metadata
const topFile = combinedResults[0];
const filepath = topFile?.path || '';
let code = '';
let topSummary = topFile.summary || '(No summary available)';
let topFunctions = [];
const fileFunctions = {};
// Truncate summary
topSummary = topSummary.split('\n').slice(0, MAX_SUMMARY_LINES).join('\n');
const allFileIds = combinedResults
.map(file => file.id)
.filter((id) => typeof id === 'number');
const allFunctionsMap = getFunctionsForFiles(allFileIds); // Record<number, Function[]>
try {
code = fs.readFileSync(filepath, 'utf-8');
const topFileId = topFile.id;
topFunctions = allFunctionsMap[topFileId]?.map(fn => {
const content = fn.content
? fn.content.split('\n').slice(0, MAX_FUNCTION_LINES).join('\n')
: '(No content available)';
return {
name: fn.name,
content,
};
}) || [];
}
catch (err) {
console.warn(`ā ļø Failed to read or analyze top file (${filepath}):`, err);
}
// š© STEP 5: Build relatedFiles with functions and fileFunctions
const relatedFiles = combinedResults.slice(0, RELATED_FILES_LIMIT).map(file => {
const fileId = file.id;
let summary = file.summary || '(No summary available)';
if (summary) {
summary = summary.split('\n').slice(0, MAX_SUMMARY_LINES).join('\n');
}
const functions = allFunctionsMap[fileId]?.map(fn => {
const content = fn.content
? fn.content.split('\n').slice(0, MAX_FUNCTION_LINES).join('\n')
: '(No content available)';
return {
name: fn.name,
content,
};
}) || [];
return {
path: file.path,
summary,
functions,
};
});
// š© STEP 6: Generate file tree
let fileTree = '';
try {
fileTree = generateFocusedFileTree(filepath, 2);
}
catch (e) {
console.warn('ā ļø Could not generate file tree:', e);
}
// š© STEP 7: Build prompt
console.log(chalk.blueBright('\nš¦ Building contextual prompt...'));
const promptContent = buildContextualPrompt({
baseInstruction: query,
code,
summary: topSummary,
functions: topFunctions,
relatedFiles,
projectFileTree: fileTree || undefined,
fileFunctions,
});
console.log(chalk.greenBright('ā
Prompt built successfully.'));
console.log(chalk.cyan(`[runAskCommand] Prompt token estimate: ~${Math.round(promptContent.length / 4)} tokens`));
// š© STEP 8: Save prompt
try {
if (!fs.existsSync(SCAI_HOME))
fs.mkdirSync(SCAI_HOME, { recursive: true });
fs.writeFileSync(PROMPT_LOG_PATH, promptContent, 'utf-8');
log(`š Prompt saved to ${PROMPT_LOG_PATH}`);
}
catch (err) {
log('ā Failed to write prompt log:', err);
}
// š© STEP 9: Ask model
try {
console.log('\nš¤ Asking the model...');
const input = {
content: promptContent,
filepath,
};
const modelResponse = await generate(input, 'llama3');
console.log(`\nš§ Model Response:\n${modelResponse.content}`);
}
catch (err) {
console.error('ā Model request failed:', err);
}
}
// š© Helper: Prompt once
function promptOnce(promptText) {
return new Promise(resolve => {
console.log(promptText);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question('> ', answer => {
rl.close();
resolve(answer.trim());
});
});
}