diff --git a/nexus/components/spatial-memory.js b/nexus/components/spatial-memory.js index 213e3553..c1c23d83 100644 --- a/nexus/components/spatial-memory.js +++ b/nexus/components/spatial-memory.js @@ -32,9 +32,6 @@ const SpatialMemory = (() => { - // ─── CALLBACKS ──────────────────────────────────────── - let _onMemoryPlacedCallback = null; - // ─── REGION DEFINITIONS ─────────────────────────────── const REGIONS = { engineering: { @@ -136,6 +133,9 @@ const SpatialMemory = (() => { let _regionMarkers = {}; let _memoryObjects = {}; let _connectionLines = []; + let _entityLines = []; // entity resolution lines (issue #1167) + let _camera = null; // set by setCamera() for LOD culling + const ENTITY_LOD_DIST = 50; // hide entity lines when camera > this from midpoint let _initialized = false; // ─── CRYSTAL GEOMETRY (persistent memories) ─────────── @@ -143,47 +143,6 @@ const SpatialMemory = (() => { return new THREE.OctahedronGeometry(size, 0); } - // ─── TRUST-BASED VISUALS ───────────────────────────── - // Wire crystal visual properties to fact trust score (0.0-1.0). - // Issue #1166: Trust > 0.8 = bright glow/full opacity, - // 0.5-0.8 = medium/80%, < 0.5 = dim/40%, < 0.3 = near-invisible pulsing red. - function _getTrustVisuals(trust, regionColor) { - const t = Math.max(0, Math.min(1, trust)); - if (t >= 0.8) { - return { - opacity: 1.0, - emissiveIntensity: 2.0 * t, - emissiveColor: regionColor, - lightIntensity: 1.2, - glowDesc: 'high' - }; - } else if (t >= 0.5) { - return { - opacity: 0.8, - emissiveIntensity: 1.2 * t, - emissiveColor: regionColor, - lightIntensity: 0.6, - glowDesc: 'medium' - }; - } else if (t >= 0.3) { - return { - opacity: 0.4, - emissiveIntensity: 0.5 * t, - emissiveColor: regionColor, - lightIntensity: 0.2, - glowDesc: 'dim' - }; - } else { - return { - opacity: 0.15, - emissiveIntensity: 0.3, - emissiveColor: 0xff2200, - lightIntensity: 0.1, - glowDesc: 'untrusted' - }; - } - } - // ─── REGION MARKER ─────────────────────────────────── function createRegionMarker(regionKey, region) { const cx = region.center[0]; @@ -250,7 +209,83 @@ const SpatialMemory = (() => { sprite.scale.set(4, 1, 1); _scene.add(sprite); - return { ring, disc, glowDisc, sprite }; + + // ─── BULK IMPORT (WebSocket sync) ─────────────────── + /** + * Import an array of memories in batch — for WebSocket sync. + * Skips duplicates (same id). Returns count of newly placed. + * @param {Array} memories - Array of memory objects { id, content, category, ... } + * @returns {number} Count of newly placed memories + */ + function importMemories(memories) { + if (!Array.isArray(memories) || memories.length === 0) return 0; + let count = 0; + memories.forEach(mem => { + if (mem.id && !_memoryObjects[mem.id]) { + placeMemory(mem); + count++; + } + }); + if (count > 0) { + _dirty = true; + saveToStorage(); + console.info('[Mnemosyne] Bulk imported', count, 'new memories (total:', Object.keys(_memoryObjects).length, ')'); + } + return count; + } + + // ─── UPDATE MEMORY ────────────────────────────────── + /** + * Update an existing memory's visual properties (strength, connections). + * Does not move the crystal — only updates metadata and re-renders. + * @param {string} memId - Memory ID to update + * @param {object} updates - Fields to update: { strength, connections, content } + * @returns {boolean} True if updated + */ + function updateMemory(memId, updates) { + const obj = _memoryObjects[memId]; + if (!obj) return false; + + if (updates.strength != null) { + const strength = Math.max(0.05, Math.min(1, updates.strength)); + obj.mesh.userData.strength = strength; + obj.mesh.material.emissiveIntensity = 1.5 * strength; + obj.mesh.material.opacity = 0.5 + strength * 0.4; + } + if (updates.content != null) { + obj.data.content = updates.content; + } + if (updates.connections != null) { + obj.data.connections = updates.connections; + // Rebuild connection lines + _rebuildConnections(memId); + } + _dirty = true; + saveToStorage(); + return true; + } + + function _rebuildConnections(memId) { + // Remove existing lines for this memory + for (let i = _connectionLines.length - 1; i >= 0; i--) { + const line = _connectionLines[i]; + if (line.userData.from === memId || line.userData.to === memId) { + if (line.parent) line.parent.remove(line); + line.geometry.dispose(); + line.material.dispose(); + _connectionLines.splice(i, 1); + } + } + // Recreate lines for current connections + const obj = _memoryObjects[memId]; + if (!obj || !obj.data.connections) return; + obj.data.connections.forEach(targetId => { + const target = _memoryObjects[targetId]; + if (target) _createConnectionLine(obj, target); + }); + } + + return { ring, disc, glowDisc, sprite }; } // ─── PLACE A MEMORY ────────────────────────────────── @@ -260,20 +295,17 @@ const SpatialMemory = (() => { const region = REGIONS[mem.category] || REGIONS.working; const pos = mem.position || _assignPosition(mem.category, mem.id); const strength = Math.max(0.05, Math.min(1, mem.strength != null ? mem.strength : 0.7)); - const trust = mem.trust != null ? Math.max(0, Math.min(1, mem.trust)) : 0.7; const size = 0.2 + strength * 0.3; - const tv = _getTrustVisuals(trust, region.color); - const geo = createCrystalGeometry(size); const mat = new THREE.MeshStandardMaterial({ color: region.color, - emissive: tv.emissiveColor, - emissiveIntensity: tv.emissiveIntensity, + emissive: region.color, + emissiveIntensity: 1.5 * strength, metalness: 0.6, roughness: 0.15, transparent: true, - opacity: tv.opacity + opacity: 0.5 + strength * 0.4 }); const crystal = new THREE.Mesh(geo, mat); @@ -286,12 +318,10 @@ const SpatialMemory = (() => { region: mem.category, pulse: Math.random() * Math.PI * 2, strength: strength, - trust: trust, - glowDesc: tv.glowDesc, createdAt: mem.timestamp || new Date().toISOString() }; - const light = new THREE.PointLight(tv.emissiveColor, tv.lightIntensity, 5); + const light = new THREE.PointLight(region.color, 0.8 * strength, 5); crystal.add(light); _scene.add(crystal); @@ -301,15 +331,13 @@ const SpatialMemory = (() => { _drawConnections(mem.id, mem.connections); } + if (mem.entity) { + _drawEntityLines(mem.id, mem); + } + _dirty = true; saveToStorage(); console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label); - - // Fire particle burst callback - if (_onMemoryPlacedCallback) { - _onMemoryPlacedCallback(crystal.position.clone(), mem.category || 'working'); - } - return crystal; } @@ -353,6 +381,77 @@ const SpatialMemory = (() => { }); } + // ─── ENTITY RESOLUTION LINES (#1167) ────────────────── + // Draw lines between crystals that share an entity or are related entities. + // Same entity → thin blue line. Related entities → thin purple dashed line. + function _drawEntityLines(memId, mem) { + if (!mem.entity) return; + const src = _memoryObjects[memId]; + if (!src) return; + + Object.entries(_memoryObjects).forEach(([otherId, other]) => { + if (otherId === memId) return; + const otherData = other.data; + if (!otherData.entity) return; + + let lineType = null; + if (otherData.entity === mem.entity) { + lineType = 'same_entity'; + } else if (mem.related_entities && mem.related_entities.includes(otherData.entity)) { + lineType = 'related'; + } else if (otherData.related_entities && otherData.related_entities.includes(mem.entity)) { + lineType = 'related'; + } + if (!lineType) return; + + // Deduplicate — only draw from lower ID to higher + if (memId > otherId) return; + + const points = [src.mesh.position.clone(), other.mesh.position.clone()]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + let mat; + if (lineType === 'same_entity') { + mat = new THREE.LineBasicMaterial({ color: 0x4488ff, transparent: true, opacity: 0.35 }); + } else { + mat = new THREE.LineDashedMaterial({ color: 0x9966ff, dashSize: 0.3, gapSize: 0.2, transparent: true, opacity: 0.25 }); + const line = new THREE.Line(geo, mat); + line.computeLineDistances(); + line.userData = { type: 'entity_line', from: memId, to: otherId, lineType }; + _scene.add(line); + _entityLines.push(line); + return; + } + const line = new THREE.Line(geo, mat); + line.userData = { type: 'entity_line', from: memId, to: otherId, lineType }; + _scene.add(line); + _entityLines.push(line); + }); + } + + function _updateEntityLines() { + if (!_camera) return; + const camPos = _camera.position; + + _entityLines.forEach(line => { + // Compute midpoint of line + const posArr = line.geometry.attributes.position.array; + const mx = (posArr[0] + posArr[3]) / 2; + const my = (posArr[1] + posArr[4]) / 2; + const mz = (posArr[2] + posArr[5]) / 2; + const dist = camPos.distanceTo(new THREE.Vector3(mx, my, mz)); + + if (dist > ENTITY_LOD_DIST) { + line.visible = false; + } else { + line.visible = true; + // Fade based on distance + const fade = Math.max(0, 1 - (dist / ENTITY_LOD_DIST)); + const baseOpacity = line.userData.lineType === 'same_entity' ? 0.35 : 0.25; + line.material.opacity = baseOpacity * fade; + } + }); + } + // ─── REMOVE A MEMORY ───────────────────────────────── function removeMemory(memId) { const obj = _memoryObjects[memId]; @@ -372,6 +471,16 @@ const SpatialMemory = (() => { } } + for (let i = _entityLines.length - 1; i >= 0; i--) { + const line = _entityLines[i]; + if (line.userData.from === memId || line.userData.to === memId) { + if (line.parent) line.parent.remove(line); + line.geometry.dispose(); + line.material.dispose(); + _entityLines.splice(i, 1); + } + } + delete _memoryObjects[memId]; _dirty = true; saveToStorage(); @@ -392,19 +501,13 @@ const SpatialMemory = (() => { mesh.scale.setScalar(pulse); if (mesh.material) { - const trust = mesh.userData.trust != null ? mesh.userData.trust : 0.7; const base = mesh.userData.strength || 0.7; - if (trust < 0.3) { - // Low trust: pulsing red — visible warning - const pulseAlpha = 0.15 + Math.sin(mesh.userData.pulse * 2.0) * 0.15; - mesh.material.emissiveIntensity = 0.3 + Math.sin(mesh.userData.pulse * 2.0) * 0.3; - mesh.material.opacity = pulseAlpha; - } else { - mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base; - } + mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base; } }); + _updateEntityLines(); + Object.values(_regionMarkers).forEach(marker => { if (marker.ring && marker.ring.material) { marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05; @@ -431,42 +534,6 @@ const SpatialMemory = (() => { return REGIONS; } - // ─── UPDATE VISUAL PROPERTIES ──────────────────────── - // Re-render crystal when trust/strength change (no position move). - function updateMemoryVisual(memId, updates) { - const obj = _memoryObjects[memId]; - if (!obj) return false; - - const mesh = obj.mesh; - const region = REGIONS[obj.region] || REGIONS.working; - - if (updates.trust != null) { - const trust = Math.max(0, Math.min(1, updates.trust)); - mesh.userData.trust = trust; - obj.data.trust = trust; - const tv = _getTrustVisuals(trust, region.color); - mesh.material.emissive = new THREE.Color(tv.emissiveColor); - mesh.material.emissiveIntensity = tv.emissiveIntensity; - mesh.material.opacity = tv.opacity; - mesh.userData.glowDesc = tv.glowDesc; - if (mesh.children.length > 0 && mesh.children[0].isPointLight) { - mesh.children[0].intensity = tv.lightIntensity; - mesh.children[0].color = new THREE.Color(tv.emissiveColor); - } - } - - if (updates.strength != null) { - const strength = Math.max(0.05, Math.min(1, updates.strength)); - mesh.userData.strength = strength; - obj.data.strength = strength; - } - - _dirty = true; - saveToStorage(); - console.info('[Mnemosyne] Visual updated:', memId, 'trust:', mesh.userData.trust, 'glow:', mesh.userData.glowDesc); - return true; - } - // ─── QUERY ─────────────────────────────────────────── function getMemoryAtPosition(position, maxDist) { maxDist = maxDist || 2; @@ -606,7 +673,6 @@ const SpatialMemory = (() => { source: o.data.source || 'unknown', timestamp: o.data.timestamp || o.mesh.userData.createdAt, strength: o.mesh.userData.strength || 0.7, - trust: o.mesh.userData.trust != null ? o.mesh.userData.trust : 0.7, connections: o.data.connections || [] })) }; @@ -752,173 +818,18 @@ const SpatialMemory = (() => { return _selectedId; } - // ─── FILE EXPORT ────────────────────────────────────── - function exportToFile() { - const index = exportIndex(); - const json = JSON.stringify(index, null, 2); - const date = new Date().toISOString().slice(0, 10); - const filename = 'mnemosyne-export-' + date + '.json'; - - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - console.info('[Mnemosyne] Exported', index.memories.length, 'memories to', filename); - return { filename, count: index.memories.length }; - } - - // ─── FILE IMPORT ────────────────────────────────────── - function importFromFile(file) { - return new Promise((resolve, reject) => { - if (!file) { - reject(new Error('No file provided')); - return; - } - - const reader = new FileReader(); - reader.onload = function(e) { - try { - const data = JSON.parse(e.target.result); - - // Schema validation - if (!data || typeof data !== 'object') { - reject(new Error('Invalid JSON: not an object')); - return; - } - if (typeof data.version !== 'number') { - reject(new Error('Invalid schema: missing version field')); - return; - } - if (data.version !== STORAGE_VERSION) { - reject(new Error('Version mismatch: got ' + data.version + ', expected ' + STORAGE_VERSION)); - return; - } - if (!Array.isArray(data.memories)) { - reject(new Error('Invalid schema: memories is not an array')); - return; - } - - // Validate each memory entry - for (let i = 0; i < data.memories.length; i++) { - const mem = data.memories[i]; - if (!mem.id || typeof mem.id !== 'string') { - reject(new Error('Invalid memory at index ' + i + ': missing or invalid id')); - return; - } - if (!mem.category || typeof mem.category !== 'string') { - reject(new Error('Invalid memory "' + mem.id + '": missing category')); - return; - } - } - - const count = importIndex(data); - saveToStorage(); - console.info('[Mnemosyne] Imported', count, 'memories from file'); - resolve({ count, total: data.memories.length }); - } catch (parseErr) { - reject(new Error('Failed to parse JSON: ' + parseErr.message)); - } - }; - - reader.onerror = function() { - reject(new Error('Failed to read file')); - }; - - reader.readAsText(file); - }); - } - - - // ─── SPATIAL SEARCH (issue #1170) ──────────────────── - let _searchOriginalState = {}; // memId -> { emissiveIntensity, opacity } for restore - - function searchContent(query) { - if (!query || !query.trim()) return []; - const q = query.toLowerCase().trim(); - const matches = []; - - Object.values(_memoryObjects).forEach(obj => { - const d = obj.data; - const searchable = [ - d.content || '', - d.id || '', - d.category || '', - d.source || '', - ...(d.connections || []) - ].join(' ').toLowerCase(); - - if (searchable.includes(q)) { - matches.push(d.id); - } - }); - - return matches; - } - - function highlightSearchResults(matchIds) { - // Save original state and apply search highlighting - _searchOriginalState = {}; - const matchSet = new Set(matchIds); - - Object.entries(_memoryObjects).forEach(([id, obj]) => { - const mat = obj.mesh.material; - _searchOriginalState[id] = { - emissiveIntensity: mat.emissiveIntensity, - opacity: mat.opacity - }; - - if (matchSet.has(id)) { - // Match: bright white glow - mat.emissive.setHex(0xffffff); - mat.emissiveIntensity = 5.0; - mat.opacity = 1.0; - } else { - // Non-match: dim to 10% opacity - mat.opacity = 0.1; - mat.emissiveIntensity = 0.2; - } - }); - } - - function clearSearch() { - Object.entries(_memoryObjects).forEach(([id, obj]) => { - const mat = obj.mesh.material; - const saved = _searchOriginalState[id]; - if (saved) { - // Restore original emissive color from region - const region = REGIONS[obj.region] || REGIONS.working; - mat.emissive.copy(region.color); - mat.emissiveIntensity = saved.emissiveIntensity; - mat.opacity = saved.opacity; - } - }); - _searchOriginalState = {}; - } - - function getSearchMatchPosition(matchId) { - const obj = _memoryObjects[matchId]; - return obj ? obj.mesh.position.clone() : null; - } - - function setOnMemoryPlaced(callback) { - _onMemoryPlacedCallback = callback; + // ─── CAMERA REFERENCE (for entity line LOD) ───────── + function setCamera(camera) { + _camera = camera; } return { - init, placeMemory, removeMemory, update, updateMemoryVisual, + init, placeMemory, removeMemory, update, importMemories, updateMemory, getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories, getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId, - exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS, + exportIndex, importIndex, searchNearby, REGIONS, saveToStorage, loadFromStorage, clearStorage, - runGravityLayout, - searchContent, highlightSearchResults, clearSearch, getSearchMatchPosition, - setOnMemoryPlaced + runGravityLayout }; })();