SPB Git

Repository Public MIT Selected

index.mjs 5.4 KB 119 lines javascript

1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/ai/index.mjs8 *  Purpose : Repository text index for grounded Q&A — per-blob chunk cache (incremental across commits), BM25 retrieval, no external calls9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { mapLimit } from '../lib/util.mjs';1415const MAX_FILE_BYTES = 96 * 1024;16const MAX_FILES = 1500;17const CHUNK_LINES = 60;18const CHUNK_OVERLAP = 10;19const TEXT_EXT = /\.(m?js|cjs|ts|tsx|jsx|py|r|jl|go|rs|c|h|cpp|hpp|java|kt|swift|rb|php|sh|zsh|sql|json|ya?ml|toml|ini|cfg|md|markdown|rst|txt|tex|bib|csv|html|css|scss|njk|ejs|vue|svelte|dockerfile|mk|cmake|proto|graphql|env\.example)$/i;20const NAME_OK = /^(Dockerfile|Makefile|README|LICENSE|CMakeLists\.txt|\.gitignore|\.env\.example)$/i;21const SKIP_RE = /(^|\/)(node_modules|vendor|dist|build|\.git|coverage|__pycache__|\.venv)\//;2223/** Lower-case word tokens (≥ 2 chars), camelCase split. */24export function tokenize(text) {25  return String(text)26    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')27    .toLowerCase()28    .split(/[^a-z0-9_]+/)29    .filter((t) => t.length >= 2 && t.length <= 40);30}3132/** Split file content into overlapping line chunks. */33export function chunkFile(path, content) {34  const lines = content.split('\n');35  const chunks = [];36  for (let start = 0; start < lines.length; start += CHUNK_LINES - CHUNK_OVERLAP) {37    const end = Math.min(lines.length, start + CHUNK_LINES);38    const text = lines.slice(start, end).join('\n');39    if (text.trim()) chunks.push({ path, start: start + 1, end, text });40    if (end >= lines.length) break;41  }42  return chunks;43}4445/** @returns {boolean} whether the path looks like indexable text */46export function indexable(path, size) {47  if (size > MAX_FILE_BYTES || SKIP_RE.test(path)) return false;48  const base = path.slice(path.lastIndexOf('/') + 1);49  return TEXT_EXT.test(base) || NAME_OK.test(base);50}5152/**53 * Build (or load) the chunk index of a repo at a commit. Chunks are cached per54 * blob sha under `global/ai-blobs/<sha>.json`, so a new commit only re-reads55 * files whose content changed.56 * @param {{repos: object, cache: object}} ctx57 * @returns {Promise<{sha: string, chunks: Array<{path: string, start: number, end: number, text: string, tf: Record<string, number>, len: number}>, df: Record<string, number>, avgLen: number, files: number, builtAt: string}>}58 */59export async function buildRepoIndex(ctx, name, sha) {60  const cachePath = ctx.cache.repoPath(name, sha, 'ai-index-v1.json');61  const cached = ctx.cache.getJSON(cachePath);62  if (cached) return cached;63  const listing = await ctx.repos.tryGit(name, ['ls-tree', '-r', '-l', '-z', sha]);64  const entries = (listing ?? '').split('\0').filter(Boolean).map((rec) => {65    const tab = rec.indexOf('\t');66    const [, type, blobSha, sizeRaw] = rec.slice(0, tab).split(/\s+/);67    return { type, blobSha, size: sizeRaw === '-' ? 0 : Number(sizeRaw), path: rec.slice(tab + 1) };68  }).filter((e) => e.type === 'blob' && indexable(e.path, e.size)).slice(0, MAX_FILES);6970  const perFile = await mapLimit(entries, 4, async (e) => {71    const blobCache = ctx.cache.path('global', 'ai-blobs', `${e.blobSha}.json`);72    const hit = ctx.cache.getJSON(blobCache);73    if (hit) return hit.map((c) => ({ ...c, path: e.path }));74    const blob = await ctx.repos.blob(name, sha, e.path, { maxBytes: MAX_FILE_BYTES });75    if (!blob?.content || blob.binary) return [];76    const chunks = chunkFile(e.path, blob.content.toString('utf8')).map((c) => {77      const tf = {};78      const toks = tokenize(`${c.path} ${c.text}`);79      for (const t of toks) tf[t] = (tf[t] ?? 0) + 1;80      return { ...c, tf, len: toks.length };81    });82    ctx.cache.setJSON(blobCache, chunks.map(({ path: _p, ...rest }) => rest));83    return chunks;84  });85  const chunks = perFile.flat();86  const df = {};87  for (const c of chunks) for (const t of Object.keys(c.tf)) df[t] = (df[t] ?? 0) + 1;88  const avgLen = chunks.length ? chunks.reduce((n, c) => n + c.len, 0) / chunks.length : 1;89  const index = { sha, chunks, df, avgLen, files: entries.length, builtAt: new Date().toISOString() };90  ctx.cache.setJSON(cachePath, index);91  return index;92}9394/**95 * BM25 retrieval over the chunk index.96 * @returns {Array<{path: string, start: number, end: number, text: string, score: number}>}97 */98export function retrieve(index, query, k = 8) {99  const terms = [...new Set(tokenize(query))];100  if (!terms.length || !index.chunks.length) return [];101  const N = index.chunks.length;102  const k1 = 1.4;103  const b = 0.75;104  const scored = [];105  for (const c of index.chunks) {106    let score = 0;107    for (const t of terms) {108      const f = c.tf[t];109      if (!f) continue;110      const n = index.df[t] ?? 0;111      const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));112      score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + (b * c.len) / index.avgLen)));113    }114    if (score > 0) scored.push({ path: c.path, start: c.start, end: c.end, text: c.text, score });115  }116  scored.sort((a, b2) => b2.score - a.score);117  return scored.slice(0, k);118}119
Files main
Menu

Theme