feat(context): Introduce smart context system: analyzer, lazy loader, cache and README/docs improvements
This commit is contained in:
285
ts/context/context-cache.ts
Normal file
285
ts/context/context-cache.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user