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