Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
45163cf14e fix: [Mnemosyne] Memory persistence export — dump spatial state to JSON (closes #1174)
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 12s
Review Approval Gate / verify-review (pull_request) Failing after 3s
2026-04-10 20:10:45 -04:00
5 changed files with 149 additions and 209 deletions

27
app.js
View File

@@ -5,7 +5,6 @@ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { SpatialMemory } from './nexus/components/spatial-memory.js';
import { SessionRooms } from './nexus/components/session-rooms.js';
import { TimelineScrubber } from './nexus/components/timeline-scrubber.js';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -707,7 +706,6 @@ async function init() {
createWorkshopTerminal();
createAshStorm();
SpatialMemory.init(scene);
TimelineScrubber.init(SpatialMemory);
SessionRooms.init(scene, camera, null);
updateLoad(90);
@@ -2008,6 +2006,30 @@ function setupControls() {
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
// Mnemosyne export/import (#1174)
document.getElementById('mnemosyne-export-btn').addEventListener('click', () => {
const result = SpatialMemory.exportToFile();
if (result) {
addChatMessage('system', 'Mnemosyne: Exported ' + result.count + ' memories to ' + result.filename);
}
});
document.getElementById('mnemosyne-import-btn').addEventListener('click', () => {
document.getElementById('mnemosyne-import-file').click();
});
document.getElementById('mnemosyne-import-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const result = await SpatialMemory.importFromFile(file);
addChatMessage('system', 'Mnemosyne: Imported ' + result.count + ' of ' + result.total + ' memories');
} catch (err) {
addChatMessage('system', 'Mnemosyne: Import failed — ' + err.message);
}
e.target.value = '';
});
}
function sendChatMessage(overrideText = null) {
@@ -2833,7 +2855,6 @@ function gameLoop() {
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
TimelineScrubber.update();
animateMemoryOrbs(delta);
}

View File

@@ -233,6 +233,11 @@
<div class="memory-meta-row"><span class="memory-meta-label">Time</span><span id="memory-panel-time">\u2014</span></div>
<div class="memory-meta-row memory-meta-row--related"><span class="memory-meta-label">Related</span><span id="memory-panel-connections">\u2014</span></div>
</div>
<div class="memory-panel-actions">
<button id="mnemosyne-export-btn" class="mnemosyne-action-btn" title="Export spatial memory to JSON">&#x2913; Export</button>
<button id="mnemosyne-import-btn" class="mnemosyne-action-btn" title="Import spatial memory from JSON">&#x2912; Import</button>
<input type="file" id="mnemosyne-import-file" accept=".json" style="display:none;">
</div>
</div>
</div>

View File

@@ -652,11 +652,93 @@ 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);
});
}
return {
init, placeMemory, removeMemory, update,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
exportIndex, importIndex, searchNearby, REGIONS,
exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS,
saveToStorage, loadFromStorage, clearStorage,
runGravityLayout
};

View File

