// ═══ // ─── REGION VISIBILITY (Memory Filter) ────────────── let _regionVisibility = {}; // category -> boolean (undefined = visible) setRegionVisibility(category, visible) { _regionVisibility[category] = visible; for (const obj of Object.values(_memoryObjects)) { if (obj.data.category === category && obj.mesh) { obj.mesh.visible = visible !== false; } } }, setAllRegionsVisible(visible) { const cats = Object.keys(REGIONS); for (const cat of cats) { _regionVisibility[cat] = visible; for (const obj of Object.values(_memoryObjects)) { if (obj.data.category === cat && obj.mesh) { obj.mesh.visible = visible; } } } }, getMemoryCountByRegion() { const counts = {}; for (const obj of Object.values(_memoryObjects)) { const cat = obj.data.category || 'working'; counts[cat] = (counts[cat] || 0) + 1; } return counts; }, isRegionVisible(category) { return _regionVisibility[category] !== false; }, ════════════════════════════════════════ // PROJECT MNEMOSYNE — SPATIAL MEMORY SCHEMA // ═══════════════════════════════════════════ // // Maps memories to persistent locations in the 3D Nexus world. // Each region corresponds to a semantic category. Memories placed // in a region stay there across sessions, forming a navigable // holographic archive. // // World layout (hex cylinder, radius 25): // // Inner ring — original Mnemosyne taxonomy (radius 15): // North (z-) → Documents & Knowledge // South (z+) → Projects & Tasks // East (x+) → Code & Engineering // West (x-) → Conversations & Social // Center → Active Working Memory // Below (y-) → Archive (cold storage) // // Outer ring — MemPalace category zones (radius 20, issue #1168): // North (z-) → User Preferences [golden] // East (x+) → Project facts [blue] // South (z+) → Tool knowledge [green] // West (x-) → General facts [gray] // // Usage from app.js: // SpatialMemory.init(scene); // SpatialMemory.placeMemory({ id, content, category, ... }); // SpatialMemory.importIndex(savedIndex); // SpatialMemory.update(delta); // ═══════════════════════════════════════════ const SpatialMemory = (() => { // ─── REGION DEFINITIONS ─────────────────────────────── const REGIONS = { engineering: { label: 'Code & Engineering', center: [15, 0, 0], radius: 10, color: 0x4af0c0, glyph: '\u2699', description: 'Source code, debugging sessions, architecture decisions' }, social: { label: 'Conversations & Social', center: [-15, 0, 0], radius: 10, color: 0x7b5cff, glyph: '\uD83D\uDCAC', description: 'Chats, discussions, human interactions' }, knowledge: { label: 'Documents & Knowledge', center: [0, 0, -15], radius: 10, color: 0xffd700, glyph: '\uD83D\uDCD6', description: 'Papers, docs, research, learned concepts' }, projects: { label: 'Projects & Tasks', center: [0, 0, 15], radius: 10, color: 0xff4466, glyph: '\uD83C\uDFAF', description: 'Active tasks, issues, milestones, goals' }, working: { label: 'Active Working Memory', center: [0, 0, 0], radius: 5, color: 0x00ff88, glyph: '\uD83D\uDCA1', description: 'Current focus — transient, high-priority memories' }, archive: { label: 'Archive', center: [0, -3, 0], radius: 20, color: 0x334455, glyph: '\uD83D\uDDC4', description: 'Cold storage — rarely accessed, aged-out memories' }, // ── MemPalace category zones — outer ring, issue #1168 ──────────── user_pref: { label: 'User Preferences', center: [0, 0, -20], radius: 10, color: 0xffd700, glyph: '\u2605', description: 'Personal preferences, habits, user-specific settings', labelY: 5 }, project: { label: 'Project Facts', center: [20, 0, 0], radius: 10, color: 0x4488ff, glyph: '\uD83D\uDCC1', description: 'Project-specific knowledge, goals, context', labelY: 5 }, tool: { label: 'Tool Knowledge', center: [0, 0, 20], radius: 10, color: 0x44cc66, glyph: '\uD83D\uDD27', description: 'Tools, commands, APIs, and how to use them', labelY: 5 }, general: { label: 'General Facts', center: [-20, 0, 0], radius: 10, color: 0x8899aa, glyph: '\uD83D\uDCDD', description: 'Miscellaneous facts not fitting other categories', labelY: 5 } }; // ─── PERSISTENCE CONFIG ────────────────────────────── const STORAGE_KEY = 'mnemosyne_spatial_memory'; const STORAGE_VERSION = 1; let _dirty = false; let _lastSavedHash = ''; // ─── STATE ──────────────────────────────────────────── let _scene = null; 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 const CONNECTION_LOD_DIST = 60; // hide connection lines when camera > this from midpoint let _initialized = false; let _constellationVisible = true; // toggle for constellation view // ─── CRYSTAL GEOMETRY (persistent memories) ─────────── function createCrystalGeometry(size) { return new THREE.OctahedronGeometry(size, 0); } // ─── REGION MARKER ─────────────────────────────────── function createRegionMarker(regionKey, region) { const cx = region.center[0]; const cy = region.center[1] + 0.06; const cz = region.center[2]; const labelY = region.labelY || 3; const ringGeo = new THREE.RingGeometry(region.radius - 0.5, region.radius, 6); const ringMat = new THREE.MeshBasicMaterial({ color: region.color, transparent: true, opacity: 0.15, side: THREE.DoubleSide }); const ring = new THREE.Mesh(ringGeo, ringMat); ring.rotation.x = -Math.PI / 2; ring.position.set(cx, cy, cz); ring.userData = { type: 'region_marker', region: regionKey }; const discGeo = new THREE.CircleGeometry(region.radius - 0.5, 6); const discMat = new THREE.MeshBasicMaterial({ color: region.color, transparent: true, opacity: 0.03, side: THREE.DoubleSide }); const disc = new THREE.Mesh(discGeo, discMat); disc.rotation.x = -Math.PI / 2; disc.position.set(cx, cy - 0.01, cz); _scene.add(ring); _scene.add(disc); // Ground glow — brighter disc for MemPalace zones (labelY > 3 signals outer ring) let glowDisc = null; if (labelY > 3) { const glowGeo = new THREE.CircleGeometry(region.radius, 32); const glowMat = new THREE.MeshBasicMaterial({ color: region.color, transparent: true, opacity: 0.06, side: THREE.DoubleSide }); glowDisc = new THREE.Mesh(glowGeo, glowMat); glowDisc.rotation.x = -Math.PI / 2; glowDisc.position.set(cx, cy - 0.02, cz); _scene.add(glowDisc); } // Floating label const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.font = '24px monospace'; ctx.fillStyle = '#' + region.color.toString(16).padStart(6, '0'); ctx.textAlign = 'center'; ctx.fillText(region.glyph + ' ' + region.label, 128, 40); const texture = new THREE.CanvasTexture(canvas); const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.6 }); const sprite = new THREE.Sprite(spriteMat); sprite.position.set(cx, labelY, cz); sprite.scale.set(4, 1, 1); _scene.add(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) _drawSingleConnection(obj, target); }); } function _drawSingleConnection(src, tgt) { const srcId = src.data.id; const tgtId = tgt.data.id; // Deduplicate — only draw from lower ID to higher if (srcId > tgtId) return; // Skip if already exists const exists = _connectionLines.some(l => (l.userData.from === srcId && l.userData.to === tgtId) || (l.userData.from === tgtId && l.userData.to === srcId) ); if (exists) return; const points = [src.mesh.position.clone(), tgt.mesh.position.clone()]; const geo = new THREE.BufferGeometry().setFromPoints(points); const srcStrength = src.mesh.userData.strength || 0.7; const tgtStrength = tgt.mesh.userData.strength || 0.7; const blendedStrength = (srcStrength + tgtStrength) / 2; const lineOpacity = 0.15 + blendedStrength * 0.55; const srcColor = new THREE.Color(REGIONS[src.region]?.color || 0x334455); const tgtColor = new THREE.Color(REGIONS[tgt.region]?.color || 0x334455); const lineColor = new THREE.Color().lerpColors(srcColor, tgtColor, 0.5); const mat = new THREE.LineBasicMaterial({ color: lineColor, transparent: true, opacity: lineOpacity }); const line = new THREE.Line(geo, mat); line.userData = { type: 'connection', from: srcId, to: tgtId, baseOpacity: lineOpacity }; line.visible = _constellationVisible; _scene.add(line); _connectionLines.push(line); } return { ring, disc, glowDisc, sprite }; } // ─── PLACE A MEMORY ────────────────────────────────── function placeMemory(mem) { if (!_scene) return null; 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 size = 0.2 + strength * 0.3; const geo = createCrystalGeometry(size); const mat = new THREE.MeshStandardMaterial({ color: region.color, emissive: region.color, emissiveIntensity: 1.5 * strength, metalness: 0.6, roughness: 0.15, transparent: true, opacity: 0.5 + strength * 0.4 }); const crystal = new THREE.Mesh(geo, mat); crystal.position.set(pos[0], pos[1] + 1.5, pos[2]); crystal.castShadow = true; crystal.userData = { type: 'spatial_memory', memId: mem.id, region: mem.category, pulse: Math.random() * Math.PI * 2, strength: strength, createdAt: mem.timestamp || new Date().toISOString() }; const light = new THREE.PointLight(region.color, 0.8 * strength, 5); crystal.add(light); _scene.add(crystal); _memoryObjects[mem.id] = { mesh: crystal, data: mem, region: mem.category }; if (mem.connections && mem.connections.length > 0) { _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); return crystal; } // ─── DETERMINISTIC POSITION ────────────────────────── function _assignPosition(category, memId) { const region = REGIONS[category] || REGIONS.working; const cx = region.center[0]; const cy = region.center[1]; const cz = region.center[2]; const r = region.radius * 0.7; let hash = 0; for (let i = 0; i < memId.length; i++) { hash = ((hash << 5) - hash) + memId.charCodeAt(i); hash |= 0; } const angle = (Math.abs(hash % 360) / 360) * Math.PI * 2; const dist = (Math.abs((hash >> 8) % 100) / 100) * r; const height = (Math.abs((hash >> 16) % 100) / 100) * 3; return [cx + Math.cos(angle) * dist, cy + height, cz + Math.sin(angle) * dist]; } // ─── CONNECTIONS (constellation-aware) ─────────────── function _drawConnections(memId, connections) { const src = _memoryObjects[memId]; if (!src) return; connections.forEach(targetId => { const tgt = _memoryObjects[targetId]; if (!tgt) return; const points = [src.mesh.position.clone(), tgt.mesh.position.clone()]; const geo = new THREE.BufferGeometry().setFromPoints(points); // Strength-encoded opacity: blend source/target strengths, min 0.15, max 0.7 const srcStrength = src.mesh.userData.strength || 0.7; const tgtStrength = tgt.mesh.userData.strength || 0.7; const blendedStrength = (srcStrength + tgtStrength) / 2; const lineOpacity = 0.15 + blendedStrength * 0.55; // Blend source/target region colors for the line const srcColor = new THREE.Color(REGIONS[src.region]?.color || 0x334455); const tgtColor = new THREE.Color(REGIONS[tgt.region]?.color || 0x334455); const lineColor = new THREE.Color().lerpColors(srcColor, tgtColor, 0.5); const mat = new THREE.LineBasicMaterial({ color: lineColor, transparent: true, opacity: lineOpacity }); const line = new THREE.Line(geo, mat); line.userData = { type: 'connection', from: memId, to: targetId, baseOpacity: lineOpacity }; line.visible = _constellationVisible; _scene.add(line); _connectionLines.push(line); }); } // ─── 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; } }); } function _updateConnectionLines() { if (!_constellationVisible) return; if (!_camera) return; const camPos = _camera.position; _connectionLines.forEach(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 > CONNECTION_LOD_DIST) { line.visible = false; } else { line.visible = true; const fade = Math.max(0, 1 - (dist / CONNECTION_LOD_DIST)); // Restore base opacity from userData if stored, else use material default const base = line.userData.baseOpacity || line.material.opacity || 0.4; line.material.opacity = base * fade; } }); } function toggleConstellation() { _constellationVisible = !_constellationVisible; _connectionLines.forEach(line => { line.visible = _constellationVisible; }); console.info('[Mnemosyne] Constellation', _constellationVisible ? 'shown' : 'hidden'); return _constellationVisible; } function isConstellationVisible() { return _constellationVisible; } // ─── REMOVE A MEMORY ───────────────────────────────── function removeMemory(memId) { const obj = _memoryObjects[memId]; if (!obj) return; if (obj.mesh.parent) obj.mesh.parent.remove(obj.mesh); if (obj.mesh.geometry) obj.mesh.geometry.dispose(); if (obj.mesh.material) obj.mesh.material.dispose(); 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); } } 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(); } // ─── ANIMATE ───────────────────────────────────────── function update(delta) { const now = Date.now(); Object.values(_memoryObjects).forEach(obj => { const mesh = obj.mesh; if (!mesh || !mesh.userData) return; mesh.rotation.y += delta * 0.3; mesh.userData.pulse += delta * 1.5; const pulse = 1 + Math.sin(mesh.userData.pulse) * 0.08; mesh.scale.setScalar(pulse); if (mesh.material) { const base = mesh.userData.strength || 0.7; mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base; } }); _updateEntityLines(); _updateConnectionLines(); Object.values(_regionMarkers).forEach(marker => { if (marker.ring && marker.ring.material) { marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05; } if (marker.glowDisc && marker.glowDisc.material) { marker.glowDisc.material.opacity = 0.04 + Math.sin(now * 0.0008) * 0.02; } }); } // ─── INIT ──────────────────────────────────────────── function init(scene) { _scene = scene; _initialized = true; Object.entries(REGIONS).forEach(([key, region]) => { if (key === 'archive') return; _regionMarkers[key] = createRegionMarker(key, region); }); // Restore persisted memories const restored = loadFromStorage(); console.info('[Mnemosyne] Spatial Memory Schema initialized —', Object.keys(REGIONS).length, 'regions,', restored, 'memories restored'); return REGIONS; } // ─── QUERY ─────────────────────────────────────────── function getMemoryAtPosition(position, maxDist) { maxDist = maxDist || 2; let closest = null; let closestDist = maxDist; Object.values(_memoryObjects).forEach(obj => { const d = obj.mesh.position.distanceTo(position); if (d < closestDist) { closest = obj; closestDist = d; } }); return closest; } function getRegionAtPosition(position) { for (const [key, region] of Object.entries(REGIONS)) { const dx = position.x - region.center[0]; const dz = position.z - region.center[2]; if (Math.sqrt(dx * dx + dz * dz) <= region.radius) return key; } return null; } function getMemoriesInRegion(regionKey) { return Object.values(_memoryObjects).filter(o => o.region === regionKey); } function getAllMemories() { return Object.values(_memoryObjects).map(o => o.data); } // ─── LOCALSTORAGE PERSISTENCE ──────────────────────── function _indexHash(index) { // Simple hash of memory IDs + count to detect changes const ids = (index.memories || []).map(m => m.id).sort().join(','); return index.memories.length + ':' + ids; } function saveToStorage() { if (typeof localStorage === 'undefined') { console.warn('[Mnemosyne] localStorage unavailable — skipping save'); return false; } try { const index = exportIndex(); const hash = _indexHash(index); if (hash === _lastSavedHash) return false; // no change const payload = JSON.stringify(index); localStorage.setItem(STORAGE_KEY, payload); _lastSavedHash = hash; _dirty = false; console.info('[Mnemosyne] Saved', index.memories.length, 'memories to localStorage'); return true; } catch (e) { if (e.name === 'QuotaExceededError' || e.code === 22) { console.warn('[Mnemosyne] localStorage quota exceeded — pruning archive memories'); _pruneArchiveMemories(); try { const index = exportIndex(); localStorage.setItem(STORAGE_KEY, JSON.stringify(index)); _lastSavedHash = _indexHash(index); console.info('[Mnemosyne] Saved after prune:', index.memories.length, 'memories'); return true; } catch (e2) { console.error('[Mnemosyne] Save failed even after prune:', e2); return false; } } console.error('[Mnemosyne] Save failed:', e); return false; } } function loadFromStorage() { if (typeof localStorage === 'undefined') { console.warn('[Mnemosyne] localStorage unavailable — starting empty'); return 0; } try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) { console.info('[Mnemosyne] No saved state found — starting fresh'); return 0; } const index = JSON.parse(raw); if (index.version !== STORAGE_VERSION) { console.warn('[Mnemosyne] Saved version mismatch (got', index.version, 'expected', + STORAGE_VERSION + ') — starting fresh'); return 0; } const count = importIndex(index); _lastSavedHash = _indexHash(index); return count; } catch (e) { console.error('[Mnemosyne] Load failed:', e); return 0; } } function _pruneArchiveMemories() { // Remove oldest archive-region memories first const archive = getMemoriesInRegion('archive'); const working = Object.values(_memoryObjects).filter(o => o.region !== 'archive'); // Sort archive by timestamp ascending (oldest first) archive.sort((a, b) => { const ta = a.data.timestamp || a.mesh.userData.createdAt || ''; const tb = b.data.timestamp || b.mesh.userData.createdAt || ''; return ta.localeCompare(tb); }); const toRemove = Math.max(1, Math.ceil(archive.length * 0.25)); for (let i = 0; i < toRemove && i < archive.length; i++) { removeMemory(archive[i].data.id); } console.info('[Mnemosyne] Pruned', toRemove, 'archive memories'); } function clearStorage() { if (typeof localStorage !== 'undefined') { localStorage.removeItem(STORAGE_KEY); _lastSavedHash = ''; console.info('[Mnemosyne] Cleared localStorage'); } } // ─── CONTEXT COMPACTION (issue #675) ────────────────── const COMPACT_CONTENT_MAXLEN = 80; // max chars for low-strength memories const COMPACT_STRENGTH_THRESHOLD = 0.5; // below this, content gets truncated const COMPACT_MAX_CONNECTIONS = 5; // cap connections per memory const COMPACT_POSITION_DECIMALS = 1; // round positions to 1 decimal function _compactPosition(pos) { const factor = Math.pow(10, COMPACT_POSITION_DECIMALS); return pos.map(v => Math.round(v * factor) / factor); } /** * Deterministically compact a memory for storage. * Same input always produces same output — no randomness. * Strong memories keep full fidelity; weak memories get truncated. */ function _compactMemory(o) { const strength = o.mesh.userData.strength || 0.7; const content = o.data.content || ''; const connections = o.data.connections || []; // Deterministic content truncation for weak memories let compactContent = content; if (strength < COMPACT_STRENGTH_THRESHOLD && content.length > COMPACT_CONTENT_MAXLEN) { compactContent = content.slice(0, COMPACT_CONTENT_MAXLEN) + '\u2026'; } // Cap connections (keep first N, deterministic) const compactConnections = connections.length > COMPACT_MAX_CONNECTIONS ? connections.slice(0, COMPACT_MAX_CONNECTIONS) : connections; return { id: o.data.id, content: compactContent, category: o.region, position: _compactPosition([o.mesh.position.x, o.mesh.position.y - 1.5, o.mesh.position.z]), source: o.data.source || 'unknown', timestamp: o.data.timestamp || o.mesh.userData.createdAt, strength: Math.round(strength * 100) / 100, // 2 decimal precision connections: compactConnections }; } // ─── PERSISTENCE ───────────────────────────────────── function exportIndex(options = {}) { const compact = options.compact !== false; // compact by default return { version: 1, exportedAt: new Date().toISOString(), compacted: compact, regions: Object.fromEntries( Object.entries(REGIONS).map(([k, v]) => [k, { label: v.label, center: v.center, radius: v.radius, color: v.color }]) ), memories: Object.values(_memoryObjects).map(o => compact ? _compactMemory(o) : { id: o.data.id, content: o.data.content, category: o.region, position: [o.mesh.position.x, o.mesh.position.y - 1.5, o.mesh.position.z], source: o.data.source || 'unknown', timestamp: o.data.timestamp || o.mesh.userData.createdAt, strength: o.mesh.userData.strength || 0.7, connections: o.data.connections || [] }) }; } function importIndex(index) { if (!index || !index.memories) return 0; let count = 0; index.memories.forEach(mem => { if (!_memoryObjects[mem.id]) { placeMemory(mem); count++; } }); console.info('[Mnemosyne] Restored', count, 'memories from index'); return count; } // ─── GRAVITY WELL CLUSTERING ────────────────────────── // Force-directed layout: same-category crystals attract, unrelated repel. // Run on load (bake positions, not per-frame). Spec from issue #1175. const GRAVITY_ITERATIONS = 20; const ATTRACT_FACTOR = 0.10; // 10% closer to same-category centroid per iteration const REPEL_FACTOR = 0.05; // 5% away from nearest unrelated crystal function runGravityLayout() { const objs = Object.values(_memoryObjects); if (objs.length < 2) { console.info('[Mnemosyne] Gravity layout: fewer than 2 crystals, skipping'); return; } console.info('[Mnemosyne] Gravity layout starting —', objs.length, 'crystals,', GRAVITY_ITERATIONS, 'iterations'); for (let iter = 0; iter < GRAVITY_ITERATIONS; iter++) { // Accumulate displacements before applying (avoids order-of-iteration bias) const dx = new Float32Array(objs.length); const dy = new Float32Array(objs.length); const dz = new Float32Array(objs.length); objs.forEach((obj, i) => { const pos = obj.mesh.position; const cat = obj.region; // ── Attraction toward same-category centroid ────────────── let sx = 0, sy = 0, sz = 0, sameCount = 0; objs.forEach(o => { if (o === obj || o.region !== cat) return; sx += o.mesh.position.x; sy += o.mesh.position.y; sz += o.mesh.position.z; sameCount++; }); if (sameCount > 0) { dx[i] += ((sx / sameCount) - pos.x) * ATTRACT_FACTOR; dy[i] += ((sy / sameCount) - pos.y) * ATTRACT_FACTOR; dz[i] += ((sz / sameCount) - pos.z) * ATTRACT_FACTOR; } // ── Repulsion from nearest unrelated crystal ─────────────── let nearestDist = Infinity; let rnx = 0, rny = 0, rnz = 0; objs.forEach(o => { if (o === obj || o.region === cat) return; const ex = pos.x - o.mesh.position.x; const ey = pos.y - o.mesh.position.y; const ez = pos.z - o.mesh.position.z; const d = Math.sqrt(ex * ex + ey * ey + ez * ez); if (d < nearestDist) { nearestDist = d; rnx = ex; rny = ey; rnz = ez; } }); if (nearestDist > 0.001 && nearestDist < Infinity) { const len = Math.sqrt(rnx * rnx + rny * rny + rnz * rnz); dx[i] += (rnx / len) * nearestDist * REPEL_FACTOR; dy[i] += (rny / len) * nearestDist * REPEL_FACTOR; dz[i] += (rnz / len) * nearestDist * REPEL_FACTOR; } }); // Apply displacements objs.forEach((obj, i) => { obj.mesh.position.x += dx[i]; obj.mesh.position.y += dy[i]; obj.mesh.position.z += dz[i]; }); } // Bake final positions to localStorage saveToStorage(); console.info('[Mnemosyne] Gravity layout complete — positions baked to localStorage'); } // ─── SPATIAL SEARCH ────────────────────────────────── function searchNearby(position, maxResults, maxDist) { maxResults = maxResults || 10; maxDist = maxDist || 30; const results = []; Object.values(_memoryObjects).forEach(obj => { const d = obj.mesh.position.distanceTo(position); if (d <= maxDist) results.push({ memory: obj.data, distance: d, position: obj.mesh.position.clone() }); }); results.sort((a, b) => a.distance - b.distance); return results.slice(0, maxResults); } // ─── CONTENT SEARCH ───────────────────────────────── /** * Search memories by text content — case-insensitive substring match. * @param {string} query - Search text * @param {object} [options] - Optional filters * @param {string} [options.category] - Restrict to a specific region * @param {number} [options.maxResults=20] - Cap results * @returns {Array<{memory: object, score: number, position: THREE.Vector3}>} */ function searchByContent(query, options = {}) { if (!query || !query.trim()) return []; const { category, maxResults = 20 } = options; const needle = query.trim().toLowerCase(); const results = []; Object.values(_memoryObjects).forEach(obj => { if (category && obj.region !== category) return; const content = (obj.data.content || '').toLowerCase(); if (!content.includes(needle)) return; // Score: number of occurrences + strength bonus let matches = 0, idx = 0; while ((idx = content.indexOf(needle, idx)) !== -1) { matches++; idx += needle.length; } const score = matches + (obj.mesh.userData.strength || 0.7); results.push({ memory: obj.data, score, position: obj.mesh.position.clone() }); }); results.sort((a, b) => b.score - a.score); return results.slice(0, maxResults); } // ─── CRYSTAL MESH COLLECTION (for raycasting) ──────── function getCrystalMeshes() { return Object.values(_memoryObjects).map(o => o.mesh); } // ─── MEMORY DATA FROM MESH ─────────────────────────── function getMemoryFromMesh(mesh) { const entry = Object.values(_memoryObjects).find(o => o.mesh === mesh); return entry ? { data: entry.data, region: entry.region } : null; } // ─── HIGHLIGHT / SELECT ────────────────────────────── let _selectedId = null; let _selectedOriginalEmissive = null; function highlightMemory(memId) { clearHighlight(); const obj = _memoryObjects[memId]; if (!obj) return; _selectedId = memId; _selectedOriginalEmissive = obj.mesh.material.emissiveIntensity; obj.mesh.material.emissiveIntensity = 4.0; obj.mesh.userData.selected = true; } function clearHighlight() { if (_selectedId && _memoryObjects[_selectedId]) { const obj = _memoryObjects[_selectedId]; obj.mesh.material.emissiveIntensity = _selectedOriginalEmissive || (obj.data.strength || 0.7) * 2.5; obj.mesh.userData.selected = false; } _selectedId = null; _selectedOriginalEmissive = null; } function getSelectedId() { return _selectedId; } // ─── CAMERA REFERENCE (for entity line LOD) ───────── function setCamera(camera) { _camera = camera; } return { init, placeMemory, removeMemory, update, importMemories, updateMemory, getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories, getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId, exportIndex, importIndex, searchNearby, searchByContent, REGIONS, saveToStorage, loadFromStorage, clearStorage, runGravityLayout, setCamera, toggleConstellation, isConstellationVisible }; })(); export { SpatialMemory };