#!/usr/bin/env node // ═══════════════════════════════════════════════════════════════════════════ // SIL Graph Version — Versionera kunskapsgrafen // Erik-krav: "Ni versionerar kod, men jag skulle också versionera kunskapsgrafen" // ═══════════════════════════════════════════════════════════════════════════ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { createHash } from 'crypto'; import { execSync } from 'child_process'; const GRAPH_PATH = '/home/bernt/.openclaw/workspace/SYSTEM_GRAPH.json'; const VERSION_DIR = '/home/bernt/.openclaw/workspace/SIL/graph-versions'; const VERSION_LOG = '/home/bernt/.openclaw/workspace/SIL/graph-version-log.jsonl'; class GraphVersion { constructor() { if (!existsSync(VERSION_DIR)) { mkdirSync(VERSION_DIR, { recursive: true }); } } /** * Skapa en ny graf-version */ createVersion(options = {}) { const graph = this.loadGraph(); const timestamp = new Date().toISOString(); const commitSha = this.getGitCommit(); const parentVersion = this.getLatestVersion(); // Beräkna hash av grafen const graphHash = this.hashGraph(graph); // Versions-ID: hash av (parent + timestamp + graph-hash) const versionId = createHash('sha256') .update(`${parentVersion?.id || 'genesis'}${timestamp}${graphHash}`) .digest('hex') .substring(0, 16); const version = { id: versionId, parent: parentVersion?.id || null, timestamp, commitSha, graphHash, datasetVersion: options.datasetVersion || this.inferDatasetVersion(graph), observerVersion: options.observerVersion || '0.1.0', reasonerVersion: options.reasonerVersion || '0.1.0', metadata: { nodeCount: graph.nodes?.length || 0, edgeCount: graph.edges?.length || 0, changedBy: options.changedBy || 'unknown', changeReason: options.changeReason || 'manual_update', previousNodeCount: parentVersion?.metadata?.nodeCount || 0, previousEdgeCount: parentVersion?.metadata?.edgeCount || 0 } }; // Spara graf med versionsinfo const versionedGraph = { ...graph, _version: version }; writeFileSync( `${VERSION_DIR}/graph-${versionId}.json`, JSON.stringify(versionedGraph, null, 2) ); // Logga version const logEntry = { ...version, action: 'created' }; writeFileSync( VERSION_LOG, JSON.stringify(logEntry) + '\n', { flag: 'a' } ); console.log(`✅ Graf-version skapad: ${versionId}`); console.log(` Parent: ${version.parent || 'genesis'}`); console.log(` Commit: ${commitSha}`); console.log(` Noder: ${version.metadata.nodeCount}`); console.log(` Kanter: ${version.metadata.edgeCount}`); return version; } /** * Hämta senaste versionen */ getLatestVersion() { if (!existsSync(VERSION_LOG)) return null; const lines = readFileSync(VERSION_LOG, 'utf8') .split('\n') .filter(line => line.trim()); if (lines.length === 0) return null; try { return JSON.parse(lines[lines.length - 1]); } catch { return null; } } /** * Hämta specifik version */ getVersion(versionId) { const path = `${VERSION_DIR}/graph-${versionId}.json`; if (!existsSync(path)) return null; try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } } /** * Lista alla versioner */ listVersions() { if (!existsSync(VERSION_LOG)) return []; return readFileSync(VERSION_LOG, 'utf8') .split('\n') .filter(line => line.trim()) .map(line => { try { return JSON.parse(line); } catch { return null; } }) .filter(Boolean); } /** * Jämför två versioner */ compareVersions(versionId1, versionId2) { const v1 = this.getVersion(versionId1); const v2 = this.getVersion(versionId2); if (!v1 || !v2) { return { error: 'En eller båda versionerna hittades inte' }; } const nodes1 = new Set(v1.nodes?.map(n => n.id) || []); const nodes2 = new Set(v2.nodes?.map(n => n.id) || []); const added = [...nodes2].filter(n => !nodes1.has(n)); const removed = [...nodes1].filter(n => !nodes2.has(n)); const edges1 = new Set(v1.edges?.map(e => `${e.from}-${e.to}`) || []); const edges2 = new Set(v2.edges?.map(e => `${e.from}-${e.to}`) || []); const edgesAdded = [...edges2].filter(e => !edges1.has(e)); const edgesRemoved = [...edges1].filter(e => !edges2.has(e)); return { from: versionId1, to: versionId2, nodes: { before: nodes1.size, after: nodes2.size, added: added.length, removed: removed.length }, edges: { before: edges1.size, after: edges2.size, added: edgesAdded.length, removed: edgesRemoved.length }, churn: { nodeChurn: added.length + removed.length, edgeChurn: edgesAdded.length + edgesRemoved.length, totalChurn: added.length + removed.length + edgesAdded.length + edgesRemoved.length } }; } /** * Återskapa exakt den världsmodell som låg till grund för ett beslut */ reconstructForDecision(decisionTimestamp) { const versions = this.listVersions(); // Hitta senaste versionen före beslutet const relevantVersion = versions .filter(v => new Date(v.timestamp) <= new Date(decisionTimestamp)) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]; if (!relevantVersion) { return { error: 'Ingen version hittad före beslutet' }; } return { decisionTimestamp, version: relevantVersion, graph: this.getVersion(relevantVersion.id) }; } // ── Helpers ───────────────────────────────────────────────────────────── loadGraph() { try { return JSON.parse(readFileSync(GRAPH_PATH, 'utf8')); } catch { return { nodes: [], edges: [] }; } } hashGraph(graph) { return createHash('sha256') .update(JSON.stringify(graph)) .digest('hex') .substring(0, 16); } getGitCommit() { try { return execSync('git rev-parse --short HEAD', { cwd: '/home/bernt/.openclaw/workspace', encoding: 'utf8' }).trim(); } catch { return 'unknown'; } } inferDatasetVersion(graph) { // Använd senaste observer-timestamp som dataset-version const timestamps = graph.nodes ?.map(n => n.lastObserved) .filter(Boolean) .sort(); return timestamps?.[timestamps.length - 1] || 'unknown'; } } // ── CLI ─────────────────────────────────────────────────────────────────── const gv = new GraphVersion(); const command = process.argv[2]; if (command === '--create') { gv.createVersion({ changedBy: process.argv[3] || 'manual', changeReason: process.argv[4] || 'explicit_versioning' }); } else if (command === '--list') { const versions = gv.listVersions(); console.log(`📊 ${versions.length} graf-versioner\n`); for (const v of versions) { console.log(` ${v.id} (${v.timestamp})`); console.log(` Parent: ${v.parent || 'genesis'}`); console.log(` Commit: ${v.commitSha}`); console.log(` Noder: ${v.metadata.nodeCount}, Kanter: ${v.metadata.edgeCount}`); console.log(); } } else if (command === '--compare') { const v1 = process.argv[3]; const v2 = process.argv[4]; if (!v1 || !v2) { console.log('Användning: node graph-version.mjs --compare '); process.exit(1); } const result = gv.compareVersions(v1, v2); console.log(JSON.stringify(result, null, 2)); } else if (command === '--reconstruct') { const timestamp = process.argv[3]; if (!timestamp) { console.log('Användning: node graph-version.mjs --reconstruct '); process.exit(1); } const result = gv.reconstructForDecision(timestamp); console.log(JSON.stringify(result, null, 2)); } else { console.log('Användning:'); console.log(' node graph-version.mjs --create [changedBy] [reason]'); console.log(' node graph-version.mjs --list'); console.log(' node graph-version.mjs --compare '); console.log(' node graph-version.mjs --reconstruct '); }