src / tools / cacheTools.ts
src / tools / cacheTools.ts
/**
* Improved caching layer.
* - mtime-aware file cache
* - shared TTL helpers
* - explicit invalidation by prefix / file
* - integrates with stateRegistry hit/miss counters
*/
import crypto from "crypto";
import fs from "fs/promises";
import {
incrementCacheHits,
incrementCacheMisses,
addCachedKey,
removeCachedKey,
isCacheValid,
} from "../stateRegistry";
function generateKey(...parts: string[]): string {
return crypto.createHash("sha256").update(parts.join("|")).digest("hex");
}
// ─── RAG cache ───────────────────────────────────────────────────────────────
const ragCache = new Map<string, { result: any; timestamp: number }>();
const RAG_TTL_MS = 60 * 60 * 1000; // 1 hour
export async function cachedRagQuery(
query: string,
workspacePath: string,
actualWork?: () => Promise<any>,
): Promise<any> {
const key = generateKey("rag", query, workspacePath);
if (isCacheValid(key) && ragCache.has(key)) {
incrementCacheHits();
return ragCache.get(key)!.result;
}
const result = actualWork
? await actualWork()
: { chunks: [], query, note: "No RAG worker provided – cache miss placeholder" };
ragCache.set(key, { result, timestamp: Date.now() });
addCachedKey(key, RAG_TTL_MS);
incrementCacheMisses();
return result;
}
// ─── Web fetch cache ─────────────────────────────────────────────────────────
const webFetchCache = new Map<string, { content: string; timestamp: number }>();
const WEB_FETCH_TTL_MS = 30 * 60 * 1000; // 30 min
export async function cachedWebFetch(
url: string,
): Promise<{ content: string; fromCache: boolean }> {
const key = generateKey("web", url);
if (isCacheValid(key) && webFetchCache.has(key)) {
incrementCacheHits();
return { content: webFetchCache.get(key)!.content, fromCache: true };
}
try {
const response = await fetch(url, {
headers: { "User-Agent": "BeledariansLMStudioTools/1.0" },
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const text = await response.text();
webFetchCache.set(key, { content: text, timestamp: Date.now() });
addCachedKey(key, WEB_FETCH_TTL_MS);
incrementCacheMisses();
return { content: text, fromCache: false };
} catch (err) {
incrementCacheMisses();
throw err;
}
}
// ─── File read cache (mtime-aware) ───────────────────────────────────────────
const fileReadCache = new Map<
string,
{ content: string; mtimeMs: number; timestamp: number }
>();
const FILE_READ_TTL_MS = 60 * 60 * 1000; // 1 hour absolute max
export async function cachedFileRead(
filePath: string,
): Promise<{ content: string; fromCache: boolean }> {
const stat = await fs.stat(filePath);
// Key includes mtime so a changed file is automatically a miss
const key = generateKey("file", filePath, String(stat.mtimeMs));
if (isCacheValid(key) && fileReadCache.has(key)) {
const entry = fileReadCache.get(key)!;
// Double-check mtime in case of clock skew / external change
if (entry.mtimeMs === stat.mtimeMs) {
incrementCacheHits();
return { content: entry.content, fromCache: true };
}
// Stale – fall through
fileReadCache.delete(key);
removeCachedKey(key);
}
const content = await fs.readFile(filePath, "utf-8");
fileReadCache.set(key, {
content,
mtimeMs: stat.mtimeMs,
timestamp: Date.now(),
});
addCachedKey(key, FILE_READ_TTL_MS);
incrementCacheMisses();
return { content, fromCache: false };
}
/**
* Invalidate every cache entry that touches a given file path.
* Call this after any write / delete / rename.
*/
export function invalidateFileCache(filePath: string): void {
const keysToDelete: string[] = [];
for (const [key, entry] of fileReadCache.entries()) {
// We can't reverse the hash, so we keep a parallel index if needed.
// For now we clear the whole file cache on any write – still cheap.
keysToDelete.push(key);
}
for (const key of keysToDelete) {
fileReadCache.delete(key);
removeCachedKey(key);
}
// Also clear RAG entries that might have indexed this file
for (const key of ragCache.keys()) {
if (key.includes("rag")) {
// Conservative: leave RAG for now; caller can call clearAllCaches if needed
}
}
}
/**
* Broader invalidation by prefix (e.g. "memory_search:", "kb_get_entries:").
* Used by toolsProvider when memory or knowledge-base data changes.
*/
export function invalidateByPrefix(prefix: string, toolResultCache?: Map<string, any>): void {
if (toolResultCache) {
for (const key of toolResultCache.keys()) {
if (key.startsWith(prefix)) {
toolResultCache.delete(key);
}
}
}
}
export function clearAllCaches(): {
success: boolean;
clearedKeys: number;
} {
const count =
ragCache.size + webFetchCache.size + fileReadCache.size;
ragCache.clear();
webFetchCache.clear();
fileReadCache.clear();
return { success: true, clearedKeys: count };
}
export function getCacheStats(): {
ragSize: number;
webFetchSize: number;
fileReadSize: number;
} {
return {
ragSize: ragCache.size,
webFetchSize: webFetchCache.size,
fileReadSize: fileReadCache.size,
};
}
/**
* Improved caching layer.
* - mtime-aware file cache
* - shared TTL helpers
* - explicit invalidation by prefix / file
* - integrates with stateRegistry hit/miss counters
*/
import crypto from "crypto";
import fs from "fs/promises";
import {
incrementCacheHits,
incrementCacheMisses,
addCachedKey,
removeCachedKey,
isCacheValid,
} from "../stateRegistry";
function generateKey(...parts: string[]): string {
return crypto.createHash("sha256").update(parts.join("|")).digest("hex");
}
// ─── RAG cache ───────────────────────────────────────────────────────────────
const ragCache = new Map<string, { result: any; timestamp: number }>();
const RAG_TTL_MS = 60 * 60 * 1000; // 1 hour
export async function cachedRagQuery(
query: string,
workspacePath: string,
actualWork?: () => Promise<any>,
): Promise<any> {
const key = generateKey("rag", query, workspacePath);
if (isCacheValid(key) && ragCache.has(key)) {
incrementCacheHits();
return ragCache.get(key)!.result;
}
const result = actualWork
? await actualWork()
: { chunks: [], query, note: "No RAG worker provided – cache miss placeholder" };
ragCache.set(key, { result, timestamp: Date.now() });
addCachedKey(key, RAG_TTL_MS);
incrementCacheMisses();
return result;
}
// ─── Web fetch cache ─────────────────────────────────────────────────────────
const webFetchCache = new Map<string, { content: string; timestamp: number }>();
const WEB_FETCH_TTL_MS = 30 * 60 * 1000; // 30 min
export async function cachedWebFetch(
url: string,
): Promise<{ content: string; fromCache: boolean }> {
const key = generateKey("web", url);
if (isCacheValid(key) && webFetchCache.has(key)) {
incrementCacheHits();
return { content: webFetchCache.get(key)!.content, fromCache: true };
}
try {
const response = await fetch(url, {
headers: { "User-Agent": "BeledariansLMStudioTools/1.0" },
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const text = await response.text();
webFetchCache.set(key, { content: text, timestamp: Date.now() });
addCachedKey(key, WEB_FETCH_TTL_MS);
incrementCacheMisses();
return { content: text, fromCache: false };
} catch (err) {
incrementCacheMisses();
throw err;
}
}
// ─── File read cache (mtime-aware) ───────────────────────────────────────────
const fileReadCache = new Map<
string,
{ content: string; mtimeMs: number; timestamp: number }
>();
const FILE_READ_TTL_MS = 60 * 60 * 1000; // 1 hour absolute max
export async function cachedFileRead(
filePath: string,
): Promise<{ content: string; fromCache: boolean }> {
const stat = await fs.stat(filePath);
// Key includes mtime so a changed file is automatically a miss
const key = generateKey("file", filePath, String(stat.mtimeMs));
if (isCacheValid(key) && fileReadCache.has(key)) {
const entry = fileReadCache.get(key)!;
// Double-check mtime in case of clock skew / external change
if (entry.mtimeMs === stat.mtimeMs) {
incrementCacheHits();
return { content: entry.content, fromCache: true };
}
// Stale – fall through
fileReadCache.delete(key);
removeCachedKey(key);
}
const content = await fs.readFile(filePath, "utf-8");
fileReadCache.set(key, {
content,
mtimeMs: stat.mtimeMs,
timestamp: Date.now(),
});
addCachedKey(key, FILE_READ_TTL_MS);
incrementCacheMisses();
return { content, fromCache: false };
}
/**
* Invalidate every cache entry that touches a given file path.
* Call this after any write / delete / rename.
*/
export function invalidateFileCache(filePath: string): void {
const keysToDelete: string[] = [];
for (const [key, entry] of fileReadCache.entries()) {
// We can't reverse the hash, so we keep a parallel index if needed.
// For now we clear the whole file cache on any write – still cheap.
keysToDelete.push(key);
}
for (const key of keysToDelete) {
fileReadCache.delete(key);
removeCachedKey(key);
}
// Also clear RAG entries that might have indexed this file
for (const key of ragCache.keys()) {
if (key.includes("rag")) {
// Conservative: leave RAG for now; caller can call clearAllCaches if needed
}
}
}
/**
* Broader invalidation by prefix (e.g. "memory_search:", "kb_get_entries:").
* Used by toolsProvider when memory or knowledge-base data changes.
*/
export function invalidateByPrefix(prefix: string, toolResultCache?: Map<string, any>): void {
if (toolResultCache) {
for (const key of toolResultCache.keys()) {
if (key.startsWith(prefix)) {
toolResultCache.delete(key);
}
}
}
}
export function clearAllCaches(): {
success: boolean;
clearedKeys: number;
} {
const count =
ragCache.size + webFetchCache.size + fileReadCache.size;
ragCache.clear();
webFetchCache.clear();
fileReadCache.clear();
return { success: true, clearedKeys: count };
}
export function getCacheStats(): {
ragSize: number;
webFetchSize: number;
fileReadSize: number;
} {
return {
ragSize: ragCache.size,
webFetchSize: webFetchCache.size,
fileReadSize: fileReadCache.size,
};
}