/** * KHAELOR * File: src/repository/recent.ts * Description: Recently-accessed-files tracker — bounded LRU with frecency scoring for context and @ mentions. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export interface RecentFilesOptions { /** LRU capacity. Default 256. */ maxEntries?: number; /** Access timestamps retained per file (frecency window). Default 16. */ maxTimestampsPerFile?: number; /** Injectable clock (tests). Default `Date.now`. */ now?: () => number; } export interface RecentFile { path: string; /** Total accesses recorded (not capped by the timestamp window). */ count: number; lastAccessMs: number; /** Frecency score at query time. */ score: number; } interface AccessEntry { count: number; lastAccessMs: number; timestampsMs: number[]; } const HOUR_MS = 3_600_000; const DAY_MS = 24 * HOUR_MS; const WEEK_MS = 7 * DAY_MS; const DEFAULT_MAX_ENTRIES = 256; const DEFAULT_MAX_TIMESTAMPS = 16; /** Decay weight of a single access by age (Firefox-style frecency buckets). */ function accessWeight(ageMs: number): number { if (ageMs <= HOUR_MS) return 8; if (ageMs <= 6 * HOUR_MS) return 4; if (ageMs <= DAY_MS) return 2; if (ageMs <= WEEK_MS) return 1; return 0.25; } /** * Session-scoped tracker of files the agent (or user, via @ mentions) has * touched. Fed by read/edit registrations; consumed by the Context Engine * and fuzzy file finding for frecency ranking. Bounded LRU — the least * recently accessed path is evicted at capacity. */ export class RecentFilesTracker { private readonly maxEntries: number; private readonly maxTimestamps: number; private readonly clock: () => number; /** Map iteration order == LRU order (re-inserted on every access). */ private readonly entries = new Map(); constructor(options: RecentFilesOptions = {}) { this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; this.maxTimestamps = options.maxTimestampsPerFile ?? DEFAULT_MAX_TIMESTAMPS; this.clock = options.now ?? Date.now; } size(): number { return this.entries.size; } has(path: string): boolean { return this.entries.has(path); } /** Register an access (read/edit/mention). `atMs` defaults to the clock. */ noteAccess(path: string, atMs?: number): void { const at = atMs ?? this.clock(); const existing = this.entries.get(path); if (existing !== undefined) { this.entries.delete(path); // re-insert to refresh LRU position existing.count += 1; existing.lastAccessMs = Math.max(existing.lastAccessMs, at); existing.timestampsMs.push(at); if (existing.timestampsMs.length > this.maxTimestamps) { existing.timestampsMs.splice(0, existing.timestampsMs.length - this.maxTimestamps); } this.entries.set(path, existing); return; } this.entries.set(path, { count: 1, lastAccessMs: at, timestampsMs: [at] }); if (this.entries.size > this.maxEntries) { const oldest = this.entries.keys().next().value; if (oldest !== undefined) this.entries.delete(oldest); } } /** Frecency score: sum of age-decayed weights over the retained accesses. */ frecency(path: string, nowMs?: number): number { const entry = this.entries.get(path); if (entry === undefined) return 0; const now = nowMs ?? this.clock(); let score = 0; for (const ts of entry.timestampsMs) { score += accessWeight(Math.max(0, now - ts)); } return score; } /** Tracked files, highest frecency first. */ recent(limit = 20, nowMs?: number): RecentFile[] { const now = nowMs ?? this.clock(); const scored: RecentFile[] = []; for (const [path, entry] of this.entries) { scored.push({ path, count: entry.count, lastAccessMs: entry.lastAccessMs, score: this.frecency(path, now), }); } scored.sort((a, b) => b.score - a.score || b.lastAccessMs - a.lastAccessMs || a.path.localeCompare(b.path)); return scored.slice(0, limit); } }