@@ -1,205 +0,0 @@
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — TIMELINE SCRUBBER
// ═══════════════════════════════════════════
//
// Horizontal timeline bar overlay for scrolling through fact history.
// Crystals outside the visible time window fade out.
//
// Issue: #1169
// ═══════════════════════════════════════════
const TimelineScrubber = (() => {
let _container = null;
let _bar = null;
let _handle = null;
let _labels = null;
let _spatialMemory = null;
let _rangeStart = 0; // 0-1 normalized
let _rangeEnd = 1; // 0-1 normalized
let _minTimestamp = null;
let _maxTimestamp = null;
let _active = false;
const PRESETS = {
'hour': { label: 'Last Hour', ms: 3600000 },
'day': { label: 'Last Day', ms: 86400000 },
'week': { label: 'Last Week', ms: 604800000 },
'all': { label: 'All Time', ms: Infinity }
};
// ─── INIT ──────────────────────────────────────────
function init(spatialMemory) {
_spatialMemory = spatialMemory;
_buildDOM();
_computeTimeRange();
console.info('[Mnemosyne] Timeline scrubber initialized');
}
function _buildDOM() {
_container = document.createElement('div');
_container.id = 'mnemosyne-timeline';
_container.style.cssText = `
position: fixed; bottom: 0; left: 0; right: 0; height: 48px;
background: rgba(5, 5, 16, 0.85); border-top: 1px solid #1a2a4a;
z-index: 1000; display: flex; align-items: center; padding: 0 16px;
font-family: monospace; font-size: 12px; color: #8899aa;
backdrop-filter: blur(8px); transition: opacity 0.3s;
`;
// Preset buttons
const presetDiv = document.createElement('div');
presetDiv.style.cssText = 'display: flex; gap: 8px; margin-right: 16px;';
Object.entries(PRESETS).forEach(([key, preset]) => {
const btn = document.createElement('button');
btn.textContent = preset.label;
btn.style.cssText = `
background: #0a0f28; border: 1px solid #1a2a4a; color: #4af0c0;
padding: 4px 8px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 3px; transition: background 0.2s;
`;
btn.onmouseenter = () => btn.style.background = '#1a2a4a';
btn.onmouseleave = () => btn.style.background = '#0a0f28';
btn.onclick = () => _applyPreset(key);
presetDiv.appendChild(btn);
});
_container.appendChild(presetDiv);
// Timeline bar
_bar = document.createElement('div');
_bar.style.cssText = `
flex: 1; height: 20px; background: #0a0f28; border: 1px solid #1a2a4a;
border-radius: 3px; position: relative; cursor: pointer; margin: 0 8px;
`;
// Handle (draggable range selector)
_handle = document.createElement('div');
_handle.style.cssText = `
position: absolute; top: 0; left: 0%; width: 100%; height: 100%;
background: rgba(74, 240, 192, 0.15); border-left: 2px solid #4af0c0;
border-right: 2px solid #4af0c0; cursor: ew-resize;
`;
_bar.appendChild(_handle);
_container.appendChild(_bar);
// Labels
_labels = document.createElement('div');
_labels.style.cssText = 'min-width: 200px; text-align: right; font-size: 11px;';
_labels.textContent = 'All Time';
_container.appendChild(_labels);
// Drag handling
let dragging = null;
_handle.addEventListener('mousedown', (e) => {
dragging = { startX: e.clientX, startLeft: parseFloat(_handle.style.left) || 0, startWidth: parseFloat(_handle.style.width) || 100 };
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const barRect = _bar.getBoundingClientRect();
const dx = (e.clientX - dragging.startX) / barRect.width * 100;
let newLeft = Math.max(0, Math.min(100 - dragging.startWidth, dragging.startLeft + dx));
_handle.style.left = newLeft + '%';
_rangeStart = newLeft / 100;
_rangeEnd = (newLeft + dragging.startWidth) / 100;
_applyFilter();
});
document.addEventListener('mouseup', () => { dragging = null; });
document.body.appendChild(_container);
}
function _computeTimeRange() {
if (!_spatialMemory) return;
const memories = _spatialMemory.getAllMemories();
if (memories.length === 0) return;
let min = Infinity, max = -Infinity;
memories.forEach(m => {
const t = new Date(m.timestamp || 0).getTime();
if (t < min) min = t;
if (t > max) max = t;
});
_minTimestamp = min;
_maxTimestamp = max;
}
function _applyPreset(key) {
const preset = PRESETS[key];
if (!preset) return;
if (preset.ms === Infinity) {
_rangeStart = 0;
_rangeEnd = 1;
} else {
const now = Date.now();
const range = _maxTimestamp - _minTimestamp;
if (range <= 0) return;
const cutoff = now - preset.ms;
_rangeStart = Math.max(0, (cutoff - _minTimestamp) / range);
_rangeEnd = 1;
}
_handle.style.left = (_rangeStart * 100) + '%';
_handle.style.width = ((_rangeEnd - _rangeStart) * 100) + '%';
_labels.textContent = preset.label;
_applyFilter();
}
function _applyFilter() {
if (!_spatialMemory) return;
const range = _maxTimestamp - _minTimestamp;
if (range <= 0) return;
const startMs = _minTimestamp + range * _rangeStart;
const endMs = _minTimestamp + range * _rangeEnd;
_spatialMemory.getCrystalMeshes().forEach(mesh => {
const ts = new Date(mesh.userData.createdAt || 0).getTime();
if (ts >= startMs && ts <= endMs) {
mesh.visible = true;
// Smooth restore
if (mesh.material) mesh.material.opacity = mesh.userData._savedOpacity || mesh.material.opacity;
} else {
// Fade out
if (mesh.material) {
mesh.userData._savedOpacity = mesh.userData._savedOpacity || mesh.material.opacity;
mesh.material.opacity = 0.02;
}
}
});
// Update label with date range
const startStr = new Date(startMs).toLocaleDateString();
const endStr = new Date(endMs).toLocaleDateString();
_labels.textContent = startStr + ' — ' + endStr;
}
function update() {
_computeTimeRange();
}
function show() {
if (_container) _container.style.display = 'flex';
_active = true;
}
function hide() {
if (_container) _container.style.display = 'none';
_active = false;
// Restore all crystals
if (_spatialMemory) {
_spatialMemory.getCrystalMeshes().forEach(mesh => {
mesh.visible = true;
if (mesh.material && mesh.userData._savedOpacity) {
mesh.material.opacity = mesh.userData._savedOpacity;
}
});
}
}
function isActive() { return _active; }
return { init, update, show, hide, isActive };
})();
export { TimelineScrubber };

View File

@@ -1461,6 +1461,43 @@ canvas#nexus-canvas {
gap: 2px;
}
/* ═══════════════════════════════════════════════════════
PROJECT MNEMOSYNE — EXPORT/IMPORT ACTIONS (#1174)
═══════════════════════════════════════════════════════ */
.memory-panel-actions {
display: flex;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(123, 92, 255, 0.15);
}
.mnemosyne-action-btn {
flex: 1;
padding: 6px 10px;
background: rgba(123, 92, 255, 0.12);
border: 1px solid rgba(123, 92, 255, 0.3);
border-radius: 6px;
color: #a08cff;
font-size: 11px;
font-family: monospace;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
white-space: nowrap;
}
.mnemosyne-action-btn:hover {
background: rgba(123, 92, 255, 0.25);
border-color: rgba(123, 92, 255, 0.6);
color: #c4b5ff;
}
.mnemosyne-action-btn:active {
transform: scale(0.96);
}
/* ═══════════════════════════════════════════════════════
PROJECT MNEMOSYNE — SESSION ROOM HUD PANEL (#1171)
═══════════════════════════════════════════════════════ */