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%
4.0 KB · 127 lines typescript
Raw Blame History
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