SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
5.2 KB · 146 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/repository/search.ts4 * Description: Fuzzy file-name search over the repository map — frecency-boosted ranking for @ mentions and palettes.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { RepositoryMap } from "./map.js";11import type { RecentFilesTracker } from "./recent.js";1213/** A ranked fuzzy-search hit (ARCHITECTURE.md `RepositoryIndex.findFiles`). */14export interface RankedFile {15  path: string;16  score: number;17}1819export interface FileSearchOptions {20  /** Default result cap when the caller passes no limit. Default 20. */21  maxResults?: number;22}2324// Score tiers — kept ≥ FRECENCY_BOOST_MAX apart across match classes so25// frecency reorders results WITHIN a class but never across classes26// (exact > prefix > substring > subsequence, always).27const TIER_EXACT = 1000;28const TIER_BASENAME_PREFIX = 850;29const TIER_PATH_PREFIX = 800;30const TIER_BASENAME_SUBSTRING = 700;31const TIER_PATH_SUBSTRING = 600;32const TIER_SUBSEQUENCE = 300;33const SUBSEQUENCE_QUALITY_MAX = 150;34const SUBSEQUENCE_BASENAME_BONUS = 30;35const FRECENCY_BOOST_MAX = 90;3637/** Characters that start a "word" inside a path for boundary bonuses. */38function isBoundary(previous: string | undefined): boolean {39  return previous === undefined || previous === "/" || previous === "." || previous === "_" || previous === "-";40}4142/**43 * Greedy subsequence match of `query` inside `text`. Returns a quality score44 * in [0, SUBSEQUENCE_QUALITY_MAX] or undefined when `query` is not a45 * subsequence. Consecutive runs and word-boundary starts score higher.46 */47export function subsequenceQuality(text: string, query: string): number | undefined {48  let quality = 0;49  let textIndex = 0;50  let previousMatch = -2;51  for (const ch of query) {52    const found = text.indexOf(ch, textIndex);53    if (found === -1) return undefined;54    if (found === previousMatch + 1) quality += 8;55    if (isBoundary(text[found - 1])) quality += 6;56    previousMatch = found;57    textIndex = found + 1;58  }59  // Denser matches (less spread across the path) are better.60  const spread = previousMatch - (query.length - 1);61  quality += Math.max(0, 20 - Math.floor(spread / 4));62  return Math.min(SUBSEQUENCE_QUALITY_MAX, quality);63}6465function baseNameOf(rel: string): string {66  return rel.slice(rel.lastIndexOf("/") + 1);67}6869/**70 * File-NAME fuzzy search over the repository map, for `@` mentions and the71 * command palette. Content search stays the grep tool's job — this facade72 * deliberately does not duplicate it.73 */74export class FileSearch {75  private readonly map: RepositoryMap;76  private readonly recent: RecentFilesTracker | undefined;77  private readonly maxResults: number;7879  constructor(map: RepositoryMap, recent?: RecentFilesTracker, options: FileSearchOptions = {}) {80    this.map = map;81    this.recent = recent;82    this.maxResults = options.maxResults ?? 20;83  }8485  /** Base match score for one candidate path, before the frecency boost. */86  private matchScore(rel: string, base: string, query: string): number | undefined {87    if (rel === query || base === query) return TIER_EXACT;88    if (base.startsWith(query)) return TIER_BASENAME_PREFIX;89    if (rel.startsWith(query)) return TIER_PATH_PREFIX;90    if (base.includes(query)) return TIER_BASENAME_SUBSTRING;91    if (rel.includes(query)) return TIER_PATH_SUBSTRING;92    const quality = subsequenceQuality(rel, query);93    if (quality === undefined) return undefined;94    const inBasename = subsequenceQuality(base, query) !== undefined;95    return TIER_SUBSEQUENCE + quality + (inBasename ? SUBSEQUENCE_BASENAME_BONUS : 0);96  }9798  private frecencyBoost(path: string): number {99    if (this.recent === undefined) return 0;100    return Math.min(FRECENCY_BOOST_MAX, this.recent.frecency(path) * 5);101  }102103  /**104   * Ranked fuzzy matches for `query`. An empty query returns recently105   * accessed files first, then alphabetical fill up to `limit`.106   */107  async findFiles(query: string, limit?: number): Promise<RankedFile[]> {108    const cap = limit ?? this.maxResults;109    const files = await this.map.files();110    const q = query.trim().toLowerCase();111112    if (q.length === 0) {113      const known = new Set(files.map((f) => f.path));114      const results: RankedFile[] = [];115      const seen = new Set<string>();116      for (const r of this.recent?.recent(cap) ?? []) {117        if (!known.has(r.path) || results.length >= cap) continue;118        results.push({ path: r.path, score: r.score });119        seen.add(r.path);120      }121      const rest = files122        .map((f) => f.path)123        .filter((p) => !seen.has(p))124        .sort((a, b) => a.localeCompare(b));125      for (const path of rest) {126        if (results.length >= cap) break;127        results.push({ path, score: 0 });128      }129      return results;130    }131132    const ranked: RankedFile[] = [];133    for (const file of files) {134      const rel = file.path.toLowerCase();135      const base = baseNameOf(rel);136      const score = this.matchScore(rel, base, q);137      if (score === undefined) continue;138      ranked.push({ path: file.path, score: score + this.frecencyBoost(file.path) });139    }140    ranked.sort(141      (a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path),142    );143    return ranked.slice(0, cap);144  }145}146