UNPKG

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**

261 lines (260 loc) 8.67 kB
// 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"; import { extractFileReferences } from "./extractFileReferences.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: undefined, relatedFiles: [], relatedFileScores: {}, folderCapsules: [], }, task: { id: 0, projectId: 0, status: "active", initialQuery: args.query?.trim() ?? "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), taskSteps: [], }, }; /* -------- Collect related file paths -------- */ const relatedPaths = []; const relatedFileScores = {}; for (const tf of safeTopFiles) { if (!relatedPaths.includes(tf.path)) relatedPaths.push(tf.path); if (typeof tf.bm25Score === "number" && Number.isFinite(tf.bm25Score)) { relatedFileScores[tf.path] = tf.bm25Score; } } for (let i = 0; i < Math.min(RELATED_FILES_LIMIT, safeRelated.length); i++) { const candidate = safeRelated[i]; const p = candidate.path; if (!relatedPaths.includes(p)) relatedPaths.push(p); if (typeof candidate.bm25Score === "number" && Number.isFinite(candidate.bm25Score)) { relatedFileScores[p] = candidate.bm25Score; } } /* -------- Add file references from user query -------- */ const referencedFiles = extractFileReferences(ctx.initContext.userQuery, { lowercase: true, }); 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; ctx.initContext.relatedFileScores = Object.fromEntries(Object.entries(relatedFileScores).filter(([filePath]) => relatedPaths.includes(filePath))); ctx.initContext.queryExpansionTerms = Array.from(new Set((args.queryExpansionTerms ?? []) .map(term => String(term).trim().toLowerCase()) .filter(term => term.length >= 2))); /* -------- 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]; } /* ====================================================== IN-DEPTH CONTEXT (Structural-Only) ====================================================== */ export async function buildInDepthContext({ filenames, kgDepth = DEFAULT_KG_DEPTH, relatedFiles, query, }) { const db = getDbForRepo(); const safeFilenames = Array.isArray(filenames) ? filenames : []; const result = {}; for (const p of safeFilenames) { const fileId = fileRowIdForPath(db, p); const structural = {}; if (typeof fileId === "number") { structural.functions = loadFunctions(db, fileId, MAX_FUNCTIONS); structural.classes = loadClasses(db, fileId, 200); } const neighbors = loadKgNeighbors(db, p, MAX_KG_NEIGHBORS); structural.kgTags = loadKgTags(db, p, MAX_KG_NEIGHBORS); if (neighbors.length) { structural.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) structural.imports = imports.slice(0, 200); if (exports.length) structural.exports = exports.slice(0, 200); } structural.focusedTree = safeGenerateFocusedTree(p, 3); result[p] = structural; } return result; }