1043 lines
37 KiB
JavaScript
1043 lines
37 KiB
JavaScript
// ═══
|
|
// ─── 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 };
|