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/recent.ts4 * Description: Recently-accessed-files tracker — bounded LRU with frecency scoring for context and @ mentions.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export interface RecentFilesOptions {11 /** LRU capacity. Default 256. */12 maxEntries?: number;13 /** Access timestamps retained per file (frecency window). Default 16. */14 maxTimestampsPerFile?: number;15 /** Injectable clock (tests). Default `Date.now`. */16 now?: () => number;17}1819export interface RecentFile {20 path: string;21 /** Total accesses recorded (not capped by the timestamp window). */22 count: number;23 lastAccessMs: number;24 /** Frecency score at query time. */25 score: number;26}2728interface AccessEntry {29 count: number;30 lastAccessMs: number;31 timestampsMs: number[];32}3334const HOUR_MS = 3_600_000;35const DAY_MS = 24 * HOUR_MS;36const WEEK_MS = 7 * DAY_MS;3738const DEFAULT_MAX_ENTRIES = 256;39const DEFAULT_MAX_TIMESTAMPS = 16;4041/** Decay weight of a single access by age (Firefox-style frecency buckets). */42function accessWeight(ageMs: number): number {43 if (ageMs <= HOUR_MS) return 8;44 if (ageMs <= 6 * HOUR_MS) return 4;45 if (ageMs <= DAY_MS) return 2;46 if (ageMs <= WEEK_MS) return 1;47 return 0.25;48}4950/**51 * Session-scoped tracker of files the agent (or user, via @ mentions) has52 * touched. Fed by read/edit registrations; consumed by the Context Engine53 * and fuzzy file finding for frecency ranking. Bounded LRU — the least54 * recently accessed path is evicted at capacity.55 */56export class RecentFilesTracker {57 private readonly maxEntries: number;58 private readonly maxTimestamps: number;59 private readonly clock: () => number;60 /** Map iteration order == LRU order (re-inserted on every access). */61 private readonly entries = new Map<string, AccessEntry>();6263 constructor(options: RecentFilesOptions = {}) {64 this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;65 this.maxTimestamps = options.maxTimestampsPerFile ?? DEFAULT_MAX_TIMESTAMPS;66 this.clock = options.now ?? Date.now;67 }6869 size(): number {70 return this.entries.size;71 }7273 has(path: string): boolean {74 return this.entries.has(path);75 }7677 /** Register an access (read/edit/mention). `atMs` defaults to the clock. */78 noteAccess(path: string, atMs?: number): void {79 const at = atMs ?? this.clock();80 const existing = this.entries.get(path);81 if (existing !== undefined) {82 this.entries.delete(path); // re-insert to refresh LRU position83 existing.count += 1;84 existing.lastAccessMs = Math.max(existing.lastAccessMs, at);85 existing.timestampsMs.push(at);86 if (existing.timestampsMs.length > this.maxTimestamps) {87 existing.timestampsMs.splice(0, existing.timestampsMs.length - this.maxTimestamps);88 }89 this.entries.set(path, existing);90 return;91 }92 this.entries.set(path, { count: 1, lastAccessMs: at, timestampsMs: [at] });93 if (this.entries.size > this.maxEntries) {94 const oldest = this.entries.keys().next().value;95 if (oldest !== undefined) this.entries.delete(oldest);96 }97 }9899 /** Frecency score: sum of age-decayed weights over the retained accesses. */100 frecency(path: string, nowMs?: number): number {101 const entry = this.entries.get(path);102 if (entry === undefined) return 0;103 const now = nowMs ?? this.clock();104 let score = 0;105 for (const ts of entry.timestampsMs) {106 score += accessWeight(Math.max(0, now - ts));107 }108 return score;109 }110111 /** Tracked files, highest frecency first. */112 recent(limit = 20, nowMs?: number): RecentFile[] {113 const now = nowMs ?? this.clock();114 const scored: RecentFile[] = [];115 for (const [path, entry] of this.entries) {116 scored.push({117 path,118 count: entry.count,119 lastAccessMs: entry.lastAccessMs,120 score: this.frecency(path, now),121 });122 }123 scored.sort((a, b) => b.score - a.score || b.lastAccessMs - a.lastAccessMs || a.path.localeCompare(b.path));124 return scored.slice(0, limit);125 }126}127