120 lines
3.1 KiB
TypeScript
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 };
|