286 lines
7.1 KiB
TypeScript
286 lines
7.1 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import * as fs from 'fs';
|
|
import type { ICacheEntry, ICacheConfig } from './types.js';
|
|
|
|
/**
|
|
* ContextCache provides persistent caching of file contents and token counts
|
|
* with automatic invalidation on file changes
|
|
*/
|
|
export class ContextCache {
|
|
private cacheDir: string;
|
|
private cache: Map<string, ICacheEntry> = new Map();
|
|
private config: Required<ICacheConfig>;
|
|
private cacheIndexPath: string;
|
|
|
|
/**
|
|
* Creates a new ContextCache
|
|
* @param projectRoot - Root directory of the project
|
|
* @param config - Cache configuration
|
|
*/
|
|
constructor(projectRoot: string, config: Partial<ICacheConfig> = {}) {
|
|
this.config = {
|
|
enabled: config.enabled ?? true,
|
|
ttl: config.ttl ?? 3600, // 1 hour default
|
|
maxSize: config.maxSize ?? 100, // 100MB default
|
|
directory: config.directory ?? plugins.path.join(projectRoot, '.nogit', 'context-cache'),
|
|
};
|
|
|
|
this.cacheDir = this.config.directory;
|
|
this.cacheIndexPath = plugins.path.join(this.cacheDir, 'index.json');
|
|
}
|
|
|
|
/**
|
|
* Initializes the cache by loading from disk
|
|
*/
|
|
public async init(): Promise<void> {
|
|
if (!this.config.enabled) {
|
|
return;
|
|
}
|
|
|
|
// Ensure cache directory exists
|
|
await plugins.smartfile.fs.ensureDir(this.cacheDir);
|
|
|
|
// Load cache index if it exists
|
|
try {
|
|
const indexExists = await plugins.smartfile.fs.fileExists(this.cacheIndexPath);
|
|
if (indexExists) {
|
|
const indexContent = await plugins.smartfile.fs.toStringSync(this.cacheIndexPath);
|
|
const indexData = JSON.parse(indexContent) as ICacheEntry[];
|
|
if (Array.isArray(indexData)) {
|
|
for (const entry of indexData) {
|
|
this.cache.set(entry.path, entry);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load cache index:', error.message);
|
|
// Start with empty cache if loading fails
|
|
}
|
|
|
|
// Clean up expired and invalid entries
|
|
await this.cleanup();
|
|
}
|
|
|
|
/**
|
|
* Gets a cached entry if it's still valid
|
|
* @param filePath - Absolute path to the file
|
|
* @returns Cache entry if valid, null otherwise
|
|
*/
|
|
public async get(filePath: string): Promise<ICacheEntry | null> {
|
|
if (!this.config.enabled) {
|
|
return null;
|
|
}
|
|
|
|
const entry = this.cache.get(filePath);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
// Check if entry is expired
|
|
const now = Date.now();
|
|
if (now - entry.cachedAt > this.config.ttl * 1000) {
|
|
this.cache.delete(filePath);
|
|
return null;
|
|
}
|
|
|
|
// Check if file has been modified
|
|
try {
|
|
const stats = await fs.promises.stat(filePath);
|
|
const currentMtime = Math.floor(stats.mtimeMs);
|
|
|
|
if (currentMtime !== entry.mtime) {
|
|
// File has changed, invalidate cache
|
|
this.cache.delete(filePath);
|
|
return null;
|
|
}
|
|
|
|
return entry;
|
|
} catch (error) {
|
|
// File doesn't exist anymore
|
|
this.cache.delete(filePath);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stores a cache entry
|
|
* @param entry - Cache entry to store
|
|
*/
|
|
public async set(entry: ICacheEntry): Promise<void> {
|
|
if (!this.config.enabled) {
|
|
return;
|
|
}
|
|
|
|
this.cache.set(entry.path, entry);
|
|
|
|
// Check cache size and evict old entries if needed
|
|
await this.enforceMaxSize();
|
|
|
|
// Persist to disk (async, don't await)
|
|
this.persist().catch((error) => {
|
|
console.warn('Failed to persist cache:', error.message);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stores multiple cache entries
|
|
* @param entries - Array of cache entries
|
|
*/
|
|
public async setMany(entries: ICacheEntry[]): Promise<void> {
|
|
if (!this.config.enabled) {
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
this.cache.set(entry.path, entry);
|
|
}
|
|
|
|
await this.enforceMaxSize();
|
|
await this.persist();
|
|
}
|
|
|
|
/**
|
|
* Checks if a file is cached and valid
|
|
* @param filePath - Absolute path to the file
|
|
* @returns True if cached and valid
|
|
*/
|
|
public async has(filePath: string): Promise<boolean> {
|
|
const entry = await this.get(filePath);
|
|
return entry !== null;
|
|
}
|
|
|
|
/**
|
|
* Gets cache statistics
|
|
*/
|
|
public getStats(): {
|
|
entries: number;
|
|
totalSize: number;
|
|
oldestEntry: number | null;
|
|
newestEntry: number | null;
|
|
} {
|
|
let totalSize = 0;
|
|
let oldestEntry: number | null = null;
|
|
let newestEntry: number | null = null;
|
|
|
|
for (const entry of this.cache.values()) {
|
|
totalSize += entry.contents.length;
|
|
|
|
if (oldestEntry === null || entry.cachedAt < oldestEntry) {
|
|
oldestEntry = entry.cachedAt;
|
|
}
|
|
|
|
if (newestEntry === null || entry.cachedAt > newestEntry) {
|
|
newestEntry = entry.cachedAt;
|
|
}
|
|
}
|
|
|
|
return {
|
|
entries: this.cache.size,
|
|
totalSize,
|
|
oldestEntry,
|
|
newestEntry,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clears all cache entries
|
|
*/
|
|
public async clear(): Promise<void> {
|
|
this.cache.clear();
|
|
await this.persist();
|
|
}
|
|
|
|
/**
|
|
* Clears specific cache entries
|
|
* @param filePaths - Array of file paths to clear
|
|
*/
|
|
public async clearPaths(filePaths: string[]): Promise<void> {
|
|
for (const path of filePaths) {
|
|
this.cache.delete(path);
|
|
}
|
|
await this.persist();
|
|
}
|
|
|
|
/**
|
|
* Cleans up expired and invalid cache entries
|
|
*/
|
|
private async cleanup(): Promise<void> {
|
|
const now = Date.now();
|
|
const toDelete: string[] = [];
|
|
|
|
for (const [path, entry] of this.cache.entries()) {
|
|
// Check expiration
|
|
if (now - entry.cachedAt > this.config.ttl * 1000) {
|
|
toDelete.push(path);
|
|
continue;
|
|
}
|
|
|
|
// Check if file still exists and hasn't changed
|
|
try {
|
|
const stats = await fs.promises.stat(path);
|
|
const currentMtime = Math.floor(stats.mtimeMs);
|
|
|
|
if (currentMtime !== entry.mtime) {
|
|
toDelete.push(path);
|
|
}
|
|
} catch (error) {
|
|
// File doesn't exist
|
|
toDelete.push(path);
|
|
}
|
|
}
|
|
|
|
for (const path of toDelete) {
|
|
this.cache.delete(path);
|
|
}
|
|
|
|
if (toDelete.length > 0) {
|
|
await this.persist();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enforces maximum cache size by evicting oldest entries
|
|
*/
|
|
private async enforceMaxSize(): Promise<void> {
|
|
const stats = this.getStats();
|
|
const maxSizeBytes = this.config.maxSize * 1024 * 1024; // Convert MB to bytes
|
|
|
|
if (stats.totalSize <= maxSizeBytes) {
|
|
return;
|
|
}
|
|
|
|
// Sort entries by age (oldest first)
|
|
const entries = Array.from(this.cache.entries()).sort(
|
|
(a, b) => a[1].cachedAt - b[1].cachedAt
|
|
);
|
|
|
|
// Remove oldest entries until we're under the limit
|
|
let currentSize = stats.totalSize;
|
|
for (const [path, entry] of entries) {
|
|
if (currentSize <= maxSizeBytes) {
|
|
break;
|
|
}
|
|
|
|
currentSize -= entry.contents.length;
|
|
this.cache.delete(path);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persists cache index to disk
|
|
*/
|
|
private async persist(): Promise<void> {
|
|
if (!this.config.enabled) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const entries = Array.from(this.cache.values());
|
|
const content = JSON.stringify(entries, null, 2);
|
|
await plugins.smartfile.memory.toFs(content, this.cacheIndexPath);
|
|
} catch (error) {
|
|
console.warn('Failed to persist cache index:', error.message);
|
|
}
|
|
}
|
|
}
|