* Add EntityManager base class for standardized entity operations * Update SemanticVersioning to extend EntityManager * Update EventRecorder to extend EntityManager * Update PersistentEntitySystem to extend EntityManager * Update ConceptSystem to extend EntityManager * Fix entity ID mismatch patterns across all Knowledge Layer components * Add proper domain ID to Brainy ID mapping * Replace direct brain.add/find calls with EntityManager methods * Ensure all entity interfaces extend ManagedEntity * Add proper metadata fields for querying (eventType, conceptType, entityType) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
404 lines
No EOL
11 KiB
TypeScript
404 lines
No EOL
11 KiB
TypeScript
/**
|
|
* 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<SemanticVersioningConfig>
|
|
private versionCache = new Map<string, Version[]>()
|
|
|
|
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<boolean> {
|
|
// 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<string> {
|
|
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<Version[]> {
|
|
// Check cache first
|
|
if (this.versionCache.has(path)) {
|
|
return this.versionCache.get(path)!
|
|
}
|
|
|
|
// Query using EntityManager
|
|
const versions = await this.findEntities<Version>(
|
|
{ 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<Buffer | null> {
|
|
// Get the version entity
|
|
const version = await this.getEntity<Version>(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<Buffer | null> {
|
|
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<Array<{
|
|
version: Version
|
|
changes?: {
|
|
additions: number
|
|
deletions: number
|
|
semanticChange: number
|
|
}
|
|
}>> {
|
|
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<void> {
|
|
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<string>()
|
|
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<number> {
|
|
// 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<number[] | undefined> {
|
|
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()
|
|
}
|
|
}
|
|
} |