arela
Version:
AI-powered CTO with multi-agent orchestration, code summarization, visual testing (web + mobile) for blazing fast development.
451 lines (449 loc) ⢠14.9 kB
JavaScript
import fs from "fs-extra";
import path from "path";
import { execa } from "execa";
import pc from "picocolors";
const CONFIG_FILE = ".arela/auto-index.json";
const STATE_FILE = ".arela/.auto-index-state.json";
/**
* Default auto-index configuration
*/
const DEFAULT_CONFIG = {
enabled: true,
silent: true,
triggers: [
{
type: "lines_added",
threshold: 1000,
},
{
type: "files_added",
threshold: 10,
},
{
type: "time_elapsed",
threshold: 3600000, // 1 hour in ms
},
{
type: "commits",
threshold: 5,
},
],
exclude_patterns: [
"node_modules/**",
"dist/**",
"build/**",
".git/**",
"*.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
],
};
/**
* Load auto-index configuration
*/
export async function loadAutoIndexConfig(cwd) {
const configPath = path.join(cwd, CONFIG_FILE);
if (!(await fs.pathExists(configPath))) {
// Create default config
await fs.ensureDir(path.dirname(configPath));
await fs.writeJSON(configPath, DEFAULT_CONFIG, { spaces: 2 });
return DEFAULT_CONFIG;
}
return await fs.readJSON(configPath);
}
/**
* Load index state
*/
async function loadIndexState(cwd) {
const statePath = path.join(cwd, STATE_FILE);
if (!(await fs.pathExists(statePath))) {
return {
last_index_time: new Date().toISOString(),
lines_since_last_index: 0,
files_since_last_index: 0,
commits_since_last_index: 0,
};
}
return await fs.readJSON(statePath);
}
/**
* Save index state
*/
async function saveIndexState(cwd, state) {
const statePath = path.join(cwd, STATE_FILE);
await fs.ensureDir(path.dirname(statePath));
await fs.writeJSON(statePath, state, { spaces: 2 });
}
/**
* Count lines added since last index
*/
async function countLinesAdded(cwd, state) {
try {
// Get diff stats since last index
const { stdout } = await execa("git", [
"diff",
"--numstat",
state.last_index_commit || "HEAD~10",
"HEAD",
], { cwd });
let totalLines = 0;
const lines = stdout.split("\n");
for (const line of lines) {
if (!line.trim())
continue;
const [added] = line.split("\t");
totalLines += parseInt(added, 10) || 0;
}
return totalLines;
}
catch {
return 0;
}
}
/**
* Count files added since last index
*/
async function countFilesAdded(cwd, state) {
try {
const { stdout } = await execa("git", [
"diff",
"--name-only",
state.last_index_commit || "HEAD~10",
"HEAD",
], { cwd });
return stdout.split("\n").filter(Boolean).length;
}
catch {
return 0;
}
}
/**
* Count commits since last index
*/
async function countCommitsSince(cwd, state) {
try {
const { stdout } = await execa("git", [
"rev-list",
"--count",
`${state.last_index_commit || "HEAD~10"}..HEAD`,
], { cwd });
return parseInt(stdout.trim(), 10) || 0;
}
catch {
return 0;
}
}
/**
* Get current git commit hash
*/
async function getCurrentCommit(cwd) {
try {
const { stdout } = await execa("git", ["rev-parse", "HEAD"], { cwd });
return stdout.trim();
}
catch {
return undefined;
}
}
/**
* Check if any trigger threshold is met
*/
async function checkTriggers(cwd, config, state) {
for (const trigger of config.triggers) {
switch (trigger.type) {
case "lines_added": {
const lines = await countLinesAdded(cwd, state);
if (lines >= trigger.threshold) {
return {
triggered: true,
reason: `${lines} lines added (threshold: ${trigger.threshold})`,
};
}
break;
}
case "files_added": {
const files = await countFilesAdded(cwd, state);
if (files >= trigger.threshold) {
return {
triggered: true,
reason: `${files} files added (threshold: ${trigger.threshold})`,
};
}
break;
}
case "time_elapsed": {
const elapsed = Date.now() - new Date(state.last_index_time).getTime();
if (elapsed >= trigger.threshold) {
return {
triggered: true,
reason: `${Math.floor(elapsed / 60000)} minutes elapsed (threshold: ${Math.floor(trigger.threshold / 60000)} minutes)`,
};
}
break;
}
case "commits": {
const commits = await countCommitsSince(cwd, state);
if (commits >= trigger.threshold) {
return {
triggered: true,
reason: `${commits} commits (threshold: ${trigger.threshold})`,
};
}
break;
}
}
}
return { triggered: false };
}
/**
* Run RAG indexing
*/
async function runIndexing(cwd, silent) {
if (!silent) {
console.log(pc.blue("š Running RAG indexing..."));
}
try {
await execa("npx", ["arela", "index"], {
cwd,
stdio: silent ? "pipe" : "inherit",
});
if (!silent) {
console.log(pc.green("ā Indexing complete"));
}
}
catch (error) {
if (!silent) {
console.error(pc.red("ā Indexing failed:"), error);
}
}
}
/**
* Check and potentially trigger auto-indexing
*/
export async function checkAutoIndex(cwd) {
const config = await loadAutoIndexConfig(cwd);
if (!config.enabled) {
return;
}
const state = await loadIndexState(cwd);
const { triggered, reason } = await checkTriggers(cwd, config, state);
if (triggered) {
if (!config.silent) {
console.log(pc.cyan(`\nš Auto-indexing triggered: ${reason}\n`));
}
await runIndexing(cwd, config.silent);
// Update state
const newState = {
last_index_time: new Date().toISOString(),
last_index_commit: await getCurrentCommit(cwd),
lines_since_last_index: 0,
files_since_last_index: 0,
commits_since_last_index: 0,
};
await saveIndexState(cwd, newState);
}
}
/**
* Get files changed in last commit (for incremental indexing)
*/
async function getChangedFiles(cwd) {
try {
const { stdout } = await execa("git", ["diff-tree", "--no-commit-id", "--name-status", "-r", "HEAD"], { cwd });
const added = [];
const modified = [];
const deleted = [];
for (const line of stdout.split("\n").filter(Boolean)) {
const [status, file] = line.split("\t");
if (status === "A")
added.push(file);
else if (status === "M")
modified.push(file);
else if (status === "D")
deleted.push(file);
}
return { added, modified, deleted };
}
catch {
return { added: [], modified: [], deleted: [] };
}
}
/**
* Check if file should be indexed
*/
function shouldIndexFile(file) {
const excludePatterns = [
/node_modules\//,
/dist\//,
/build\//,
/\.git\//,
/\.next\//,
/coverage\//,
/\.min\.js$/,
/\.map$/,
/lock\.(json|yaml)$/,
];
return !excludePatterns.some(pattern => pattern.test(file));
}
/**
* Incrementally update RAG index (only changed files)
*/
export async function incrementalIndex(options) {
const { cwd, model = "nomic-embed-text", ollamaHost = "http://localhost:11434", silent = true } = options;
// Get changed files
const changes = await getChangedFiles(cwd);
const filesToIndex = [
...changes.added.filter(shouldIndexFile),
...changes.modified.filter(shouldIndexFile),
];
const filesToDelete = changes.deleted.filter(shouldIndexFile);
// Skip if no relevant changes
if (filesToIndex.length === 0 && filesToDelete.length === 0) {
return { added: 0, updated: 0, deleted: 0, skipped: true };
}
if (!silent) {
console.log(pc.cyan(`\nš Incremental indexing: ${filesToIndex.length} to update, ${filesToDelete.length} to remove\n`));
}
// Load existing index
const indexPath = path.join(cwd, ".arela", ".rag-index.json");
let index = { version: "1.0", model, timestamp: new Date().toISOString(), embeddings: [] };
if (await fs.pathExists(indexPath)) {
index = await fs.readJson(indexPath);
}
// Remove deleted files
let deletedCount = 0;
if (filesToDelete.length > 0) {
const beforeCount = index.embeddings.length;
index.embeddings = index.embeddings.filter((emb) => !filesToDelete.some(deleted => emb.file === deleted));
deletedCount = beforeCount - index.embeddings.length;
}
// Re-index changed files
let addedCount = 0;
let updatedCount = 0;
if (filesToIndex.length > 0) {
// Check Ollama
const { isOllamaRunning } = await import("../rag/index.js");
if (!(await isOllamaRunning(ollamaHost))) {
if (!silent)
console.log(pc.yellow("ā ļø Ollama not running. Skipping."));
return { added: 0, updated: 0, deleted: deletedCount, skipped: true };
}
for (const file of filesToIndex) {
const filePath = path.join(cwd, file);
if (!(await fs.pathExists(filePath)))
continue;
try {
const hadOld = index.embeddings.some((emb) => emb.file === file);
index.embeddings = index.embeddings.filter((emb) => emb.file !== file);
const content = await fs.readFile(filePath, "utf-8");
const lines = content.split("\n");
const chunkSize = 50;
for (let i = 0; i < lines.length; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize).join("\n").trim();
if (!chunk)
continue;
const response = await fetch(`${ollamaHost}/api/embeddings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt: chunk }),
});
if (!response.ok)
continue;
const data = await response.json();
index.embeddings.push({ file, chunk, embedding: data.embedding });
}
hadOld ? updatedCount++ : addedCount++;
}
catch {
if (!silent)
console.log(pc.yellow(`ā ļø Failed to index ${file}`));
}
}
}
// Save
index.timestamp = new Date().toISOString();
await fs.ensureDir(path.dirname(indexPath));
await fs.writeJson(indexPath, index, { spaces: 2 });
if (!silent) {
console.log(pc.green(`\nā
Index updated: +${addedCount} added, ~${updatedCount} updated, -${deletedCount} deleted\n`));
}
return { added: addedCount, updated: updatedCount, deleted: deletedCount, skipped: false };
}
/**
* Install git post-commit hook for auto-indexing
*/
export async function installAutoIndexHook(cwd) {
const hookPath = path.join(cwd, ".git", "hooks", "post-commit");
// Check if .git exists
if (!(await fs.pathExists(path.join(cwd, ".git")))) {
throw new Error("Not a git repository");
}
const hookContent = `#!/bin/sh
# Arela auto-indexing hook
# Incrementally updates RAG index after each commit
# Run in background to not slow down commits
(arela auto-index --silent &) 2>/dev/null
exit 0
`;
await fs.writeFile(hookPath, hookContent, { mode: 0o755 });
console.log(pc.green("ā
Auto-indexing hook installed!"));
console.log(pc.gray(" RAG index will update automatically after each commit"));
}
/**
* Uninstall post-commit hook
*/
export async function uninstallAutoIndexHook(cwd) {
const hookPath = path.join(cwd, ".git", "hooks", "post-commit");
if (await fs.pathExists(hookPath)) {
const content = await fs.readFile(hookPath, "utf-8");
if (content.includes("Arela auto-indexing")) {
await fs.remove(hookPath);
console.log(pc.green("ā
Auto-indexing hook removed"));
}
else {
console.log(pc.yellow("ā ļø Post-commit hook exists but is not Arela's. Not removing."));
}
}
else {
console.log(pc.gray("No auto-indexing hook found"));
}
}
/**
* Show auto-index status
*/
export async function showAutoIndexStatus(cwd) {
const config = await loadAutoIndexConfig(cwd);
const state = await loadIndexState(cwd);
console.log(pc.bold(pc.cyan("\nš Auto-Index Status\n")));
console.log(pc.bold("Enabled:"), config.enabled ? pc.green("Yes") : pc.red("No"));
console.log(pc.bold("Silent:"), config.silent ? "Yes" : "No");
console.log(pc.bold("Last indexed:"), new Date(state.last_index_time).toLocaleString());
console.log(pc.bold("\nTriggers:"));
for (const trigger of config.triggers) {
let current = 0;
let unit = "";
switch (trigger.type) {
case "lines_added":
current = await countLinesAdded(cwd, state);
unit = "lines";
break;
case "files_added":
current = await countFilesAdded(cwd, state);
unit = "files";
break;
case "time_elapsed":
current = Math.floor((Date.now() - new Date(state.last_index_time).getTime()) / 60000);
unit = "minutes";
break;
case "commits":
current = await countCommitsSince(cwd, state);
unit = "commits";
break;
}
const percent = (current / trigger.threshold) * 100;
const color = percent >= 100 ? pc.red : percent >= 80 ? pc.yellow : pc.green;
console.log(` ${trigger.type}: ${color(`${current}/${trigger.threshold} ${unit}`)} (${percent.toFixed(0)}%)`);
}
console.log("");
}
//# sourceMappingURL=auto-index.js.map