scai
Version:
> **AI-powered CLI for local code analysis, commit message suggestions, and natural-language queries.** 100% local, private, GDPR-friendly, made in Denmark/EU with ❤️.
257 lines (256 loc) • 8.31 kB
JavaScript
// src/utils/buildContextualPrompt.ts
import path from "path";
import fs from "fs";
import { getDbForRepo } from "../db/client.js";
import { generateFileTree, generateFocusedFileTree } from "./fileTree.js";
import { RELATED_FILES_LIMIT } from "../constants.js";
import { loadRelevantFolderCapsules } from "./loadRelevantFolderCapsules.js";
/* --- Constants --- */
const MAX_FUNCTIONS = 50;
const MAX_KG_NEIGHBORS = 50;
const DEFAULT_KG_DEPTH = 3;
/* --- Helpers --- */
function fileRowIdForPath(db, filePath) {
try {
const row = db
.prepare(`SELECT id FROM files WHERE path = ?`)
.get(filePath);
return row?.id;
}
catch {
return undefined;
}
}
/* ---------------- KG helpers ---------------- */
function loadKgTags(db, entityUniqueId, limit = MAX_KG_NEIGHBORS) {
try {
const rows = db
.prepare(`
SELECT tm.name as tag
FROM graph_entity_tags et
JOIN graph_tags_master tm ON tm.id = et.tag_id
WHERE et.entity_unique_id = ?
LIMIT ?
`)
.all(entityUniqueId, limit);
return rows.map((r) => r.tag);
}
catch {
return [];
}
}
function loadKgNeighbors(db, sourceUniqueId, limit = MAX_KG_NEIGHBORS) {
try {
const rows = db
.prepare(`
SELECT relation, target_unique_id as target
FROM graph_edges
WHERE source_unique_id = ?
LIMIT ?
`)
.all(sourceUniqueId, limit);
return rows.map((r) => ({ relation: r.relation, target: r.target }));
}
catch {
return [];
}
}
/* ---------------- Functions / classes ---------------- */
function loadFunctions(db, fileId, limit = MAX_FUNCTIONS) {
if (!fileId)
return [];
try {
const rows = db
.prepare(`SELECT name, start_line, end_line
FROM functions
WHERE file_id = ?
ORDER BY start_line
LIMIT ?`)
.all(fileId, limit);
return rows.map((r) => ({
name: r.name ?? undefined,
start: r.start_line,
end: r.end_line,
}));
}
catch {
return [];
}
}
function loadClasses(db, fileId, limit = 50) {
if (!fileId)
return [];
try {
const rows = db
.prepare(`SELECT name, start_line, end_line
FROM graph_classes
WHERE file_id = ?
ORDER BY start_line
LIMIT ?`)
.all(fileId, limit);
return rows.map((r) => ({
name: r.name ?? undefined,
start: r.start_line,
end: r.end_line,
}));
}
catch {
return [];
}
}
/* ---------------- Trees ---------------- */
function safeGenerateFocusedTree(filePath, depth = 2) {
try {
return generateFocusedFileTree(filePath, depth) || undefined;
}
catch {
return undefined;
}
}
function safeGenerateRepoTree(depth = 2) {
try {
const root = process.cwd();
return generateFileTree(root, depth) || undefined;
}
catch {
return undefined;
}
}
/* ======================================================
LIGHT CONTEXT
====================================================== */
export async function buildLightContext(args) {
const db = getDbForRepo();
const safeTopFiles = Array.isArray(args.topFiles) ? args.topFiles : [];
const safeRelated = Array.isArray(args.relatedFiles)
? args.relatedFiles
: [];
const ctx = {
initContext: {
userQuery: (args.query || "").trim(),
repoTree: safeGenerateRepoTree(2),
relatedFiles: [],
folderCapsules: [],
},
};
/* -------- Collect related file paths -------- */
const relatedPaths = [];
for (const tf of safeTopFiles) {
if (!relatedPaths.includes(tf.path))
relatedPaths.push(tf.path);
}
for (let i = 0; i < Math.min(RELATED_FILES_LIMIT, safeRelated.length); i++) {
const p = safeRelated[i].path;
if (!relatedPaths.includes(p))
relatedPaths.push(p);
}
/* -------- Add file references from user query -------- */
const referencedFiles = extractFileReferences(ctx.initContext.userQuery);
const topFilePaths = [];
for (const ref of referencedFiles) {
const found = safeTopFiles.find(f => f.path.toLowerCase().includes(ref));
if (found && !topFilePaths.includes(found.path)) {
topFilePaths.push(found.path);
}
}
// Merge query references into relatedPaths
for (const tf of topFilePaths) {
if (!relatedPaths.includes(tf))
relatedPaths.push(tf);
}
ctx.initContext.relatedFiles = relatedPaths;
/* -------- Folder capsules (orientation layer) -------- */
const folderPaths = normalizeToFolders(relatedPaths);
let folderCapsules = loadRelevantFolderCapsules(folderPaths);
// Ensure root capsule exists
const rootExists = folderCapsules.some(c => c.path === '/');
if (!rootExists) {
const rootCapsule = {
path: '/',
depth: 1,
stats: { fileCount: 0, byType: {} },
roles: [],
concerns: [],
keyFiles: [],
dependencies: { importsFrom: [], usedBy: [] },
confidence: 0.5,
};
folderCapsules.unshift(rootCapsule);
}
ctx.initContext.folderCapsules = folderCapsules;
return ctx;
}
/* -------- Helper: get folders from filepaths -------- */
function normalizeToFolders(paths) {
const out = new Set();
for (const p of paths) {
try {
const stat = fs.statSync(p);
if (stat.isDirectory()) {
out.add(p);
}
else if (stat.isFile()) {
out.add(path.dirname(p));
}
}
catch {
// ignore invalid paths
}
}
return [...out];
}
/* -------- Helper: extract file references from query -------- */
function extractFileReferences(query) {
const matches = [];
const filenameRegex = /[\w\-\/]+\.\w{1,6}/gi;
const explicit = query.match(filenameRegex);
if (explicit)
matches.push(...explicit.map(s => s.toLowerCase()));
return [...new Set(matches)];
}
/* ======================================================
IN-DEPTH CONTEXT
====================================================== */
export async function buildInDepthContext({ filenames, kgDepth = DEFAULT_KG_DEPTH, relatedFiles, query, }) {
const db = getDbForRepo();
const safeFilenames = Array.isArray(filenames) ? filenames : [];
const safeRelated = Array.isArray(relatedFiles) ? relatedFiles : [];
const initCtx = {
userQuery: query?.trim() || "",
repoTree: safeGenerateRepoTree(3),
relatedFiles: safeRelated,
folderCapsules: loadRelevantFolderCapsules(normalizeToFolders(safeRelated)),
};
const workingFiles = [];
const out = {
initContext: initCtx,
workingFiles,
};
/* -------- Working files (deep phase only) -------- */
for (const p of safeFilenames) {
const fileId = fileRowIdForPath(db, p);
const fileObj = { path: p };
if (typeof fileId === "number") {
fileObj.functions = loadFunctions(db, fileId, MAX_FUNCTIONS);
fileObj.classes = loadClasses(db, fileId, 200);
}
const neighbors = loadKgNeighbors(db, p, MAX_KG_NEIGHBORS);
fileObj.kgTags = loadKgTags(db, p, MAX_KG_NEIGHBORS);
if (neighbors.length) {
fileObj.kgNeighborhood = neighbors.map((e) => `${e.relation}:${e.target}`);
const imports = neighbors
.filter((e) => e.relation === "imports")
.map((e) => e.target);
const exports = neighbors
.filter((e) => e.relation === "exports")
.map((e) => e.target);
if (imports.length)
fileObj.imports = imports.slice(0, 200);
if (exports.length)
fileObj.exports = exports.slice(0, 200);
}
fileObj.focusedTree = safeGenerateFocusedTree(p, 3);
workingFiles.push(fileObj);
}
return out;
}