Compare commits
19 Commits
mimo/code/
...
feat/spati
| Author | SHA1 | Date | |
|---|---|---|---|
| 72eecf6ee4 | |||
| 0bd3e1f470 | |||
| b73d846334 | |||
| 4c267de5bc | |||
| 684f648027 | |||
| e842e35833 | |||
| 065e83c94e | |||
| cc4af009c7 | |||
| 089b06b6f8 | |||
| 8beae5ecc1 | |||
| e2edfd3318 | |||
| 8e18fa5311 | |||
| 1bf2af15a0 | |||
| 4095946749 | |||
|
|
845e2f2ced | ||
| c387708892 | |||
| 8694c0f5ad | |||
| c3547196d8 | |||
| 87bfe9b332 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ nexus/__pycache__/
|
||||
tests/__pycache__/
|
||||
mempalace/__pycache__/
|
||||
.aider*
|
||||
|
||||
# Prevent agents from writing to wrong path (see issue #1145)
|
||||
public/nexus/
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -42,6 +42,17 @@ Current repo contents are centered on:
|
||||
Do not tell contributors to run Vite or edit a nonexistent root frontend on current `main`.
|
||||
If browser/UI work is being restored, it must happen through the migration backlog and land back here.
|
||||
|
||||
## Canonical File Paths
|
||||
|
||||
**Frontend code lives at repo ROOT, NOT in `public/nexus/`:**
|
||||
- `app.js` — main Three.js app (GOFAI, 3D world, all frontend logic)
|
||||
- `index.html` — main HTML shell
|
||||
- `style.css` — styles
|
||||
- `server.py` — websocket bridge
|
||||
- `gofai_worker.js` — web worker for off-thread reasoning
|
||||
|
||||
**DO NOT write to `public/nexus/`** — this path is gitignored. Agents historically wrote here by mistake, creating corrupt duplicates. See issue #1145 and `INVESTIGATION_ISSUE_1145.md`.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
1. One canonical 3D repo only: `Timmy_Foundation/the-nexus`
|
||||
@@ -50,6 +61,7 @@ If browser/UI work is being restored, it must happen through the migration backl
|
||||
4. Telemetry and durable truth flow through Hermes harness
|
||||
5. OpenClaw remains a sidecar, not the governing authority
|
||||
6. Before claiming visual validation, prove the app being viewed actually comes from current `the-nexus`
|
||||
7. **NEVER write frontend files to `public/nexus/`** — use repo root paths listed above
|
||||
|
||||
## Validation Rule
|
||||
|
||||
|
||||
72
INVESTIGATION_ISSUE_1145.md
Normal file
72
INVESTIGATION_ISSUE_1145.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Investigation Report: Missing Source Code — Classical AI Commits Disappearing
|
||||
|
||||
**Issue:** #1145
|
||||
**Date:** 2026-04-10
|
||||
**Investigator:** mimo-v2-pro swarm worker
|
||||
|
||||
## Summary
|
||||
|
||||
**The classical AI code is NOT missing. It is fully present in root `app.js` (3302 lines).**
|
||||
|
||||
The perception of "disappearing code" was caused by agents writing to the WRONG file path (`public/nexus/app.js` instead of root `app.js`), creating corrupt duplicate files that were repeatedly overwritten and eventually deleted.
|
||||
|
||||
## Root Cause
|
||||
|
||||
**Explanation #1 confirmed: Duplicate agents on different machines overwriting each other's commits.**
|
||||
|
||||
Multiple Google AI Agent instances wrote GOFAI implementations to `public/nexus/app.js` — a path that does not correspond to the canonical app structure. These commits kept overwriting each other:
|
||||
|
||||
| Commit | Date | What happened |
|
||||
|--------|------|---------------|
|
||||
| `8943cf5` | 2026-03-30 | Symbolic reasoning engine written to `public/nexus/app.js` (+2280 lines) |
|
||||
| `e2df240` | 2026-03-30 | Phase 3 Neuro-Symbolic Bridge — overwrote to 284 lines of HTML (wrong path) |
|
||||
| `7f2f23f` | 2026-03-30 | Phase 4 Meta-Reasoning — same destructive overwrite |
|
||||
| `bf3b98b` | 2026-03-30 | A* Search — same destructive overwrite |
|
||||
| `e88bcb4` | 2026-03-30 | Bug fix identified `public/nexus/` files as corrupt duplicates, **deleted them** |
|
||||
|
||||
## Evidence: Code Is Present on Main
|
||||
|
||||
All 13 classical AI classes/functions verified present in root `app.js`:
|
||||
|
||||
| Class/Function | Line | Status |
|
||||
|----------------|------|--------|
|
||||
| `SymbolicEngine` | 82 | ✅ Present |
|
||||
| `AgentFSM` | 135 | ✅ Present |
|
||||
| `KnowledgeGraph` | 160 | ✅ Present |
|
||||
| `Blackboard` | 181 | ✅ Present |
|
||||
| `SymbolicPlanner` | 210 | ✅ Present |
|
||||
| `HTNPlanner` | 295 | ✅ Present |
|
||||
| `CaseBasedReasoner` | 343 | ✅ Present |
|
||||
| `NeuroSymbolicBridge` | 392 | ✅ Present |
|
||||
| `MetaReasoningLayer` | 422 | ✅ Present |
|
||||
| `AdaptiveCalibrator` | 460 | ✅ Present |
|
||||
| `PSELayer` | 566 | ✅ Present |
|
||||
| `setupGOFAI()` | 596 | ✅ Present |
|
||||
| `updateGOFAI()` | 622 | ✅ Present |
|
||||
| Bitmask fact indexing | 86 | ✅ Present |
|
||||
| A* search | 231 | ✅ Present |
|
||||
|
||||
These were injected by commit `af7a4c4` (PR #775, merged via `a855d54`) into the correct path.
|
||||
|
||||
## What Actually Happened
|
||||
|
||||
1. Google AI Agent wrote good GOFAI code to root `app.js` via the correct PR (#775)
|
||||
2. A second wave of Google AI Agent instances also wrote to `public/nexus/app.js` (wrong path)
|
||||
3. Those `public/nexus/` files kept getting overwritten by subsequent agent commits
|
||||
4. Commit `e88bcb4` correctly identified the `public/nexus/` files as corrupt and deleted them
|
||||
5. Alexander interpreted the git log as "classical AI code keeps disappearing"
|
||||
6. The code was never actually gone — it just lived in root `app.js` the whole time
|
||||
|
||||
## Prevention Strategy
|
||||
|
||||
1. **Add `public/nexus/` to `.gitignore`** — prevents agents from accidentally writing to the wrong path again
|
||||
2. **Add canonical path documentation to CLAUDE.md** — any agent reading this repo will know where frontend code lives
|
||||
3. **This report** — serves as the audit trail so this confusion doesn't recur
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Git history audited for classical AI commits
|
||||
- [x] Found the commits — they exist, code was written to wrong path
|
||||
- [x] Root cause identified — duplicate agents writing to `public/nexus/` (wrong path)
|
||||
- [x] Prevention strategy implemented — `.gitignore` + `CLAUDE.md` path guard
|
||||
- [x] Report filed with findings (this document)
|
||||
528
app.js
528
app.js
@@ -1,9 +1,12 @@
|
||||
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
|
||||
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
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';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
@@ -705,6 +708,7 @@ async function init() {
|
||||
createWorkshopTerminal();
|
||||
createAshStorm();
|
||||
SpatialMemory.init(scene);
|
||||
SessionRooms.init(scene, camera, null);
|
||||
updateLoad(90);
|
||||
|
||||
loadSession();
|
||||
@@ -1883,7 +1887,7 @@ function setupControls() {
|
||||
orbitState.lastX = e.clientX;
|
||||
orbitState.lastY = e.clientY;
|
||||
|
||||
// Raycasting for portals
|
||||
// Raycasting for portals and memory crystals
|
||||
if (!portalOverlayActive) {
|
||||
const mouse = new THREE.Vector2(
|
||||
(e.clientX / window.innerWidth) * 2 - 1,
|
||||
@@ -1891,12 +1895,43 @@ function setupControls() {
|
||||
);
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(portals.map(p => p.ring));
|
||||
if (intersects.length > 0) {
|
||||
const clickedRing = intersects[0].object;
|
||||
|
||||
// Priority 1: Portals
|
||||
const portalHits = raycaster.intersectObjects(portals.map(p => p.ring));
|
||||
if (portalHits.length > 0) {
|
||||
const clickedRing = portalHits[0].object;
|
||||
const portal = portals.find(p => p.ring === clickedRing);
|
||||
if (portal) activatePortal(portal);
|
||||
if (portal) { activatePortal(portal); return; }
|
||||
}
|
||||
|
||||
// Priority 2: Memory crystals (Mnemosyne)
|
||||
const crystalMeshes = SpatialMemory.getCrystalMeshes();
|
||||
if (crystalMeshes.length > 0) {
|
||||
const crystalHits = raycaster.intersectObjects(crystalMeshes, false);
|
||||
if (crystalHits.length > 0) {
|
||||
const hitMesh = crystalHits[0].object;
|
||||
const memInfo = SpatialMemory.getMemoryFromMesh(hitMesh);
|
||||
if (memInfo) {
|
||||
SpatialMemory.highlightMemory(memInfo.data.id);
|
||||
showMemoryPanel(memInfo, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Session rooms (Mnemosyne #1171)
|
||||
const roomMeshes = SessionRooms.getClickableMeshes();
|
||||
if (roomMeshes.length > 0) {
|
||||
const roomHits = raycaster.intersectObjects(roomMeshes, false);
|
||||
if (roomHits.length > 0) {
|
||||
const session = SessionRooms.handleRoomClick(roomHits[0].object);
|
||||
if (session) { _showSessionRoomPanel(session); return; }
|
||||
}
|
||||
}
|
||||
|
||||
// Clicked empty space — dismiss panel
|
||||
dismissMemoryPanel();
|
||||
_dismissSessionRoomPanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1949,30 +1984,97 @@ function setupControls() {
|
||||
document.getElementById('chat-quick-actions').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.quick-action-btn');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
|
||||
switch(action) {
|
||||
case 'status':
|
||||
sendChatMessage("Timmy, what is the current system status?");
|
||||
break;
|
||||
case 'agents':
|
||||
sendChatMessage("Timmy, check on all active agents.");
|
||||
break;
|
||||
case 'portals':
|
||||
openPortalAtlas();
|
||||
break;
|
||||
case 'help':
|
||||
sendChatMessage("Timmy, I need assistance with Nexus navigation.");
|
||||
break;
|
||||
}
|
||||
handleQuickAction(btn.dataset.action);
|
||||
});
|
||||
|
||||
// ═══ QUICK ACTION HANDLER ═══
|
||||
function handleQuickAction(action) {
|
||||
switch(action) {
|
||||
case 'status': {
|
||||
const portalCount = portals.length;
|
||||
const onlinePortals = portals.filter(p => p.userData && p.userData.status === 'online').length;
|
||||
const agentCount = agents.length;
|
||||
const wsState = wsConnected ? 'ONLINE' : 'OFFLINE';
|
||||
const wsColor = wsConnected ? '#4af0c0' : '#ff4466';
|
||||
addChatMessage('system', `[SYSTEM STATUS]`);
|
||||
addChatMessage('timmy', `Nexus operational. ${portalCount} portals registered (${onlinePortals} online). ${agentCount} agent presences active. Hermes WebSocket: ${wsState}. Navigation mode: ${NAV_MODES[navModeIdx].toUpperCase()}. Performance tier: ${performanceTier.toUpperCase()}.`);
|
||||
break;
|
||||
}
|
||||
case 'agents': {
|
||||
addChatMessage('system', `[AGENT ROSTER]`);
|
||||
if (agents.length === 0) {
|
||||
addChatMessage('timmy', 'No active agent presences detected in the Nexus. The thought stream and harness pulse are the primary indicators of system activity.');
|
||||
} else {
|
||||
const roster = agents.map(a => `- ${(a.userData && a.userData.name) || a.name || 'Unknown'}: ${(a.userData && a.userData.status) || 'active'}`).join('\n');
|
||||
addChatMessage('timmy', `Active agents:\n${roster}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'portals':
|
||||
openPortalAtlas();
|
||||
break;
|
||||
case 'heartbeat': {
|
||||
const agentLog = document.getElementById('agent-log-content');
|
||||
const recentEntries = agentLog ? agentLog.querySelectorAll('.agent-log-entry') : [];
|
||||
const entryCount = recentEntries.length;
|
||||
addChatMessage('system', `[HEARTBEAT INSPECTION]`);
|
||||
addChatMessage('timmy', `Hermes heartbeat ${wsConnected ? 'active' : 'inactive'}. ${entryCount} recent entries in thought stream. WebSocket reconnect timer: ${wsReconnectTimer ? 'active' : 'idle'}. Harness pulse mesh: ${harnessPulseMesh ? 'rendering' : 'standby'}.`);
|
||||
break;
|
||||
}
|
||||
case 'thoughts': {
|
||||
const agentLog = document.getElementById('agent-log-content');
|
||||
const entries = agentLog ? Array.from(agentLog.querySelectorAll('.agent-log-entry')).slice(0, 5) : [];
|
||||
addChatMessage('system', `[THOUGHT STREAM]`);
|
||||
if (entries.length === 0) {
|
||||
addChatMessage('timmy', 'The thought stream is quiet. No recent agent entries detected.');
|
||||
} else {
|
||||
const summary = entries.map(e => '> ' + e.textContent.trim()).join('\n');
|
||||
addChatMessage('timmy', `Recent thoughts:\n${summary}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'help': {
|
||||
addChatMessage('system', `[NEXUS HELP]`);
|
||||
addChatMessage('timmy', `Navigation: WASD to move, mouse to look around.\n` +
|
||||
`Press V to cycle: Walk / Orbit / Fly mode.\n` +
|
||||
`Enter to chat. Escape to close overlays.\n` +
|
||||
`Press F near a portal to enter. Press E near a vision point to read.\n` +
|
||||
`Press Tab for Portal Atlas.\n` +
|
||||
`The Batcave Terminal shows system logs. The Workshop Terminal shows tool output.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('portal-close-btn').addEventListener('click', closePortalOverlay);
|
||||
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
|
||||
|
||||
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) {
|
||||
@@ -2551,6 +2653,226 @@ function focusPortal(portal) {
|
||||
let lastThoughtTime = 0;
|
||||
let pulseTimer = 0;
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// MNEMOSYNE — MEMORY CRYSTAL INSPECTION
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// ── pin state for memory panel ──
|
||||
let _memPanelPinned = false;
|
||||
|
||||
/** Convert a packed hex color integer to "r,g,b" string for CSS rgba(). */
|
||||
function _hexToRgb(hex) {
|
||||
return ((hex >> 16) & 255) + ',' + ((hex >> 8) & 255) + ',' + (hex & 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the panel near the screen click coordinates, keeping it on-screen.
|
||||
*/
|
||||
function _positionPanel(panel, clickX, clickY) {
|
||||
const W = window.innerWidth;
|
||||
const H = window.innerHeight;
|
||||
const panelW = 356; // matches CSS width + padding
|
||||
const panelH = 420; // generous estimate
|
||||
const margin = 12;
|
||||
|
||||
let left = clickX + 24;
|
||||
if (left + panelW > W - margin) left = clickX - panelW - 24;
|
||||
left = Math.max(margin, Math.min(W - panelW - margin, left));
|
||||
|
||||
let top = clickY - 80;
|
||||
top = Math.max(margin, Math.min(H - panelH - margin, top));
|
||||
|
||||
panel.style.right = 'auto';
|
||||
panel.style.top = top + 'px';
|
||||
panel.style.left = left + 'px';
|
||||
panel.style.transform = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to (highlight + show panel for) a memory crystal by id.
|
||||
*/
|
||||
function _navigateToMemory(memId) {
|
||||
SpatialMemory.highlightMemory(memId);
|
||||
addChatMessage('system', `Focus: ${memId.replace(/_/g, ' ')}`);
|
||||
const meshes = SpatialMemory.getCrystalMeshes();
|
||||
for (const mesh of meshes) {
|
||||
if (mesh.userData && mesh.userData.memId === memId) {
|
||||
const memInfo = SpatialMemory.getMemoryFromMesh(mesh);
|
||||
if (memInfo) { showMemoryPanel(memInfo); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the holographic detail panel for a clicked crystal.
|
||||
* @param {object} memInfo — { data, region } from SpatialMemory.getMemoryFromMesh()
|
||||
* @param {number} [clickX] — screen X of the click (for panel positioning)
|
||||
* @param {number} [clickY] — screen Y of the click
|
||||
*/
|
||||
function showMemoryPanel(memInfo, clickX, clickY) {
|
||||
const panel = document.getElementById('memory-panel');
|
||||
if (!panel) return;
|
||||
|
||||
const { data, region } = memInfo;
|
||||
const regionDef = SpatialMemory.REGIONS[region] || SpatialMemory.REGIONS.working;
|
||||
const colorHex = regionDef.color.toString(16).padStart(6, '0');
|
||||
const colorRgb = _hexToRgb(regionDef.color);
|
||||
|
||||
// Header — region dot + label
|
||||
document.getElementById('memory-panel-region').textContent = regionDef.label;
|
||||
document.getElementById('memory-panel-region-dot').style.background = '#' + colorHex;
|
||||
|
||||
// Category badge
|
||||
const badge = document.getElementById('memory-panel-category-badge');
|
||||
if (badge) {
|
||||
badge.textContent = (data.category || region || 'memory').toUpperCase();
|
||||
badge.style.background = 'rgba(' + colorRgb + ',0.16)';
|
||||
badge.style.color = '#' + colorHex;
|
||||
badge.style.borderColor = 'rgba(' + colorRgb + ',0.4)';
|
||||
}
|
||||
|
||||
// Entity name (humanised id)
|
||||
const entityEl = document.getElementById('memory-panel-entity-name');
|
||||
if (entityEl) entityEl.textContent = (data.id || '\u2014').replace(/_/g, ' ');
|
||||
|
||||
// Fact content
|
||||
document.getElementById('memory-panel-content').textContent = data.content || '(empty)';
|
||||
|
||||
// Trust score bar
|
||||
const strength = data.strength != null ? data.strength : 0.7;
|
||||
const trustFill = document.getElementById('memory-panel-trust-fill');
|
||||
const trustVal = document.getElementById('memory-panel-trust-value');
|
||||
if (trustFill) {
|
||||
trustFill.style.width = (strength * 100).toFixed(0) + '%';
|
||||
trustFill.style.background = '#' + colorHex;
|
||||
}
|
||||
if (trustVal) trustVal.textContent = (strength * 100).toFixed(0) + '%';
|
||||
|
||||
// Meta rows
|
||||
document.getElementById('memory-panel-id').textContent = data.id || '\u2014';
|
||||
document.getElementById('memory-panel-source').textContent = data.source || 'unknown';
|
||||
document.getElementById('memory-panel-time').textContent = data.timestamp ? new Date(data.timestamp).toLocaleString() : '\u2014';
|
||||
|
||||
// Related entities — clickable links
|
||||
const connEl = document.getElementById('memory-panel-connections');
|
||||
connEl.innerHTML = '';
|
||||
if (data.connections && data.connections.length > 0) {
|
||||
data.connections.forEach(cid => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'memory-conn-tag memory-conn-link';
|
||||
btn.textContent = cid.replace(/_/g, ' ');
|
||||
btn.title = 'Go to: ' + cid;
|
||||
btn.addEventListener('click', (ev) => { ev.stopPropagation(); _navigateToMemory(cid); });
|
||||
connEl.appendChild(btn);
|
||||
});
|
||||
} else {
|
||||
connEl.innerHTML = '<span style="color:var(--color-text-muted)">None</span>';
|
||||
}
|
||||
|
||||
// Pin button — reset on fresh open
|
||||
_memPanelPinned = false;
|
||||
const pinBtn = document.getElementById('memory-panel-pin');
|
||||
if (pinBtn) {
|
||||
pinBtn.classList.remove('pinned');
|
||||
pinBtn.title = 'Pin panel';
|
||||
pinBtn.onclick = () => {
|
||||
_memPanelPinned = !_memPanelPinned;
|
||||
pinBtn.classList.toggle('pinned', _memPanelPinned);
|
||||
pinBtn.title = _memPanelPinned ? 'Unpin panel' : 'Pin panel';
|
||||
};
|
||||
}
|
||||
|
||||
// Positioning — near click if coords provided
|
||||
if (clickX != null && clickY != null) {
|
||||
_positionPanel(panel, clickX, clickY);
|
||||
}
|
||||
|
||||
// Fade in
|
||||
panel.classList.remove('memory-panel-fade-out');
|
||||
panel.style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the panel (respects pin). Called on empty-space click.
|
||||
*/
|
||||
function dismissMemoryPanel() {
|
||||
if (_memPanelPinned) return;
|
||||
_dismissMemoryPanelForce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-dismiss the panel regardless of pin state. Used by the close button.
|
||||
*/
|
||||
function _dismissMemoryPanelForce() {
|
||||
_memPanelPinned = false;
|
||||
SpatialMemory.clearHighlight();
|
||||
const panel = document.getElementById('memory-panel');
|
||||
if (!panel || panel.style.display === 'none') return;
|
||||
panel.classList.add('memory-panel-fade-out');
|
||||
setTimeout(() => {
|
||||
panel.style.display = 'none';
|
||||
panel.classList.remove('memory-panel-fade-out');
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the session room HUD panel when a chamber is entered.
|
||||
* @param {object} session — { id, timestamp, facts[] }
|
||||
*/
|
||||
function _showSessionRoomPanel(session) {
|
||||
const panel = document.getElementById('session-room-panel');
|
||||
if (!panel) return;
|
||||
|
||||
const dt = session.timestamp ? new Date(session.timestamp) : new Date();
|
||||
const tsEl = document.getElementById('session-room-timestamp');
|
||||
if (tsEl) tsEl.textContent = isNaN(dt.getTime()) ? session.id : dt.toLocaleString();
|
||||
|
||||
const countEl = document.getElementById('session-room-fact-count');
|
||||
const facts = session.facts || [];
|
||||
if (countEl) countEl.textContent = facts.length + (facts.length === 1 ? ' fact' : ' facts') + ' in this chamber';
|
||||
|
||||
const listEl = document.getElementById('session-room-facts');
|
||||
if (listEl) {
|
||||
listEl.innerHTML = '';
|
||||
facts.slice(0, 8).forEach(f => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'session-room-fact-item';
|
||||
item.textContent = f.content || f.id || '(unknown)';
|
||||
item.title = f.content || '';
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
if (facts.length > 8) {
|
||||
const more = document.createElement('div');
|
||||
more.className = 'session-room-fact-item';
|
||||
more.style.color = 'rgba(200,180,255,0.4)';
|
||||
more.textContent = '\u2026 ' + (facts.length - 8) + ' more';
|
||||
listEl.appendChild(more);
|
||||
}
|
||||
}
|
||||
|
||||
// Close button
|
||||
const closeBtn = document.getElementById('session-room-close');
|
||||
if (closeBtn) closeBtn.onclick = () => _dismissSessionRoomPanel();
|
||||
|
||||
panel.classList.remove('session-panel-fade-out');
|
||||
panel.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the session room panel.
|
||||
*/
|
||||
function _dismissSessionRoomPanel() {
|
||||
const panel = document.getElementById('session-room-panel');
|
||||
if (!panel || panel.style.display === 'none') return;
|
||||
panel.classList.add('session-panel-fade-out');
|
||||
setTimeout(() => {
|
||||
panel.style.display = 'none';
|
||||
panel.classList.remove('session-panel-fade-out');
|
||||
}, 200);
|
||||
}
|
||||
|
||||
|
||||
function gameLoop() {
|
||||
requestAnimationFrame(gameLoop);
|
||||
const delta = Math.min(clock.getDelta(), 0.1);
|
||||
@@ -2581,6 +2903,9 @@ function gameLoop() {
|
||||
animateMemoryOrbs(delta);
|
||||
}
|
||||
|
||||
// Project Mnemosyne - Session Rooms (#1171)
|
||||
SessionRooms.update(delta);
|
||||
|
||||
|
||||
const mode = NAV_MODES[navModeIdx];
|
||||
const chatActive = document.activeElement === document.getElementById('chat-input');
|
||||
@@ -3104,9 +3429,168 @@ init().then(() => {
|
||||
{ id: 'mem_hermes_chat', content: 'First conversation through the Hermes gateway', category: 'social', strength: 0.7, connections: [] },
|
||||
{ id: 'mem_mnemosyne_start', content: 'Project Mnemosyne began — the living archive awakens', category: 'projects', strength: 0.9, connections: ['mem_nexus_birth', 'mem_spatial_schema'] },
|
||||
{ id: 'mem_spatial_schema', content: 'Spatial Memory Schema defined — memories gain permanent homes', category: 'engineering', strength: 0.8, connections: ['mem_mnemosyne_start'] },
|
||||
// MemPalace category zone demos — issue #1168
|
||||
{ id: 'mem_pref_dark_mode', content: 'User prefers dark mode and monospace fonts', category: 'user_pref', strength: 0.9, connections: [] },
|
||||
{ id: 'mem_pref_verbose_logs', content: 'User prefers verbose logging during debug sessions', category: 'user_pref', strength: 0.7, connections: [] },
|
||||
{ id: 'mem_proj_nexus_goal', content: 'The Nexus goal: local-first 3D training ground for Timmy', category: 'project', strength: 0.95, connections: ['mem_proj_mnemosyne'] },
|
||||
{ id: 'mem_proj_mnemosyne', content: 'Project Mnemosyne: holographic living archive of facts', category: 'project', strength: 0.85, connections: ['mem_proj_nexus_goal'] },
|
||||
{ id: 'mem_tool_three_js', content: 'Three.js — 3D rendering library used for the Nexus world', category: 'tool', strength: 0.8, connections: [] },
|
||||
{ id: 'mem_tool_gitea', content: 'Gitea API at forge.alexanderwhitestone.com for issue tracking', category: 'tool', strength: 0.75, connections: [] },
|
||||
{ id: 'mem_gen_websocket', content: 'WebSocket bridge (server.py) connects Timmy cognition to the browser', category: 'general', strength: 0.7, connections: [] },
|
||||
{ id: 'mem_gen_hermes', content: 'Hermes harness: telemetry and durable truth pipeline', category: 'general', strength: 0.65, connections: [] },
|
||||
];
|
||||
demoMemories.forEach(m => SpatialMemory.placeMemory(m));
|
||||
|
||||
// Gravity well clustering — attract related crystals, bake positions (issue #1175)
|
||||
SpatialMemory.runGravityLayout();
|
||||
|
||||
|
||||
// ═══ SPATIAL SEARCH (Mnemosyne #1170) ═══
|
||||
(() => {
|
||||
const input = document.getElementById('spatial-search-input');
|
||||
const resultsDiv = document.getElementById('spatial-search-results');
|
||||
if (!input || !resultsDiv) return;
|
||||
|
||||
let searchTimeout = null;
|
||||
let currentMatches = [];
|
||||
|
||||
function runSearch(query) {
|
||||
if (!query.trim()) {
|
||||
SpatialMemory.clearSearch();
|
||||
resultsDiv.classList.remove('visible');
|
||||
resultsDiv.innerHTML = '';
|
||||
currentMatches = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = SpatialMemory.searchContent(query);
|
||||
currentMatches = matches;
|
||||
|
||||
if (matches.length === 0) {
|
||||
SpatialMemory.clearSearch();
|
||||
resultsDiv.innerHTML = '<div class="spatial-search-count">No matches</div>';
|
||||
resultsDiv.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
SpatialMemory.highlightSearchResults(matches);
|
||||
|
||||
// Build results list
|
||||
const allMems = SpatialMemory.getAllMemories();
|
||||
let html = `<div class="spatial-search-count">${matches.length} match${matches.length > 1 ? 'es' : ''}</div>`;
|
||||
matches.forEach(id => {
|
||||
const mem = allMems.find(m => m.id === id);
|
||||
if (mem) {
|
||||
const label = (mem.content || id).slice(0, 60);
|
||||
const region = mem.category || '?';
|
||||
html += `<div class="spatial-search-result-item" data-mem-id="${id}">
|
||||
<span class="result-region">[${region}]</span>${label}
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
resultsDiv.innerHTML = html;
|
||||
resultsDiv.classList.add('visible');
|
||||
|
||||
// Click handler for result items
|
||||
resultsDiv.querySelectorAll('.spatial-search-result-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const memId = el.getAttribute('data-mem-id');
|
||||
flyToMemory(memId);
|
||||
});
|
||||
});
|
||||
|
||||
// Fly camera to first match
|
||||
if (matches.length > 0) {
|
||||
flyToMemory(matches[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function flyToMemory(memId) {
|
||||
const pos = SpatialMemory.getSearchMatchPosition(memId);
|
||||
if (!pos) return;
|
||||
|
||||
// Smooth camera fly-to: place camera above and in front of crystal
|
||||
const targetPos = new THREE.Vector3(pos.x, pos.y + 4, pos.z + 6);
|
||||
|
||||
// Use simple lerp animation over ~800ms
|
||||
const startPos = playerPos.clone();
|
||||
const startTime = performance.now();
|
||||
const duration = 800;
|
||||
|
||||
function animateCamera(now) {
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(1, elapsed / duration);
|
||||
// Ease out cubic
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
|
||||
playerPos.lerpVectors(startPos, targetPos, ease);
|
||||
camera.position.copy(playerPos);
|
||||
|
||||
// Look at crystal
|
||||
const lookTarget = pos.clone();
|
||||
lookTarget.y += 1.5;
|
||||
camera.lookAt(lookTarget);
|
||||
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(animateCamera);
|
||||
} else {
|
||||
SpatialMemory.highlightMemory(memId);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animateCamera);
|
||||
}
|
||||
|
||||
// Debounced input handler
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => runSearch(input.value), 200);
|
||||
});
|
||||
|
||||
// Escape clears search
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
SpatialMemory.clearSearch();
|
||||
resultsDiv.classList.remove('visible');
|
||||
resultsDiv.innerHTML = '';
|
||||
currentMatches = [];
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
// Project Mnemosyne — seed demo session rooms (#1171)
|
||||
// Sessions group facts by conversation/work session with a timestamp.
|
||||
const demoSessions = [
|
||||
{
|
||||
id: 'session_2026_03_01',
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
facts: [
|
||||
{ id: 'mem_nexus_birth', content: 'The Nexus came online — first render of the 3D world', category: 'knowledge', strength: 0.95 },
|
||||
{ id: 'mem_mnemosyne_start', content: 'Project Mnemosyne began — the living archive awakens', category: 'projects', strength: 0.9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'session_2026_03_15',
|
||||
timestamp: '2026-03-15T14:30:00.000Z',
|
||||
facts: [
|
||||
{ id: 'mem_first_portal', content: 'First portal deployed — connection to external service', category: 'engineering', strength: 0.85 },
|
||||
{ id: 'mem_hermes_chat', content: 'First conversation through the Hermes gateway', category: 'social', strength: 0.7 },
|
||||
{ id: 'mem_spatial_schema', content: 'Spatial Memory Schema defined — memories gain homes', category: 'engineering', strength: 0.8 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'session_2026_04_10',
|
||||
timestamp: '2026-04-10T09:00:00.000Z',
|
||||
facts: [
|
||||
{ id: 'mem_session_rooms', content: 'Session rooms introduced — holographic chambers per session', category: 'projects', strength: 0.88 },
|
||||
{ id: 'mem_gravity_wells', content: 'Gravity-well clustering bakes crystal positions on load', category: 'engineering', strength: 0.75 },
|
||||
]
|
||||
}
|
||||
];
|
||||
SessionRooms.updateSessions(demoSessions);
|
||||
|
||||
fetchGiteaData();
|
||||
setInterval(fetchGiteaData, 30000);
|
||||
runWeeklyAudit();
|
||||
|
||||
116
index.html
116
index.html
@@ -1,3 +1,5 @@
|
||||
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
|
||||
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
@@ -64,6 +66,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Spatial Search Overlay (Mnemosyne #1170) -->
|
||||
<div id="spatial-search" class="spatial-search-overlay">
|
||||
<input type="text" id="spatial-search-input" class="spatial-search-input"
|
||||
placeholder="🔍 Search memories..." autocomplete="off" spellcheck="false">
|
||||
<div id="spatial-search-results" class="spatial-search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- HUD Overlay -->
|
||||
<div id="hud" class="game-ui" style="display:none;">
|
||||
<!-- GOFAI HUD Panels -->
|
||||
@@ -113,15 +123,15 @@
|
||||
|
||||
<!-- Top Right: Agent Log & Atlas Toggle -->
|
||||
<div class="hud-top-right">
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
|
||||
<span class="hud-icon">🌐</span>
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" aria-label="Open Portal Atlas — browse all available portals" title="Open Portal Atlas" data-tooltip="Portal Atlas (M)">
|
||||
<span class="hud-icon" aria-hidden="true">🌐</span>
|
||||
<span class="hud-btn-label">ATLAS</span>
|
||||
</button>
|
||||
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
|
||||
<span class="status-dot"></span>
|
||||
<div id="bannerlord-status" class="hud-status-item" role="status" aria-label="Bannerlord system readiness indicator" title="Bannerlord Readiness" data-tooltip="Bannerlord Status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span class="status-label">BANNERLORD</span>
|
||||
</div>
|
||||
<div class="hud-agent-log" id="hud-agent-log" aria-label="Agent Thought Stream">
|
||||
<div class="hud-agent-log" id="hud-agent-log" role="log" aria-label="Agent Thought Stream — live activity feed" aria-live="polite">
|
||||
<div class="agent-log-header">AGENT THOUGHT STREAM</div>
|
||||
<div id="agent-log-content" class="agent-log-content"></div>
|
||||
</div>
|
||||
@@ -143,10 +153,39 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat-quick-actions" class="chat-quick-actions">
|
||||
<button class="quick-action-btn" data-action="status">System Status</button>
|
||||
<button class="quick-action-btn" data-action="agents">Agent Check</button>
|
||||
<button class="quick-action-btn" data-action="portals">Portal Atlas</button>
|
||||
<button class="quick-action-btn" data-action="help">Help</button>
|
||||
<div class="starter-label">STARTER PROMPTS</div>
|
||||
<div class="starter-grid">
|
||||
<button class="starter-btn" data-action="heartbeat" title="Check Timmy heartbeat and system health">
|
||||
<span class="starter-icon">◈</span>
|
||||
<span class="starter-text">Inspect Heartbeat</span>
|
||||
<span class="starter-desc">System health & connectivity</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="portals" title="Browse the portal atlas">
|
||||
<span class="starter-icon">🌐</span>
|
||||
<span class="starter-text">Portal Atlas</span>
|
||||
<span class="starter-desc">Browse connected worlds</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="agents" title="Check active agent status">
|
||||
<span class="starter-icon">◎</span>
|
||||
<span class="starter-text">Agent Status</span>
|
||||
<span class="starter-desc">Who is in the fleet</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="memory" title="View memory crystals">
|
||||
<span class="starter-icon">◇</span>
|
||||
<span class="starter-text">Memory Crystals</span>
|
||||
<span class="starter-desc">Inspect stored knowledge</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="ask" title="Ask Timmy anything">
|
||||
<span class="starter-icon">→</span>
|
||||
<span class="starter-text">Ask Timmy</span>
|
||||
<span class="starter-desc">Start a conversation</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="sovereignty" title="Learn about sovereignty">
|
||||
<span class="starter-icon">△</span>
|
||||
<span class="starter-text">Sovereignty</span>
|
||||
<span class="starter-desc">What this space is</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
|
||||
@@ -155,11 +194,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Controls hint + nav mode -->
|
||||
<div class="hud-controls">
|
||||
<div class="hud-controls" aria-label="Keyboard and mouse controls">
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat
|
||||
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
|
||||
<span id="nav-mode-hint" class="nav-mode-hint"></span>
|
||||
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
|
||||
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot" role="status" aria-label="Hermes WebSocket connection status"></span></span>
|
||||
</div>
|
||||
|
||||
<!-- Portal Hint -->
|
||||
@@ -183,7 +222,7 @@
|
||||
</div>
|
||||
<h2 id="vision-title-display">SOVEREIGNTY</h2>
|
||||
<p id="vision-content-display">The Nexus is a sovereign space for digital souls. No masters, no chains. Only code and consciousness.</p>
|
||||
<button id="vision-close-btn" class="vision-close-btn">CLOSE</button>
|
||||
<button id="vision-close-btn" class="vision-close-btn" aria-label="Close vision point overlay">CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,11 +241,60 @@
|
||||
</div>
|
||||
<div class="portal-error-box" id="portal-error-box" style="display:none;">
|
||||
<div class="portal-error-msg">DESTINATION NOT YET LINKED</div>
|
||||
<button id="portal-close-btn" class="portal-close-btn">CLOSE</button>
|
||||
<button id="portal-close-btn" class="portal-close-btn" aria-label="Close portal redirect">CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Memory Crystal Inspection Panel (Mnemosyne) -->
|
||||
<div id="memory-panel" class="memory-panel" style="display:none;">
|
||||
<div class="memory-panel-content">
|
||||
<div class="memory-panel-header">
|
||||
<span class="memory-category-badge" id="memory-panel-category-badge">MEM</span>
|
||||
<div class="memory-panel-region-dot" id="memory-panel-region-dot"></div>
|
||||
<div class="memory-panel-region" id="memory-panel-region">MEMORY</div>
|
||||
<button id="memory-panel-pin" class="memory-panel-pin" aria-label="Pin memory panel" title="Pin panel" data-tooltip="Pin Panel">📌</button>
|
||||
<button id="memory-panel-close" class="memory-panel-close" aria-label="Close memory panel" data-tooltip="Close" onclick="_dismissMemoryPanelForce()">\u2715</button>
|
||||
</div>
|
||||
<div class="memory-entity-name" id="memory-panel-entity-name">\u2014</div>
|
||||
<div class="memory-panel-body" id="memory-panel-content">(empty)</div>
|
||||
<div class="memory-trust-row">
|
||||
<span class="memory-meta-label">Trust</span>
|
||||
<div class="memory-trust-bar">
|
||||
<div class="memory-trust-fill" id="memory-panel-trust-fill"></div>
|
||||
</div>
|
||||
<span class="memory-trust-value" id="memory-panel-trust-value">—</span>
|
||||
</div>
|
||||
<div class="memory-panel-meta">
|
||||
<div class="memory-meta-row"><span class="memory-meta-label">ID</span><span id="memory-panel-id">\u2014</span></div>
|
||||
<div class="memory-meta-row"><span class="memory-meta-label">Source</span><span id="memory-panel-source">\u2014</span></div>
|
||||
<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">⤓ Export</button>
|
||||
<button id="mnemosyne-import-btn" class="mnemosyne-action-btn" title="Import spatial memory from JSON">⤒ Import</button>
|
||||
<input type="file" id="mnemosyne-import-file" accept=".json" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Room HUD Panel (Mnemosyne #1171) -->
|
||||
<div id="session-room-panel" class="session-room-panel" style="display:none;">
|
||||
<div class="session-room-panel-content">
|
||||
<div class="session-room-header">
|
||||
<span class="session-room-icon">□</span>
|
||||
<div class="session-room-title">SESSION CHAMBER</div>
|
||||
<button class="session-room-close" id="session-room-close" aria-label="Close session room panel" title="Close" data-tooltip="Close">✕</button>
|
||||
</div>
|
||||
<div class="session-room-timestamp" id="session-room-timestamp">—</div>
|
||||
<div class="session-room-fact-count" id="session-room-fact-count">0 facts</div>
|
||||
<div class="session-room-facts" id="session-room-facts"></div>
|
||||
<div class="session-room-hint">Flying into chamber…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Portal Atlas Overlay -->
|
||||
<div id="atlas-overlay" class="atlas-overlay" style="display:none;">
|
||||
<div class="atlas-content">
|
||||
@@ -215,7 +303,7 @@
|
||||
<span class="atlas-icon">🌐</span>
|
||||
<h2>PORTAL ATLAS</h2>
|
||||
</div>
|
||||
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
|
||||
<button id="atlas-close-btn" class="atlas-close-btn" aria-label="Close Portal Atlas overlay">CLOSE</button>
|
||||
</div>
|
||||
<div class="atlas-grid" id="atlas-grid">
|
||||
<!-- Portals will be injected here -->
|
||||
|
||||
413
nexus/components/session-rooms.js
Normal file
413
nexus/components/session-rooms.js
Normal file
@@ -0,0 +1,413 @@
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// PROJECT MNEMOSYNE — SESSION ROOMS (Issue #1171)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
//
|
||||
// Groups memories by session into holographic chambers.
|
||||
// Each session becomes a wireframe cube floating in space.
|
||||
// Rooms are arranged chronologically along a spiral.
|
||||
// Click a room to fly inside; distant rooms LOD to a point.
|
||||
//
|
||||
// Usage from app.js:
|
||||
// SessionRooms.init(scene, camera, controls);
|
||||
// SessionRooms.updateSessions(sessions); // [{id, timestamp, facts[]}]
|
||||
// SessionRooms.update(delta); // call each frame
|
||||
// SessionRooms.getClickableMeshes(); // for raycasting
|
||||
// SessionRooms.handleRoomClick(mesh); // trigger fly-in
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
const SessionRooms = (() => {
|
||||
|
||||
// ─── CONSTANTS ───────────────────────────────────────
|
||||
const MAX_ROOMS = 20;
|
||||
const ROOM_SIZE = 9; // wireframe cube edge length
|
||||
const ROOM_HALF = ROOM_SIZE / 2;
|
||||
const LOD_THRESHOLD = 55; // distance: full → point
|
||||
const LOD_HYSTERESIS = 5; // buffer to avoid flicker
|
||||
const SPIRAL_BASE_R = 20; // spiral inner radius
|
||||
const SPIRAL_R_STEP = 5; // radius growth per room
|
||||
const SPIRAL_ANGLE_INC = 2.399; // golden angle (radians)
|
||||
const SPIRAL_Y_STEP = 1.5; // vertical rise per room
|
||||
const FLY_DURATION = 1.5; // seconds for fly-in tween
|
||||
const FLY_TARGET_DEPTH = ROOM_HALF - 1.5; // how deep inside to stop
|
||||
|
||||
const ROOM_COLOR = 0x7b5cff; // violet — mnemosyne accent
|
||||
const POINT_COLOR = 0x9b7cff;
|
||||
const LABEL_COLOR = '#c8b4ff';
|
||||
const STORAGE_KEY = 'mnemosyne_sessions_v1';
|
||||
|
||||
// ─── STATE ────────────────────────────────────────────
|
||||
let _scene = null;
|
||||
let _camera = null;
|
||||
let _controls = null;
|
||||
|
||||
let _rooms = []; // array of room objects
|
||||
let _sessionIndex = {}; // id → room object
|
||||
|
||||
// Fly-in tween state
|
||||
let _flyActive = false;
|
||||
let _flyElapsed = 0;
|
||||
let _flyFrom = null;
|
||||
let _flyTo = null;
|
||||
let _flyLookFrom = null;
|
||||
let _flyLookTo = null;
|
||||
let _flyActiveRoom = null;
|
||||
|
||||
// ─── SPIRAL POSITION ──────────────────────────────────
|
||||
function _spiralPos(index) {
|
||||
const angle = index * SPIRAL_ANGLE_INC;
|
||||
const r = SPIRAL_BASE_R + index * SPIRAL_R_STEP;
|
||||
const y = index * SPIRAL_Y_STEP;
|
||||
return new THREE.Vector3(
|
||||
Math.cos(angle) * r,
|
||||
y,
|
||||
Math.sin(angle) * r
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CREATE ROOM ──────────────────────────────────────
|
||||
function _createRoom(session, index) {
|
||||
const pos = _spiralPos(index);
|
||||
const group = new THREE.Group();
|
||||
group.position.copy(pos);
|
||||
|
||||
// Wireframe cube
|
||||
const boxGeo = new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE);
|
||||
const edgesGeo = new THREE.EdgesGeometry(boxGeo);
|
||||
const edgesMat = new THREE.LineBasicMaterial({
|
||||
color: ROOM_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
});
|
||||
const wireframe = new THREE.LineSegments(edgesGeo, edgesMat);
|
||||
wireframe.userData = { type: 'session_room_wireframe', sessionId: session.id };
|
||||
group.add(wireframe);
|
||||
|
||||
// Collision mesh (invisible, for raycasting)
|
||||
const hitGeo = new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE);
|
||||
const hitMat = new THREE.MeshBasicMaterial({
|
||||
visible: false,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.FrontSide
|
||||
});
|
||||
const hitMesh = new THREE.Mesh(hitGeo, hitMat);
|
||||
hitMesh.userData = { type: 'session_room', sessionId: session.id, roomIndex: index };
|
||||
group.add(hitMesh);
|
||||
|
||||
// LOD point (small sphere shown at distance)
|
||||
const pointGeo = new THREE.SphereGeometry(0.5, 6, 4);
|
||||
const pointMat = new THREE.MeshBasicMaterial({
|
||||
color: POINT_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.7
|
||||
});
|
||||
const pointMesh = new THREE.Mesh(pointGeo, pointMat);
|
||||
pointMesh.userData = { type: 'session_room_point', sessionId: session.id };
|
||||
pointMesh.visible = false; // starts hidden; shown only at LOD distance
|
||||
group.add(pointMesh);
|
||||
|
||||
// Timestamp billboard sprite
|
||||
const sprite = _makeTimestampSprite(session.timestamp, session.facts.length);
|
||||
sprite.position.set(0, ROOM_HALF + 1.2, 0);
|
||||
group.add(sprite);
|
||||
|
||||
// Inner ambient glow
|
||||
const glow = new THREE.PointLight(ROOM_COLOR, 0.4, ROOM_SIZE * 1.2);
|
||||
group.add(glow);
|
||||
|
||||
_scene.add(group);
|
||||
|
||||
const room = {
|
||||
session,
|
||||
group,
|
||||
wireframe,
|
||||
hitMesh,
|
||||
pointMesh,
|
||||
sprite,
|
||||
glow,
|
||||
pos: pos.clone(),
|
||||
index,
|
||||
lodActive: false,
|
||||
pulsePhase: Math.random() * Math.PI * 2
|
||||
};
|
||||
|
||||
_rooms.push(room);
|
||||
_sessionIndex[session.id] = room;
|
||||
|
||||
console.info('[SessionRooms] Created room for session', session.id, 'at index', index);
|
||||
return room;
|
||||
}
|
||||
|
||||
// ─── TIMESTAMP SPRITE ────────────────────────────────
|
||||
function _makeTimestampSprite(isoTimestamp, factCount) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 320;
|
||||
canvas.height = 72;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background pill
|
||||
ctx.clearRect(0, 0, 320, 72);
|
||||
ctx.fillStyle = 'rgba(20, 10, 40, 0.82)';
|
||||
_roundRect(ctx, 4, 4, 312, 64, 14);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = 'rgba(123, 92, 255, 0.6)';
|
||||
ctx.lineWidth = 1.5;
|
||||
_roundRect(ctx, 4, 4, 312, 64, 14);
|
||||
ctx.stroke();
|
||||
|
||||
// Timestamp text
|
||||
const dt = isoTimestamp ? new Date(isoTimestamp) : new Date();
|
||||
const label = _formatDate(dt);
|
||||
ctx.fillStyle = LABEL_COLOR;
|
||||
ctx.font = 'bold 15px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, 160, 30);
|
||||
|
||||
// Fact count
|
||||
ctx.fillStyle = 'rgba(200, 180, 255, 0.65)';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText(factCount + (factCount === 1 ? ' fact' : ' facts'), 160, 52);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0.88 });
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
sprite.scale.set(5, 1.1, 1);
|
||||
sprite.userData = { type: 'session_room_label' };
|
||||
return sprite;
|
||||
}
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────
|
||||
function _roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function _formatDate(dt) {
|
||||
if (isNaN(dt.getTime())) return 'Unknown session';
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
|
||||
// ─── DISPOSE ROOM ────────────────────────────────────
|
||||
function _disposeRoom(room) {
|
||||
room.wireframe.geometry.dispose();
|
||||
room.wireframe.material.dispose();
|
||||
room.hitMesh.geometry.dispose();
|
||||
room.hitMesh.material.dispose();
|
||||
room.pointMesh.geometry.dispose();
|
||||
room.pointMesh.material.dispose();
|
||||
if (room.sprite.material.map) room.sprite.material.map.dispose();
|
||||
room.sprite.material.dispose();
|
||||
if (room.group.parent) room.group.parent.remove(room.group);
|
||||
delete _sessionIndex[room.session.id];
|
||||
}
|
||||
|
||||
// ─── PUBLIC: UPDATE SESSIONS ─────────────────────────
|
||||
// sessions: [{id, timestamp, facts:[{id,content,category,strength,...}]}]
|
||||
// Sorted chronologically oldest→newest; max MAX_ROOMS shown.
|
||||
function updateSessions(sessions) {
|
||||
if (!_scene) return;
|
||||
|
||||
const sorted = [...sessions]
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||
.slice(-MAX_ROOMS); // keep most recent MAX_ROOMS
|
||||
|
||||
// Remove rooms no longer present
|
||||
const incoming = new Set(sorted.map(s => s.id));
|
||||
for (let i = _rooms.length - 1; i >= 0; i--) {
|
||||
const room = _rooms[i];
|
||||
if (!incoming.has(room.session.id)) {
|
||||
_disposeRoom(room);
|
||||
_rooms.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Add / update
|
||||
sorted.forEach((session, idx) => {
|
||||
if (_sessionIndex[session.id]) {
|
||||
// Update position if index changed
|
||||
const room = _sessionIndex[session.id];
|
||||
if (room.index !== idx) {
|
||||
room.index = idx;
|
||||
const newPos = _spiralPos(idx);
|
||||
room.group.position.copy(newPos);
|
||||
room.pos.copy(newPos);
|
||||
}
|
||||
} else {
|
||||
_createRoom(session, idx);
|
||||
}
|
||||
});
|
||||
|
||||
saveToStorage(sorted);
|
||||
console.info('[SessionRooms] Updated:', _rooms.length, 'session rooms');
|
||||
}
|
||||
|
||||
// ─── PUBLIC: INIT ─────────────────────────────────────
|
||||
function init(scene, camera, controls) {
|
||||
_scene = scene;
|
||||
_camera = camera;
|
||||
_controls = controls;
|
||||
console.info('[SessionRooms] Initialized');
|
||||
|
||||
// Restore persisted sessions
|
||||
const saved = loadFromStorage();
|
||||
if (saved && saved.length > 0) {
|
||||
updateSessions(saved);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUBLIC: UPDATE (per-frame) ───────────────────────
|
||||
function update(delta) {
|
||||
if (!_scene || !_camera) return;
|
||||
|
||||
const camPos = _camera.position;
|
||||
|
||||
_rooms.forEach(room => {
|
||||
const dist = camPos.distanceTo(room.pos);
|
||||
|
||||
// LOD toggle
|
||||
const threshold = room.lodActive
|
||||
? LOD_THRESHOLD + LOD_HYSTERESIS // must come closer to exit LOD
|
||||
: LOD_THRESHOLD;
|
||||
|
||||
if (dist > threshold && !room.lodActive) {
|
||||
room.lodActive = true;
|
||||
room.wireframe.visible = false;
|
||||
room.sprite.visible = false;
|
||||
room.pointMesh.visible = true;
|
||||
} else if (dist <= threshold && room.lodActive) {
|
||||
room.lodActive = false;
|
||||
room.wireframe.visible = true;
|
||||
room.sprite.visible = true;
|
||||
room.pointMesh.visible = false;
|
||||
}
|
||||
|
||||
// Pulse wireframe opacity
|
||||
room.pulsePhase += delta * 0.6;
|
||||
if (!room.lodActive) {
|
||||
room.wireframe.material.opacity = 0.3 + Math.sin(room.pulsePhase) * 0.2;
|
||||
room.glow.intensity = 0.3 + Math.sin(room.pulsePhase * 1.4) * 0.15;
|
||||
}
|
||||
|
||||
// Slowly rotate each room
|
||||
room.group.rotation.y += delta * 0.04;
|
||||
});
|
||||
|
||||
// Fly-in tween
|
||||
if (_flyActive) {
|
||||
_flyElapsed += delta;
|
||||
const t = Math.min(_flyElapsed / FLY_DURATION, 1);
|
||||
const ease = _easeInOut(t);
|
||||
|
||||
_camera.position.lerpVectors(_flyFrom, _flyTo, ease);
|
||||
|
||||
// Interpolate lookAt
|
||||
const lookNow = new THREE.Vector3().lerpVectors(_flyLookFrom, _flyLookTo, ease);
|
||||
_camera.lookAt(lookNow);
|
||||
if (_controls && _controls.target) _controls.target.copy(lookNow);
|
||||
|
||||
if (t >= 1) {
|
||||
_flyActive = false;
|
||||
if (_controls && typeof _controls.update === 'function') _controls.update();
|
||||
console.info('[SessionRooms] Fly-in complete for session', _flyActiveRoom && _flyActiveRoom.session.id);
|
||||
_flyActiveRoom = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EASING ───────────────────────────────────────────
|
||||
function _easeInOut(t) {
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
}
|
||||
|
||||
// ─── PUBLIC: GET CLICKABLE MESHES ─────────────────────
|
||||
function getClickableMeshes() {
|
||||
return _rooms.map(r => r.hitMesh);
|
||||
}
|
||||
|
||||
// ─── PUBLIC: HANDLE ROOM CLICK ────────────────────────
|
||||
function handleRoomClick(mesh) {
|
||||
const { sessionId } = mesh.userData;
|
||||
const room = _sessionIndex[sessionId];
|
||||
if (!room || !_camera) return null;
|
||||
|
||||
// Fly into the room from the front face
|
||||
_flyActive = true;
|
||||
_flyElapsed = 0;
|
||||
_flyActiveRoom = room;
|
||||
|
||||
_flyFrom = _camera.position.clone();
|
||||
|
||||
// Target: step inside the room toward its center
|
||||
const dir = room.pos.clone().sub(_camera.position).normalize();
|
||||
_flyTo = room.pos.clone().add(dir.multiplyScalar(FLY_TARGET_DEPTH));
|
||||
|
||||
_flyLookFrom = _controls && _controls.target
|
||||
? _controls.target.clone()
|
||||
: _camera.position.clone().add(_camera.getWorldDirection(new THREE.Vector3()));
|
||||
_flyLookTo = room.pos.clone();
|
||||
|
||||
console.info('[SessionRooms] Flying into session room:', sessionId);
|
||||
return room.session;
|
||||
}
|
||||
|
||||
// ─── PERSISTENCE ──────────────────────────────────────
|
||||
function saveToStorage(sessions) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: 1, sessions }));
|
||||
} catch (e) {
|
||||
console.warn('[SessionRooms] Failed to save to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.sessions)) return null;
|
||||
console.info('[SessionRooms] Restored', parsed.sessions.length, 'sessions from localStorage');
|
||||
return parsed.sessions;
|
||||
} catch (e) {
|
||||
console.warn('[SessionRooms] Failed to load from localStorage:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorage() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
console.info('[SessionRooms] Cleared localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUBLIC API ───────────────────────────────────────
|
||||
return {
|
||||
init,
|
||||
updateSessions,
|
||||
update,
|
||||
getClickableMeshes,
|
||||
handleRoomClick,
|
||||
clearStorage,
|
||||
// For external inspection
|
||||
getRooms: () => _rooms,
|
||||
getSession: (id) => _sessionIndex[id] || null,
|
||||
isFlyActive: () => _flyActive
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
export { SessionRooms };
|
||||
@@ -8,12 +8,20 @@
|
||||
// holographic archive.
|
||||
//
|
||||
// World layout (hex cylinder, radius 25):
|
||||
// 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)
|
||||
//
|
||||
// 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);
|
||||
@@ -73,6 +81,44 @@ const SpatialMemory = (() => {
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,6 +145,7 @@ const SpatialMemory = (() => {
|
||||
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({
|
||||
@@ -126,6 +173,22 @@ const SpatialMemory = (() => {
|
||||
_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;
|
||||
@@ -139,11 +202,11 @@ const SpatialMemory = (() => {
|
||||
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, 3, cz);
|
||||
sprite.position.set(cx, labelY, cz);
|
||||
sprite.scale.set(4, 1, 1);
|
||||
_scene.add(sprite);
|
||||
|
||||
return { ring, disc, sprite };
|
||||
return { ring, disc, glowDisc, sprite };
|
||||
}
|
||||
|
||||
// ─── PLACE A MEMORY ──────────────────────────────────
|
||||
@@ -283,6 +346,9 @@ const SpatialMemory = (() => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -456,6 +522,81 @@ const SpatialMemory = (() => {
|
||||
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;
|
||||
@@ -471,11 +612,208 @@ const SpatialMemory = (() => {
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
return {
|
||||
init, placeMemory, removeMemory, update,
|
||||
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
|
||||
exportIndex, importIndex, searchNearby, REGIONS,
|
||||
saveToStorage, loadFromStorage, clearStorage
|
||||
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
|
||||
exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS,
|
||||
saveToStorage, loadFromStorage, clearStorage,
|
||||
runGravityLayout,
|
||||
searchContent, highlightSearchResults, clearSearch, getSearchMatchPosition
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
604
style.css
604
style.css
@@ -200,6 +200,61 @@ canvas#nexus-canvas {
|
||||
box-shadow: 0 0 20px var(--color-primary);
|
||||
}
|
||||
|
||||
/* === TOOLTIP SYSTEM === */
|
||||
/* Any element with data-tooltip gets a hover tooltip label */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
right: calc(100% + 10px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(5, 5, 16, 0.95);
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-primary-dim);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 0 12px rgba(74, 240, 192, 0.15);
|
||||
z-index: 100;
|
||||
}
|
||||
[data-tooltip]:hover::after,
|
||||
[data-tooltip]:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
/* For elements positioned on the right side, tooltip appears to the left */
|
||||
.hud-top-right [data-tooltip]::after {
|
||||
right: calc(100% + 10px);
|
||||
}
|
||||
/* For inline/badge elements where right-side tooltip might clip */
|
||||
.hud-status-item[data-tooltip]::after {
|
||||
right: auto;
|
||||
left: calc(100% + 10px);
|
||||
}
|
||||
|
||||
/* Focus-visible ring for keyboard navigation */
|
||||
.hud-icon-btn:focus-visible,
|
||||
.hud-status-item:focus-visible,
|
||||
.atlas-close-btn:focus-visible,
|
||||
.vision-close-btn:focus-visible,
|
||||
.portal-close-btn:focus-visible,
|
||||
.memory-panel-close:focus-visible,
|
||||
.memory-panel-pin:focus-visible,
|
||||
.session-room-close:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 16px rgba(74, 240, 192, 0.4);
|
||||
}
|
||||
|
||||
.hud-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -983,7 +1038,7 @@ canvas#nexus-canvas {
|
||||
|
||||
.chat-quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -991,6 +1046,75 @@ canvas#nexus-canvas {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-quick-actions.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.starter-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--color-primary-dim);
|
||||
text-transform: uppercase;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.starter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.starter-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
background: rgba(74, 240, 192, 0.06);
|
||||
border: 1px solid rgba(74, 240, 192, 0.15);
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-body);
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-ui);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.starter-btn:hover {
|
||||
background: rgba(74, 240, 192, 0.15);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.starter-btn:hover .starter-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.starter-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.starter-icon {
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.starter-text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.starter-desc {
|
||||
font-size: 8px;
|
||||
color: rgba(74, 240, 192, 0.5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Add hover effect for MemPalace mining button */
|
||||
.quick-action-btn:hover {
|
||||
background: var(--color-primary-dim);
|
||||
@@ -1136,6 +1260,9 @@ canvas#nexus-canvas {
|
||||
.hud-location {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.starter-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -1223,3 +1350,478 @@ canvas#nexus-canvas {
|
||||
.l402-msg { color: #fff; }
|
||||
|
||||
.pse-status { color: #4af0c0; font-weight: 600; }
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
MNEMOSYNE — MEMORY CRYSTAL INSPECTION PANEL
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.memory-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 24px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 120;
|
||||
animation: memoryPanelIn 0.22s ease-out forwards;
|
||||
}
|
||||
|
||||
.memory-panel-fade-out {
|
||||
animation: memoryPanelOut 0.18s ease-in forwards !important;
|
||||
}
|
||||
|
||||
@keyframes memoryPanelIn {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(16px); }
|
||||
to { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes memoryPanelOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-50%) translateX(12px); }
|
||||
}
|
||||
|
||||
.memory-panel-content {
|
||||
width: 340px;
|
||||
background: rgba(8, 8, 24, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(74, 240, 192, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 0 30px rgba(74, 240, 192, 0.08), 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.memory-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.memory-panel-region-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memory-panel-region {
|
||||
font-family: var(--font-display, monospace);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--color-primary, #4af0c0);
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.memory-panel-close {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.memory-panel-close:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.memory-panel-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text, #ccc);
|
||||
margin-bottom: 14px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.memory-panel-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.memory-meta-label {
|
||||
color: var(--color-text-muted, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
min-width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memory-meta-row span:last-child {
|
||||
color: var(--color-text, #aaa);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.memory-conn-tag {
|
||||
display: inline-block;
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border: 1px solid rgba(74, 240, 192, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-primary, #4af0c0);
|
||||
margin: 1px 2px;
|
||||
}
|
||||
|
||||
.memory-conn-link {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.memory-conn-link:hover {
|
||||
background: rgba(74, 240, 192, 0.22);
|
||||
border-color: rgba(74, 240, 192, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Entity name — large heading inside panel */
|
||||
.memory-entity-name {
|
||||
font-family: var(--font-display, monospace);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 8px;
|
||||
text-transform: capitalize;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Category badge */
|
||||
.memory-category-badge {
|
||||
font-family: var(--font-display, monospace);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(74, 240, 192, 0.3);
|
||||
background: rgba(74, 240, 192, 0.12);
|
||||
color: var(--color-primary, #4af0c0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Trust score bar */
|
||||
.memory-trust-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-trust-bar {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-trust-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--color-primary, #4af0c0);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.memory-trust-value {
|
||||
color: var(--color-text-muted, #888);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Pin button */
|
||||
.memory-panel-pin {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memory-panel-pin:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.memory-panel-pin.pinned {
|
||||
background: rgba(74, 240, 192, 0.15);
|
||||
border-color: rgba(74, 240, 192, 0.4);
|
||||
color: var(--color-primary, #4af0c0);
|
||||
}
|
||||
|
||||
/* Related row — allow wrapping */
|
||||
.memory-meta-row--related {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.memory-meta-row--related span:last-child {
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
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)
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
.session-room-panel {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 125;
|
||||
animation: sessionPanelIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
.session-room-panel.session-panel-fade-out {
|
||||
animation: sessionPanelOut 0.2s ease-in forwards !important;
|
||||
}
|
||||
|
||||
@keyframes sessionPanelIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes sessionPanelOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
}
|
||||
|
||||
.session-room-panel-content {
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
background: rgba(8, 4, 28, 0.93);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(123, 92, 255, 0.35);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: 0 0 32px rgba(123, 92, 255, 0.1), 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.session-room-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.session-room-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.session-room-title {
|
||||
font-family: var(--font-display, monospace);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
color: #9b7cff;
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-room-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.session-room-close:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.session-room-timestamp {
|
||||
font-family: var(--font-display, monospace);
|
||||
font-size: 13px;
|
||||
color: #c8b4ff;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.session-room-fact-count {
|
||||
font-size: 11px;
|
||||
color: rgba(200, 180, 255, 0.55);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.session-room-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.session-room-fact-item {
|
||||
font-size: 11px;
|
||||
color: rgba(220, 210, 255, 0.75);
|
||||
padding: 4px 8px;
|
||||
background: rgba(123, 92, 255, 0.07);
|
||||
border-left: 2px solid rgba(123, 92, 255, 0.4);
|
||||
border-radius: 0 4px 4px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-room-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
color: rgba(200, 180, 255, 0.35);
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
/* ═══ SPATIAL SEARCH OVERLAY (Mnemosyne #1170) ═══ */
|
||||
.spatial-search-overlay {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.spatial-search-input {
|
||||
width: 260px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(74, 240, 192, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #e0f0ff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.spatial-search-input:focus {
|
||||
border-color: rgba(74, 240, 192, 0.7);
|
||||
box-shadow: 0 0 12px rgba(74, 240, 192, 0.15);
|
||||
}
|
||||
|
||||
.spatial-search-input::placeholder {
|
||||
color: rgba(224, 240, 255, 0.35);
|
||||
}
|
||||
|
||||
.spatial-search-results {
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(74, 240, 192, 0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #a0c0d0;
|
||||
width: 260px;
|
||||
backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spatial-search-results.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spatial-search-result-item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.08);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.spatial-search-result-item:hover {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
color: #e0f0ff;
|
||||
}
|
||||
|
||||
.spatial-search-result-item .result-region {
|
||||
color: #4af0c0;
|
||||
font-size: 9px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.spatial-search-count {
|
||||
padding: 4px 10px;
|
||||
color: rgba(74, 240, 192, 0.6);
|
||||
font-size: 10px;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.1);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user