UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

579 lines (482 loc) 16.7 kB
/** * RVF Embedding Cache - Pure TypeScript Binary File Cache * * Replaces the sql.js-based PersistentEmbeddingCache with a lightweight * pure-TS binary file format. No native dependencies required. * * Features: * - Map-based in-memory cache with periodic flush to binary file * - LRU eviction tracked via access timestamps * - TTL support for cache entries * - Deterministic FNV-1a text hashing for keys * - Binary format: RVEC magic + entry records * * Binary entry format: * [4-byte key-hash][4-byte dims][dims*4 bytes float32][8-byte timestamp][8-byte access-count] * * @module @claude-flow/embeddings */ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs'; import { dirname } from 'path'; /** Validate a file path is safe */ function validatePath(p: string): void { if (p.includes('\0')) throw new Error('Cache path contains null bytes'); } // ============================================================================ // Configuration // ============================================================================ /** * Configuration for RVF embedding cache */ export interface RvfEmbeddingCacheConfig { /** Path to the binary cache file */ cachePath: string; /** Maximum number of entries (default: 10000) */ maxSize?: number; /** TTL in milliseconds (default: 7 days) */ ttlMs?: number; /** Embedding dimensions (used for validation) */ dimensions?: number; } // ============================================================================ // Cache Entry // ============================================================================ interface CacheEntry { embedding: Float32Array; createdAt: number; accessedAt: number; accessCount: number; } // ============================================================================ // Constants // ============================================================================ /** Binary file magic bytes: "RVEC" */ const MAGIC = new Uint8Array([0x52, 0x56, 0x45, 0x43]); // R V E C /** Default TTL: 7 days in milliseconds */ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; /** Default max entries */ const DEFAULT_MAX_SIZE = 10000; /** Auto-flush interval: 30 seconds */ const AUTO_FLUSH_INTERVAL_MS = 30000; /** FNV-1a offset basis (32-bit) */ const FNV_OFFSET_BASIS = 0x811c9dc5; /** FNV-1a prime (32-bit) */ const FNV_PRIME = 0x01000193; // ============================================================================ // RVF Embedding Cache // ============================================================================ /** * Pure-TS binary file embedding cache with LRU eviction and TTL support. * * Stores embeddings as raw Float32Array bytes keyed by FNV-1a text hashes. * Uses an in-memory Map with periodic flush to a compact binary file. */ export class RvfEmbeddingCache { private readonly cachePath: string; private readonly maxSize: number; private readonly ttlMs: number; private readonly dimensions: number | undefined; private entries: Map<number, CacheEntry> = new Map(); private textToHash: Map<string, number> = new Map(); private dirty = false; private flushTimer: ReturnType<typeof setInterval> | null = null; private initialized = false; constructor(config: RvfEmbeddingCacheConfig) { this.cachePath = config.cachePath; this.maxSize = config.maxSize ?? DEFAULT_MAX_SIZE; this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS; this.dimensions = config.dimensions; validatePath(this.cachePath); } // -------------------------------------------------------------------------- // Initialization // -------------------------------------------------------------------------- /** * Lazily initialize the cache: load from disk if file exists, start auto-flush. */ private async ensureInitialized(): Promise<void> { if (this.initialized) return; // Ensure parent directory exists const dir = dirname(this.cachePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // Load existing cache file if (existsSync(this.cachePath)) { this.loadFromFile(); } // Clean expired entries on startup this.cleanExpired(); // Start auto-flush timer this.startAutoFlush(); this.initialized = true; } // -------------------------------------------------------------------------- // Public API (matches PersistentEmbeddingCache) // -------------------------------------------------------------------------- /** * Get an embedding from the cache by text. * Returns null if not found or expired. */ async get(text: string): Promise<Float32Array | null> { await this.ensureInitialized(); const hash = this.hashText(text); const entry = this.entries.get(hash); if (!entry) { return null; } // Check TTL const now = Date.now(); if (now - entry.createdAt > this.ttlMs) { this.entries.delete(hash); this.textToHash.delete(text); this.dirty = true; return null; } // Update LRU tracking entry.accessedAt = now; entry.accessCount++; this.dirty = true; return entry.embedding; } /** * Store an embedding in the cache. * Triggers LRU eviction if the cache exceeds maxSize. */ async set(text: string, embedding: Float32Array): Promise<void> { await this.ensureInitialized(); // Validate dimensions if configured if (this.dimensions !== undefined && embedding.length !== this.dimensions) { throw new Error( `Dimension mismatch: expected ${this.dimensions}, got ${embedding.length}` ); } const hash = this.hashText(text); const now = Date.now(); // Copy the embedding to avoid external mutation const copy = new Float32Array(embedding.length); copy.set(embedding); const existing = this.entries.get(hash); if (existing) { existing.embedding = copy; existing.accessedAt = now; existing.accessCount++; } else { this.entries.set(hash, { embedding: copy, createdAt: now, accessedAt: now, accessCount: 1, }); this.textToHash.set(text, hash); } this.dirty = true; // Evict if over capacity this.evictIfNeeded(); } /** * Check whether the cache contains an embedding for the given text. */ async has(text: string): Promise<boolean> { await this.ensureInitialized(); const hash = this.hashText(text); const entry = this.entries.get(hash); if (!entry) return false; // Check TTL if (Date.now() - entry.createdAt > this.ttlMs) { this.entries.delete(hash); this.textToHash.delete(text); this.dirty = true; return false; } return true; } /** * Delete a specific entry from the cache. * Returns true if the entry existed and was deleted. */ async delete(text: string): Promise<boolean> { await this.ensureInitialized(); const hash = this.hashText(text); const existed = this.entries.delete(hash); this.textToHash.delete(text); if (existed) { this.dirty = true; } return existed; } /** * Clear all entries from the cache and persist the empty state. */ async clear(): Promise<void> { await this.ensureInitialized(); this.entries.clear(); this.textToHash.clear(); this.dirty = true; this.flushToFile(); } /** * Return the number of entries currently in the cache. */ async size(): Promise<number> { await this.ensureInitialized(); return this.entries.size; } /** * Flush pending changes to disk and stop the auto-flush timer. */ async close(): Promise<void> { this.stopAutoFlush(); if (this.dirty) { this.flushToFile(); } this.entries.clear(); this.textToHash.clear(); this.initialized = false; } // -------------------------------------------------------------------------- // Hashing // -------------------------------------------------------------------------- /** * FNV-1a 32-bit hash of the input text. * Deterministic: same input always produces the same hash. */ private hashText(text: string): number { let hash = FNV_OFFSET_BASIS; for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, FNV_PRIME) >>> 0; } return hash; } // -------------------------------------------------------------------------- // LRU Eviction // -------------------------------------------------------------------------- /** * If the cache exceeds maxSize, evict the least-recently-accessed entries * until we are back at 90% capacity. */ private evictIfNeeded(): void { if (this.entries.size <= this.maxSize) return; const targetSize = Math.floor(this.maxSize * 0.9); const toEvict = this.entries.size - targetSize; // Sort entries by accessedAt ascending (oldest first) const sorted = [...this.entries.entries()].sort( (a, b) => a[1].accessedAt - b[1].accessedAt ); // Build reverse map for O(1) lookup (hash → text) const hashToText = new Map<number, string>(); for (const [text, hash] of this.textToHash) { hashToText.set(hash, text); } for (let i = 0; i < toEvict && i < sorted.length; i++) { const [hash] = sorted[i]; this.entries.delete(hash); const text = hashToText.get(hash); if (text !== undefined) this.textToHash.delete(text); } this.dirty = true; } // -------------------------------------------------------------------------- // TTL Cleanup // -------------------------------------------------------------------------- /** * Remove all entries whose createdAt timestamp is older than TTL. */ private cleanExpired(): void { const cutoff = Date.now() - this.ttlMs; const toDelete: number[] = []; for (const [hash, entry] of this.entries) { if (entry.createdAt < cutoff) { toDelete.push(hash); } } // Build reverse map for O(1) lookup const hashToText = new Map<number, string>(); for (const [text, h] of this.textToHash) { hashToText.set(h, text); } for (const hash of toDelete) { this.entries.delete(hash); const text = hashToText.get(hash); if (text !== undefined) this.textToHash.delete(text); } if (toDelete.length > 0) { this.dirty = true; } } // -------------------------------------------------------------------------- // Auto-Flush Timer // -------------------------------------------------------------------------- private startAutoFlush(): void { if (this.flushTimer) return; this.flushTimer = setInterval(() => { if (this.dirty) { this.flushToFile(); } }, AUTO_FLUSH_INTERVAL_MS); if (this.flushTimer.unref) this.flushTimer.unref(); } private stopAutoFlush(): void { if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } } // -------------------------------------------------------------------------- // Binary Serialization // -------------------------------------------------------------------------- /** * Write all entries to the binary cache file. * * Format: * [4-byte magic "RVEC"] * For each entry: * [4-byte key-hash (uint32)] * [4-byte dims (uint32)] * [dims * 4 bytes float32 data] * [8-byte createdAt (float64, used as timestamp)] * [8-byte accessCount (float64)] */ private flushToFile(): void { try { // Version 2 format: magic(4) + version(4) + entries... // Entry: hash(4) + dims(4) + embedding(dims*4) + createdAt(8) + accessedAt(8) + accessCount(8) let totalSize = MAGIC.length + 4; // magic + version uint32 for (const [, entry] of this.entries) { totalSize += 4 + 4 + entry.embedding.length * 4 + 8 + 8 + 8; } const buffer = new ArrayBuffer(totalSize); const view = new DataView(buffer); const bytes = new Uint8Array(buffer); let offset = 0; // Write magic bytes.set(MAGIC, 0); offset += MAGIC.length; // Write format version view.setUint32(offset, 2, true); offset += 4; // Write entries for (const [hash, entry] of this.entries) { // Key hash (uint32, little-endian) view.setUint32(offset, hash, true); offset += 4; // Dimensions (uint32, little-endian) view.setUint32(offset, entry.embedding.length, true); offset += 4; // Embedding data (float32 array, little-endian) for (let i = 0; i < entry.embedding.length; i++) { view.setFloat32(offset, entry.embedding[i], true); offset += 4; } // createdAt as float64 (little-endian) - v2: separate from accessedAt view.setFloat64(offset, entry.createdAt, true); offset += 8; // accessedAt as float64 (little-endian) view.setFloat64(offset, entry.accessedAt, true); offset += 8; // Access count as float64 (little-endian) view.setFloat64(offset, entry.accessCount, true); offset += 8; } // Ensure parent directory exists const dir = dirname(this.cachePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const tmpSuffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); const tmpPath = this.cachePath + '.tmp.' + tmpSuffix; writeFileSync(tmpPath, Buffer.from(buffer)); renameSync(tmpPath, this.cachePath); this.dirty = false; } catch (error) { console.error( '[rvf-embedding-cache] Flush error:', error instanceof Error ? error.message : error ); } } /** * Load entries from the binary cache file. */ private loadFromFile(): void { try { const fileBuffer = readFileSync(this.cachePath); if (fileBuffer.length < MAGIC.length) return; const buffer = fileBuffer.buffer.slice( fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength ); const view = new DataView(buffer); const bytes = new Uint8Array(buffer); let offset = 0; // Verify magic for (let i = 0; i < MAGIC.length; i++) { if (bytes[offset + i] !== MAGIC[i]) { console.warn('[rvf-embedding-cache] Invalid magic bytes, skipping load'); return; } } offset += MAGIC.length; // Check for version header (v2+) let formatVersion = 1; if (offset + 4 <= buffer.byteLength) { const possibleVersion = view.getUint32(offset, true); if (possibleVersion === 2) { formatVersion = possibleVersion; offset += 4; } } // Read entries while (offset + 8 <= buffer.byteLength) { // Need at least 4 (hash) + 4 (dims) = 8 bytes for the header const hash = view.getUint32(offset, true); offset += 4; const dims = view.getUint32(offset, true); offset += 4; const entryDataSize = formatVersion === 2 ? dims * 4 + 8 + 8 + 8 // v2: embedding + createdAt + accessedAt + accessCount : dims * 4 + 8 + 8; // v1: embedding + accessedAt + accessCount if (offset + entryDataSize > buffer.byteLength) { console.warn('[rvf-embedding-cache] Truncated entry, stopping load'); break; } // Read embedding const embedding = new Float32Array(dims); for (let i = 0; i < dims; i++) { embedding[i] = view.getFloat32(offset, true); offset += 4; } let createdAt: number; let accessedAt: number; let accessCount: number; if (formatVersion >= 2) { createdAt = view.getFloat64(offset, true); offset += 8; accessedAt = view.getFloat64(offset, true); offset += 8; accessCount = view.getFloat64(offset, true); offset += 8; } else { // v1: only accessedAt was stored, use it as createdAt too accessedAt = view.getFloat64(offset, true); offset += 8; accessCount = view.getFloat64(offset, true); offset += 8; createdAt = accessedAt; } this.entries.set(hash, { embedding, createdAt, accessedAt, accessCount, }); } } catch (error) { console.warn( '[rvf-embedding-cache] Load error:', error instanceof Error ? error.message : error ); } } }