@ooples/token-optimizer-mcp
Version:
Intelligent context window optimization for Claude Code - store content externally via caching and compression, freeing up your context window for what matters
450 lines (449 loc) • 16.7 kB
JavaScript
import Database from 'better-sqlite3';
import { LRUCache } from 'lru-cache';
import path from 'path';
import fs from 'fs';
import os from 'os';
export class CacheEngine {
db;
memoryCache;
dbPath;
stats = {
hits: 0,
misses: 0,
semanticHits: 0, // Track semantic cache hits separately
};
// Semantic caching components (optional)
embeddingGenerator;
vectorStore;
semanticConfig;
constructor(dbPath, maxMemoryItems = 1000, embeddingGenerator, vectorStore, semanticConfig) {
// Use user-provided path, environment variable, or default to ~/.token-optimizer-cache
const defaultCacheDir = process.env.TOKEN_OPTIMIZER_CACHE_DIR ||
path.join(os.homedir(), '.token-optimizer-cache');
const cacheDir = dbPath ? path.dirname(dbPath) : defaultCacheDir;
// Ensure cache directory exists
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const finalDbPath = dbPath || path.join(cacheDir, 'cache.db');
// Retry logic with up to 3 attempts
let lastError = null;
const maxAttempts = 3;
let dbInitialized = false;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
// First attempt: use requested path
// Second attempt: try cleaning up corrupted files and retry
// PHASE 1 FIX: Removed tmpdir fallback - was causing 0% cache hit rate
// Third attempt: fail loudly instead of falling back to ephemeral temp location
const dbPathToUse = finalDbPath;
// If this is attempt 2, try to clean up corrupted files
if (attempt === 2 && fs.existsSync(finalDbPath)) {
try {
fs.unlinkSync(finalDbPath);
// Also remove WAL files
const walPath = `${finalDbPath}-wal`;
const shmPath = `${finalDbPath}-shm`;
if (fs.existsSync(walPath))
fs.unlinkSync(walPath);
if (fs.existsSync(shmPath))
fs.unlinkSync(shmPath);
}
catch {
// If we can't clean up, attempt 3 will fail with an error (no tmpdir fallback)
}
}
this.db = new Database(dbPathToUse);
this.db.pragma('journal_mode = WAL');
// Create cache table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
compressed_size INTEGER NOT NULL,
original_size INTEGER NOT NULL,
hit_count INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
last_accessed_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_last_accessed ON cache(last_accessed_at);
CREATE INDEX IF NOT EXISTS idx_hit_count ON cache(hit_count);
`);
// Success! Store the path we used
this.dbPath = dbPathToUse;
dbInitialized = true;
break;
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Try to close the database if it was partially opened
try {
if (this.db) {
this.db.close();
}
}
catch {
// Ignore close errors
}
if (attempt < maxAttempts) {
// Log warning and try next attempt
console.warn(`Cache database initialization attempt ${attempt}/${maxAttempts} failed:`, error);
console.warn(`Retrying... (attempt ${attempt + 1}/${maxAttempts})`);
}
}
}
// If all attempts failed, throw a comprehensive error
if (!dbInitialized) {
throw new Error(`CRITICAL: Failed to initialize persistent cache database after ${maxAttempts} attempts. ` +
`Last error: ${lastError?.message || 'Unknown error'}. ` +
`Attempted path: ${finalDbPath}. ` +
`PHASE 1 FIX: Removed tmpdir fallback to prevent 0% cache hit rate. ` +
`Action required: Check disk space, file permissions, and ensure directory exists. ` +
`Cache WILL NOT persist without fixing this issue.`);
}
// Initialize in-memory LRU cache for frequently accessed items
this.memoryCache = new LRUCache({
max: maxMemoryItems,
ttl: 1000 * 60 * 60, // 1 hour TTL
});
// Initialize semantic caching components (optional)
this.embeddingGenerator = embeddingGenerator;
this.vectorStore = vectorStore;
this.semanticConfig = {
similarityThreshold: semanticConfig?.similarityThreshold ?? 0.85,
topK: semanticConfig?.topK ?? 5,
enabled: semanticConfig?.enabled ??
(embeddingGenerator !== undefined && vectorStore !== undefined),
};
}
/**
* Get a value from cache (synchronous, exact match only)
* For backward compatibility, this method only performs exact key matching
* Use getWithSemantic() for semantic similarity search
*/
get(key) {
const result = this.getExact(key);
if (result === null) {
this.stats.misses++;
}
return result;
}
/**
* Get a value from cache with semantic matching enabled
* First tries exact key match, then semantic similarity if enabled
*/
async getWithSemantic(key) {
// Try exact key match first (fast path)
const exactMatch = this.getExact(key);
if (exactMatch !== null) {
return exactMatch;
}
// If semantic caching is enabled, try similarity search
if (this.semanticConfig.enabled &&
this.embeddingGenerator &&
this.vectorStore) {
try {
const semanticMatch = await this.getSemanticMatch(key);
if (semanticMatch !== null) {
this.stats.semanticHits++;
return semanticMatch;
}
}
catch (error) {
// Log error but don't fail - fall back to cache miss
console.warn('Semantic cache lookup failed:', error);
}
}
this.stats.misses++;
return null;
}
/**
* Get a value from cache using exact key match (synchronous)
*/
getExact(key) {
// Check memory cache first
const memValue = this.memoryCache.get(key);
if (memValue !== undefined) {
this.stats.hits++;
this.updateHitCount(key);
return memValue.content;
}
// Check SQLite cache
const stmt = this.db.prepare(`
SELECT value, compressed_size FROM cache WHERE key = ?
`);
const row = stmt.get(key);
if (row) {
this.stats.hits++;
// Update hit count and last accessed time
this.updateHitCount(key);
// Add to memory cache for faster access
this.memoryCache.set(key, {
content: row.value,
compressedSize: row.compressed_size,
});
return row.value;
}
return null;
}
/**
* Get a value from cache using semantic similarity matching
* Searches for similar queries and returns the closest match above threshold
*/
async getSemanticMatch(query) {
if (!this.embeddingGenerator || !this.vectorStore) {
return null;
}
// Generate embedding for the query
const queryEmbedding = await this.embeddingGenerator.generateEmbedding(query);
// Search for similar vectors in the store
const results = await this.vectorStore.search(queryEmbedding, this.semanticConfig.topK || 5, this.semanticConfig.similarityThreshold || 0.85);
if (results.length === 0) {
return null;
}
// Get the most similar result
const bestMatch = results[0];
// Retrieve the cached value using the matched key
const cachedValue = this.getExact(bestMatch.id);
if (cachedValue !== null) {
// Log semantic hit for debugging
console.log(`Semantic cache hit: query="${query}" matched key="${bestMatch.id}" (similarity: ${bestMatch.similarity.toFixed(3)})`);
}
return cachedValue;
}
/**
* Get a value from cache with metadata (including compression info)
*/
getWithMetadata(key) {
// Check memory cache first
const memValue = this.memoryCache.get(key);
if (memValue !== undefined) {
this.stats.hits++;
this.updateHitCount(key);
return memValue;
}
// Check SQLite cache
const stmt = this.db.prepare(`
SELECT value, compressed_size FROM cache WHERE key = ?
`);
const row = stmt.get(key);
if (row) {
this.stats.hits++;
// Update hit count and last accessed time
this.updateHitCount(key);
// Add to memory cache for faster access
this.memoryCache.set(key, {
content: row.value,
compressedSize: row.compressed_size,
});
return {
content: row.value,
compressedSize: row.compressed_size,
};
}
this.stats.misses++;
return null;
}
/**
* Set a value in cache (synchronous, without semantic embedding)
* For backward compatibility
*/
set(key, value, originalSize, compressedSize) {
const now = Date.now();
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO cache
(key, value, compressed_size, original_size, hit_count, created_at, last_accessed_at)
VALUES (?, ?, ?, ?,
COALESCE((SELECT hit_count FROM cache WHERE key = ?), 0),
COALESCE((SELECT created_at FROM cache WHERE key = ?), ?),
?)
`);
stmt.run(key, value, compressedSize, originalSize, key, key, now, now);
// Add to memory cache
this.memoryCache.set(key, { content: value, compressedSize });
}
/**
* Set a value in cache with semantic embedding
* Also generates and stores embedding if semantic caching is enabled
*/
async setWithSemantic(key, value, originalSize, compressedSize) {
// First do the regular set
this.set(key, value, originalSize, compressedSize);
// Generate and store embedding if semantic caching is enabled
if (this.semanticConfig.enabled &&
this.embeddingGenerator &&
this.vectorStore) {
try {
const embedding = await this.embeddingGenerator.generateEmbedding(key);
await this.vectorStore.add(key, embedding);
}
catch (error) {
// Log error but don't fail the cache set operation
console.warn('Failed to generate/store embedding for cache key:', error);
}
}
}
/**
* Delete a value from cache (synchronous)
*/
delete(key) {
this.memoryCache.delete(key);
const stmt = this.db.prepare('DELETE FROM cache WHERE key = ?');
const result = stmt.run(key);
return result.changes > 0;
}
/**
* Delete a value from cache with semantic embedding removal
* Also removes the embedding if semantic caching is enabled
*/
async deleteWithSemantic(key) {
const result = this.delete(key);
// Remove embedding if semantic caching is enabled
if (this.semanticConfig.enabled && this.vectorStore) {
try {
await this.vectorStore.delete(key);
}
catch (error) {
console.warn('Failed to delete embedding from vector store:', error);
}
}
return result;
}
/**
* Clear all cache (synchronous)
*/
clear() {
this.memoryCache.clear();
this.db.exec('DELETE FROM cache');
this.stats.hits = 0;
this.stats.misses = 0;
this.stats.semanticHits = 0;
}
/**
* Clear all cache including vector store
* Also clears the vector store if semantic caching is enabled
*/
async clearWithSemantic() {
this.clear();
// Clear vector store if semantic caching is enabled
if (this.semanticConfig.enabled && this.vectorStore) {
try {
await this.vectorStore.clear();
}
catch (error) {
console.warn('Failed to clear vector store:', error);
}
}
}
/**
* Get cache statistics
*/
getStats() {
const stmt = this.db.prepare(`
SELECT
COUNT(*) as total_entries,
SUM(hit_count) as total_hits,
SUM(compressed_size) as total_compressed,
SUM(original_size) as total_original
FROM cache
`);
const row = stmt.get();
const totalRequests = this.stats.hits + this.stats.misses;
const hitRate = totalRequests > 0 ? this.stats.hits / totalRequests : 0;
const compressionRatio = row.total_original > 0 ? row.total_compressed / row.total_original : 0;
const totalHits = this.stats.hits + this.stats.semanticHits;
const semanticHitRate = totalHits > 0 ? this.stats.semanticHits / totalHits : 0;
return {
totalEntries: row.total_entries,
totalHits: row.total_hits || 0,
totalMisses: this.stats.misses,
hitRate,
totalCompressedSize: row.total_compressed || 0,
totalOriginalSize: row.total_original || 0,
compressionRatio,
semanticHits: this.stats.semanticHits,
semanticHitRate,
};
}
/**
* Evict least recently used entries to stay under size limit
*/
evictLRU(maxSizeBytes) {
// Get keys to keep (most recently used) using a running total
const keysToKeep = this.db
.prepare(`
WITH ranked AS (
SELECT
key,
compressed_size,
SUM(compressed_size) OVER (ORDER BY last_accessed_at DESC, key ASC) as running_total
FROM cache
)
SELECT key FROM ranked
WHERE running_total <= ?
`)
.all(maxSizeBytes);
if (keysToKeep.length === 0) {
// If no keys fit in the limit, keep none and delete all
const result = this.db.prepare('DELETE FROM cache').run();
// Clear memory cache too
this.memoryCache.clear();
return result.changes;
}
// Delete entries not in the keep list
const placeholders = keysToKeep.map(() => '?').join(',');
const stmt = this.db.prepare(`
DELETE FROM cache WHERE key NOT IN (${placeholders})
`);
const result = stmt.run(...keysToKeep.map((k) => k.key));
// Remove deleted entries from memory cache
for (const key of Array.from(this.memoryCache.keys())) {
if (!keysToKeep.some((k) => k.key === key)) {
this.memoryCache.delete(key);
}
}
return result.changes;
}
/**
* Get all cache entries (for debugging/monitoring)
*/
getAllEntries() {
const stmt = this.db.prepare(`
SELECT
key,
value,
compressed_size as compressedSize,
original_size as originalSize,
hit_count as hitCount,
created_at as createdAt,
last_accessed_at as lastAccessedAt
FROM cache
ORDER BY hit_count DESC, last_accessed_at DESC
`);
return stmt.all();
}
/**
* Update hit count and last accessed time
*/
updateHitCount(key) {
const stmt = this.db.prepare(`
UPDATE cache
SET hit_count = hit_count + 1, last_accessed_at = ?
WHERE key = ?
`);
stmt.run(Date.now(), key);
}
/**
* Get the database path currently in use
*/
getDatabasePath() {
return this.dbPath;
}
/**
* Close database connection
*/
close() {
this.db.close();
}
}
//# sourceMappingURL=cache-engine.js.map