/** * Semantic Versioning System for VFS * * Only creates versions when the MEANING of content changes significantly * PRODUCTION-READY: Real implementation using embeddings */ import { Brainy } from '../brainy.js' import { NounType, VerbType } from '../types/graphTypes.js' import { cosineDistance } from '../utils/distance.js' import { createHash } from 'crypto' import { v4 as uuidv4 } from '../universal/uuid.js' import { EntityManager, ManagedEntity } from './EntityManager.js' /** * Version metadata */ export interface Version extends ManagedEntity { id: string path: string version: number timestamp: number hash: string size: number semanticHash?: string // Hash of the embedding for quick comparison author?: string message?: string parentVersion?: string } /** * Semantic versioning configuration */ export interface SemanticVersioningConfig { threshold?: number // Semantic change threshold (0-1, default 0.3) maxVersions?: number // Max versions to keep per file minInterval?: number // Minimum time between versions (ms) sizeChangeThreshold?: number // Size change threshold (0-1) } /** * Semantic Versioning System * * Creates versions only when content meaning changes significantly * Uses vector embeddings to detect semantic changes */ export class SemanticVersioning extends EntityManager { private config: Required private versionCache = new Map() constructor( brain: Brainy, config?: SemanticVersioningConfig ) { super(brain, 'vfs-version') this.config = { threshold: config?.threshold ?? 0.3, maxVersions: config?.maxVersions ?? 10, minInterval: config?.minInterval ?? 60000, // 1 minute sizeChangeThreshold: config?.sizeChangeThreshold ?? 0.5 } } /** * Check if content has changed enough to warrant a new version */ async shouldVersion(oldContent: Buffer, newContent: Buffer): Promise { // Quick hash check - if identical, no version needed const oldHash = this.hashContent(oldContent) const newHash = this.hashContent(newContent) if (oldHash === newHash) { return false } // Check size change const sizeChange = Math.abs(oldContent.length - newContent.length) / Math.max(oldContent.length, 1) if (sizeChange > this.config.sizeChangeThreshold) { return true // Large size change warrants version } // For small files, any change is significant if (oldContent.length < 100 || newContent.length < 100) { return true } // Check semantic change using embeddings try { const semanticDistance = await this.calculateSemanticDistance(oldContent, newContent) return semanticDistance > this.config.threshold } catch (error) { // If embedding fails, fall back to size-based decision console.warn('Failed to calculate semantic distance:', error) return sizeChange > 0.2 } } /** * Create a new version */ async createVersion( path: string, content: Buffer, metadata?: { author?: string message?: string } ): Promise { const versionId = uuidv4() const timestamp = Date.now() const hash = this.hashContent(content) // Get current version number const versions = await this.getVersions(path) const versionNumber = versions.length + 1 const parentVersion = versions[0]?.id // Generate embedding for semantic comparison let embedding: number[] | undefined let semanticHash: string | undefined try { // Only generate embedding for reasonably sized content if (content.length < 100000) { embedding = await this.generateEmbedding(content) if (embedding) { semanticHash = this.hashEmbedding(embedding) } } } catch (error) { console.warn('Failed to generate embedding for version:', error) } // Create version entity const version: Version = { id: versionId, path, version: versionNumber, timestamp, hash, semanticHash, size: content.length, author: metadata?.author, message: metadata?.message, parentVersion } // Store version using EntityManager (with actual content as data) await this.storeEntity(version, NounType.State, embedding, content) // Create relationship to parent version if exists if (parentVersion) { try { await this.createRelationship(versionId, parentVersion, VerbType.Succeeds) } catch (error) { console.warn(`Failed to create parent relationship for version ${versionId}:`, error) // Continue without relationship - non-critical for version functionality } } // Update cache if (!this.versionCache.has(path)) { this.versionCache.set(path, []) } this.versionCache.get(path)!.unshift({ id: versionId, path, version: versionNumber, timestamp, hash, size: content.length, semanticHash, author: metadata?.author, message: metadata?.message, parentVersion }) // Prune old versions if needed await this.pruneVersions(path) return versionId } /** * Get all versions for a file */ async getVersions(path: string): Promise { // Check cache first if (this.versionCache.has(path)) { return this.versionCache.get(path)! } // Query using EntityManager const versions = await this.findEntities( { path }, NounType.State, this.config.maxVersions * 2 // Get extra in case some are pruned ) // Sort by timestamp (newest first) versions.sort((a, b) => b.timestamp - a.timestamp) // Update cache this.versionCache.set(path, versions) return versions } /** * Get a specific version's content */ async getVersion(path: string, versionId: string): Promise { // Get the version entity const version = await this.getEntity(versionId) if (!version || version.path !== path) { return null } // Get the content from Brainy using the Brainy ID const brainyId = await this.getBrainyId(versionId) if (!brainyId) { return null } const entity = await this.brain.get(brainyId) return entity?.data as Buffer | null } /** * Restore a file to a specific version */ async restoreVersion(path: string, versionId: string): Promise { const content = await this.getVersion(path, versionId) if (!content) { throw new Error(`Version ${versionId} not found for ${path}`) } // Create a new version pointing to the restored one await this.createVersion(path, content, { message: `Restored to version ${versionId}` }) return content } /** * Get version history with diffs */ async getVersionHistory(path: string, limit = 10): Promise> { const versions = await this.getVersions(path) const history = [] for (let i = 0; i < Math.min(versions.length, limit); i++) { const version = versions[i] let changes = undefined // Calculate changes from parent if (version.parentVersion && i < versions.length - 1) { const parentVersion = versions[i + 1] if (parentVersion.id === version.parentVersion) { // Simple size-based diff for now changes = { additions: Math.max(0, version.size - parentVersion.size), deletions: Math.max(0, parentVersion.size - version.size), semanticChange: version.semanticHash && parentVersion.semanticHash ? this.estimateSemanticChange(version.semanticHash, parentVersion.semanticHash) : 0 } } } history.push({ version, changes }) } return history } /** * Prune old versions beyond the limit */ private async pruneVersions(path: string): Promise { const versions = await this.getVersions(path) if (versions.length <= this.config.maxVersions) { return } // Keep important versions (first, last, and evenly distributed) const toKeep = new Set() const toDelete: string[] = [] // Always keep first and last toKeep.add(versions[0].id) // Newest toKeep.add(versions[versions.length - 1].id) // Oldest // Keep evenly distributed versions const step = Math.floor(versions.length / this.config.maxVersions) for (let i = 0; i < versions.length; i += step) { toKeep.add(versions[i].id) } // Mark others for deletion for (const version of versions) { if (!toKeep.has(version.id)) { toDelete.push(version.id) } } // Delete excess versions for (const id of toDelete.slice(0, versions.length - this.config.maxVersions)) { await this.deleteEntity(id) } // Update cache this.versionCache.set( path, versions.filter(v => !toDelete.includes(v.id)) ) } /** * Calculate semantic distance between two pieces of content */ private async calculateSemanticDistance(oldContent: Buffer, newContent: Buffer): Promise { // Generate embeddings const [oldEmbedding, newEmbedding] = await Promise.all([ this.generateEmbedding(oldContent), this.generateEmbedding(newContent) ]) if (!oldEmbedding || !newEmbedding) { throw new Error('Failed to generate embeddings') } // Calculate cosine distance return cosineDistance(oldEmbedding, newEmbedding) } /** * Generate embedding for content */ private async generateEmbedding(content: Buffer): Promise { try { // For text content, use first 10KB for embedding const text = content.toString('utf8', 0, Math.min(10240, content.length)) // Use Brainy's embedding function const vector = await this.brain.embed(text) return vector } catch (error) { console.error('Failed to generate embedding:', error) return undefined } } /** * Hash content for quick comparison */ private hashContent(content: Buffer): string { return createHash('sha256').update(content).digest('hex') } /** * Hash embedding for quick comparison */ private hashEmbedding(embedding: number[]): string { return createHash('sha256') .update(Buffer.from(new Float32Array(embedding).buffer)) .digest('hex') } /** * Estimate semantic change from hashes (rough approximation) */ private estimateSemanticChange(hash1: string, hash2: string): number { if (hash1 === hash2) return 0 // Simple hamming distance on first few characters // This is a rough approximation let distance = 0 for (let i = 0; i < Math.min(hash1.length, hash2.length, 8); i++) { if (hash1[i] !== hash2[i]) distance++ } return distance / 8 } /** * Clear version cache for a file */ clearCache(path?: string): void { if (path) { this.versionCache.delete(path) } else { this.versionCache.clear() } } }