kthulu/codebase/@packages/agent-core/src/context-cache.ts

120 lines
3.1 KiB
TypeScript

import type { ToolResult } from '@kthulu/shared';
interface CachedResult {
result: ToolResult;
timestamp: number;
fileMtime?: number;
}
function stableHash(obj: Record<string, unknown>): string {
const sorted = Object.keys(obj)
.sort()
.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {} as Record<string, unknown>);
return JSON.stringify(sorted);
}
function cacheKey(tool: string, params: Record<string, unknown>): string {
return `${tool}:${stableHash(params)}`;
}
function extractFilePath(params: Record<string, unknown>): string | undefined {
const candidate = params['path'] ?? params['file'] ?? params['filePath'];
return typeof candidate === 'string' ? candidate : undefined;
}
export class ContextCache {
private cache = new Map<string, CachedResult>();
private readonly maxSize: number;
private readonly ttlMs: number;
constructor(maxSize: number = 50, ttlMs: number = 300_000) {
this.maxSize = maxSize;
this.ttlMs = ttlMs;
}
get(tool: string, params: Record<string, unknown>, currentFileMtime?: number): ToolResult | null {
const key = cacheKey(tool, params);
const entry = this.cache.get(key);
if (entry === undefined) {
return null;
}
if (Date.now() - entry.timestamp >= this.ttlMs) {
this.cache.delete(key);
return null;
}
if (entry.fileMtime !== undefined && currentFileMtime !== undefined && entry.fileMtime !== currentFileMtime) {
this.cache.delete(key);
return null;
}
return entry.result;
}
set(
tool: string,
params: Record<string, unknown>,
result: ToolResult,
fileMtime?: number,
): void {
const key = cacheKey(tool, params);
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
this.evictOldest();
}
this.cache.set(key, {
result,
timestamp: Date.now(),
fileMtime,
});
}
invalidateForFile(filePath: string): void {
for (const [key, entry] of this.cache) {
// Reconstruct params is not possible from the key alone, so we check
// if the serialized key contains the filePath string — a safe heuristic
// given that filePath values always appear verbatim in stableHash output.
const colonIdx = key.indexOf(':');
const paramJson = colonIdx !== -1 ? key.slice(colonIdx + 1) : key;
if (paramJson.includes(filePath)) {
this.cache.delete(key);
continue;
}
// Also guard against the entry's stored params via fileMtime presence;
// when filePath appears in params it will always be in the JSON key above.
void entry; // entry retained for future mtime-based invalidation
}
}
clear(): void {
this.cache.clear();
}
get size(): number {
return this.cache.size;
}
private evictOldest(): void {
let oldestKey: string | undefined;
let oldestTimestamp = Infinity;
for (const [key, entry] of this.cache) {
if (entry.timestamp < oldestTimestamp) {
oldestTimestamp = entry.timestamp;
oldestKey = key;
}
}
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
}
export type { CachedResult };