Compare commits

..

3 Commits

Author SHA1 Message Date
9a7e554568 fix: add missing MemoryPulse import, init, and update calls
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 14s
Review Approval Gate / verify-review (pull_request) Failing after 2s
2026-04-11 20:47:45 +00:00
d12bd7a806 feat(mnemosyne): wire MemoryPulse into app.js
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 14s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- Import MemoryPulse component
- Initialize with scene + SpatialMemory
- Call update() in animation loop
- Trigger pulse on crystal click via raycasting
2026-04-11 20:46:34 +00:00
9355c02417 feat(mnemosyne): Memory Pulse — holographic ripple propagation
When a memory crystal is accessed, a visual pulse wave radiates
outward through the connection graph, illuminating linked memories
by BFS hop distance.

Features:
- Expanding ring effect at each crystal (color-matched to region)
- Connection line flash between pulsed memories
- Travel time based on spatial distance
- Intensity decay per hop (0.65^hop)
- Depth-limited to 5 hops to prevent runaway
- Fully self-contained component, integrates via SpatialMemory API
2026-04-11 20:45:16 +00:00
18 changed files with 321 additions and 1478 deletions

66
app.js
View File

@@ -6,7 +6,7 @@ import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { SpatialMemory } from './nexus/components/spatial-memory.js';
import { MemoryBirth } from './nexus/components/memory-birth.js';
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
import { MemoryInspect } from './nexus/components/memory-inspect.js';
import { MemoryPulse } from './nexus/components/memory-pulse.js';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -49,7 +49,6 @@ let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
let memoryFeedEntries = []; // Mnemosyne: recent memory events for feed panel
let _memoryFilterOpen = false; // Mnemosyne: filter panel state
let _clickStartX = 0, _clickStartY = 0; // Mnemosyne: click-vs-drag detection
let loadProgress = 0;
let performanceTier = 'high';
@@ -712,9 +711,9 @@ async function init() {
createAshStorm();
SpatialMemory.init(scene);
MemoryBirth.init(scene);
MemoryPulse.init(scene, SpatialMemory);
MemoryBirth.wrapSpatialMemory(SpatialMemory);
SpatialMemory.setCamera(camera);
MemoryInspect.init({ onNavigate: _navigateToMemory });
updateLoad(90);
loadSession();
@@ -1906,8 +1905,6 @@ function setupControls() {
mouseDown = true;
orbitState.lastX = e.clientX;
orbitState.lastY = e.clientY;
_clickStartX = e.clientX;
_clickStartY = e.clientY;
// Raycasting for portals
if (!portalOverlayActive) {
@@ -1923,40 +1920,23 @@ function setupControls() {
const portal = portals.find(p => p.ring === clickedRing);
if (portal) activatePortal(portal);
}
}
}
});
document.addEventListener('mouseup', (e) => {
const wasDrag = Math.abs(e.clientX - _clickStartX) > 5 || Math.abs(e.clientY - _clickStartY) > 5;
mouseDown = false;
if (wasDrag || e.target !== canvas) return;
// Crystal click detection (Mnemosyne inspect panel, issue #1227)
if (!portalOverlayActive) {
const mouse = new THREE.Vector2(
(e.clientX / window.innerWidth) * 2 - 1,
-(e.clientY / window.innerHeight) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const crystalMeshes = SpatialMemory.getCrystalMeshes();
const hits = raycaster.intersectObjects(crystalMeshes);
if (hits.length > 0) {
const entry = SpatialMemory.getMemoryFromMesh(hits[0].object);
if (entry) {
SpatialMemory.highlightMemory(entry.data.id);
const regionDef = SpatialMemory.REGIONS[entry.region] || SpatialMemory.REGIONS.working;
MemoryInspect.show(entry.data, regionDef);
}
} else {
// Clicked empty space — close inspect panel and deselect crystal
if (MemoryInspect.isOpen()) {
SpatialMemory.clearHighlight();
MemoryInspect.hide();
// Raycasting for memory crystals — trigger pulse
const crystalMeshes = SpatialMemory.getCrystalMeshes();
if (crystalMeshes.length > 0) {
const crystalHits = raycaster.intersectObjects(crystalMeshes);
if (crystalHits.length > 0) {
const hitMesh = crystalHits[0].object;
const memData = SpatialMemory.getMemoryFromMesh(hitMesh);
if (memData) {
MemoryPulse.triggerPulse(memData.data.id);
}
}
}
}
}
});
document.addEventListener('mouseup', () => { mouseDown = false; });
document.addEventListener('mousemove', (e) => {
if (!mouseDown) return;
if (document.activeElement === document.getElementById('chat-input')) return;
@@ -2187,23 +2167,6 @@ function clearMemoryFeed() {
console.info('[Mnemosyne] Memory feed cleared');
}
/**
* Navigate to a linked memory from the inspect panel.
* Highlights the target crystal and re-opens the panel with its data.
* @param {string} memId
*/
function _navigateToMemory(memId) {
const all = SpatialMemory.getAllMemories();
const data = all.find(m => m.id === memId);
if (!data) {
console.warn('[MemoryInspect] Linked memory not found in scene:', memId);
return;
}
SpatialMemory.highlightMemory(memId);
const regionDef = SpatialMemory.REGIONS[data.category] || SpatialMemory.REGIONS.working;
MemoryInspect.show(data, regionDef);
}
function handleMemoryMessage(data) {
const action = data.action;
const memory = data.memory;
@@ -2924,6 +2887,7 @@ function gameLoop() {
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
MemoryBirth.update(delta);
MemoryPulse.update(delta);
animateMemoryOrbs(delta);
}

View File

@@ -473,13 +473,6 @@ index.html
</div>
<!-- Memory Inspect Panel (Mnemosyne, issue #1227) -->
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel">
</div>
<!-- Memory Connections Panel (Mnemosyne) -->
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel">
</div>
<script>
// ─── MNEMOSYNE: Memory Filter Panel ───────────────────

View File

@@ -1,291 +0,0 @@
// ═══════════════════════════════════════════════════════════
// MNEMOSYNE — Memory Connection Panel
// ═══════════════════════════════════════════════════════════
//
// Interactive panel for browsing, adding, and removing memory
// connections. Opens as a sub-panel from MemoryInspect when
// a memory crystal is selected.
//
// Usage from app.js:
// MemoryConnections.init({
// onNavigate: fn(memId), // fly to another memory
// onConnectionChange: fn(memId, newConnections) // update hooks
// });
// MemoryConnections.show(memData, allMemories);
// MemoryConnections.hide();
//
// Depends on: SpatialMemory (for updateMemory + highlightMemory)
// ═══════════════════════════════════════════════════════════
const MemoryConnections = (() => {
let _panel = null;
let _onNavigate = null;
let _onConnectionChange = null;
let _currentMemId = null;
let _hoveredConnId = null;
// ─── INIT ────────────────────────────────────────────────
function init(opts = {}) {
_onNavigate = opts.onNavigate || null;
_onConnectionChange = opts.onConnectionChange || null;
_panel = document.getElementById('memory-connections-panel');
if (!_panel) {
console.warn('[MemoryConnections] Panel element #memory-connections-panel not found in DOM');
}
}
// ─── SHOW ────────────────────────────────────────────────
function show(memData, allMemories) {
if (!_panel || !memData) return;
_currentMemId = memData.id;
const connections = memData.connections || [];
const connectedSet = new Set(connections);
// Build lookup for connected memories
const memLookup = {};
(allMemories || []).forEach(m => { memLookup[m.id] = m; });
// Connected memories list
let connectedHtml = '';
if (connections.length > 0) {
connectedHtml = connections.map(cid => {
const cm = memLookup[cid];
const label = cm ? _truncate(cm.content || cid, 40) : cid;
const cat = cm ? cm.category : '';
const strength = cm ? Math.round((cm.strength || 0.7) * 100) : 70;
return `
<div class="mc-conn-item" data-memid="${_esc(cid)}">
<div class="mc-conn-info">
<span class="mc-conn-label" title="${_esc(cid)}">${_esc(label)}</span>
<span class="mc-conn-meta">${_esc(cat)} · ${strength}%</span>
</div>
<div class="mc-conn-actions">
<button class="mc-btn mc-btn-nav" data-nav="${_esc(cid)}" title="Navigate to memory">⮞</button>
<button class="mc-btn mc-btn-remove" data-remove="${_esc(cid)}" title="Remove connection">✕</button>
</div>
</div>`;
}).join('');
} else {
connectedHtml = '<div class="mc-empty">No connections yet</div>';
}
// Find nearby unconnected memories (same region, then other regions)
const suggestions = _findSuggestions(memData, allMemories, connectedSet);
let suggestHtml = '';
if (suggestions.length > 0) {
suggestHtml = suggestions.map(s => {
const label = _truncate(s.content || s.id, 36);
const cat = s.category || '';
const proximity = s._proximity || '';
return `
<div class="mc-suggest-item" data-memid="${_esc(s.id)}">
<div class="mc-suggest-info">
<span class="mc-suggest-label" title="${_esc(s.id)}">${_esc(label)}</span>
<span class="mc-suggest-meta">${_esc(cat)} · ${_esc(proximity)}</span>
</div>
<button class="mc-btn mc-btn-add" data-add="${_esc(s.id)}" title="Add connection">+</button>
</div>`;
}).join('');
} else {
suggestHtml = '<div class="mc-empty">No nearby memories to connect</div>';
}
_panel.innerHTML = `
<div class="mc-header">
<span class="mc-title">⬡ Connections</span>
<button class="mc-close" id="mc-close-btn" aria-label="Close connections panel">✕</button>
</div>
<div class="mc-section">
<div class="mc-section-label">LINKED (${connections.length})</div>
<div class="mc-conn-list" id="mc-conn-list">${connectedHtml}</div>
</div>
<div class="mc-section">
<div class="mc-section-label">SUGGESTED</div>
<div class="mc-suggest-list" id="mc-suggest-list">${suggestHtml}</div>
</div>
`;
// Wire close button
_panel.querySelector('#mc-close-btn')?.addEventListener('click', hide);
// Wire navigation buttons
_panel.querySelectorAll('[data-nav]').forEach(btn => {
btn.addEventListener('click', () => {
if (_onNavigate) _onNavigate(btn.dataset.nav);
});
});
// Wire remove buttons
_panel.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => _removeConnection(btn.dataset.remove));
});
// Wire add buttons
_panel.querySelectorAll('[data-add]').forEach(btn => {
btn.addEventListener('click', () => _addConnection(btn.dataset.add));
});
// Wire hover highlight for connection items
_panel.querySelectorAll('.mc-conn-item').forEach(item => {
item.addEventListener('mouseenter', () => _highlightConnection(item.dataset.memid));
item.addEventListener('mouseleave', _clearConnectionHighlight);
});
_panel.style.display = 'flex';
requestAnimationFrame(() => _panel.classList.add('mc-visible'));
}
// ─── HIDE ────────────────────────────────────────────────
function hide() {
if (!_panel) return;
_clearConnectionHighlight();
_panel.classList.remove('mc-visible');
const onEnd = () => {
_panel.style.display = 'none';
_panel.removeEventListener('transitionend', onEnd);
};
_panel.addEventListener('transitionend', onEnd);
setTimeout(() => { if (_panel) _panel.style.display = 'none'; }, 350);
_currentMemId = null;
}
// ─── SUGGESTION ENGINE ──────────────────────────────────
function _findSuggestions(memData, allMemories, connectedSet) {
if (!allMemories) return [];
const suggestions = [];
const pos = memData.position || [0, 0, 0];
const sameRegion = memData.category || 'working';
for (const m of allMemories) {
if (m.id === memData.id) continue;
if (connectedSet.has(m.id)) continue;
const mpos = m.position || [0, 0, 0];
const dist = Math.sqrt(
(pos[0] - mpos[0]) ** 2 +
(pos[1] - mpos[1]) ** 2 +
(pos[2] - mpos[2]) ** 2
);
// Categorize proximity
let proximity = 'nearby';
if (m.category === sameRegion) {
proximity = dist < 5 ? 'same region · close' : 'same region';
} else {
proximity = dist < 10 ? 'adjacent' : 'distant';
}
suggestions.push({ ...m, _dist: dist, _proximity: proximity });
}
// Sort: same region first, then by distance
suggestions.sort((a, b) => {
const aSame = a.category === sameRegion ? 0 : 1;
const bSame = b.category === sameRegion ? 0 : 1;
if (aSame !== bSame) return aSame - bSame;
return a._dist - b._dist;
});
return suggestions.slice(0, 8); // Cap at 8 suggestions
}
// ─── CONNECTION ACTIONS ─────────────────────────────────
function _addConnection(targetId) {
if (!_currentMemId) return;
// Get current memory data via SpatialMemory
const allMems = typeof SpatialMemory !== 'undefined' ? SpatialMemory.getAllMemories() : [];
const current = allMems.find(m => m.id === _currentMemId);
if (!current) return;
const conns = [...(current.connections || [])];
if (conns.includes(targetId)) return;
conns.push(targetId);
// Update SpatialMemory
if (typeof SpatialMemory !== 'undefined') {
SpatialMemory.updateMemory(_currentMemId, { connections: conns });
}
// Also create reverse connection on target
const target = allMems.find(m => m.id === targetId);
if (target) {
const targetConns = [...(target.connections || [])];
if (!targetConns.includes(_currentMemId)) {
targetConns.push(_currentMemId);
SpatialMemory.updateMemory(targetId, { connections: targetConns });
}
}
if (_onConnectionChange) _onConnectionChange(_currentMemId, conns);
// Re-render panel
const updatedMem = { ...current, connections: conns };
show(updatedMem, allMems);
}
function _removeConnection(targetId) {
if (!_currentMemId) return;
const allMems = typeof SpatialMemory !== 'undefined' ? SpatialMemory.getAllMemories() : [];
const current = allMems.find(m => m.id === _currentMemId);
if (!current) return;
const conns = (current.connections || []).filter(c => c !== targetId);
if (typeof SpatialMemory !== 'undefined') {
SpatialMemory.updateMemory(_currentMemId, { connections: conns });
}
// Also remove reverse connection
const target = allMems.find(m => m.id === targetId);
if (target) {
const targetConns = (target.connections || []).filter(c => c !== _currentMemId);
SpatialMemory.updateMemory(targetId, { connections: targetConns });
}
if (_onConnectionChange) _onConnectionChange(_currentMemId, conns);
const updatedMem = { ...current, connections: conns };
show(updatedMem, allMems);
}
// ─── 3D HIGHLIGHT ───────────────────────────────────────
function _highlightConnection(memId) {
_hoveredConnId = memId;
if (typeof SpatialMemory !== 'undefined') {
SpatialMemory.highlightMemory(memId);
}
}
function _clearConnectionHighlight() {
if (_hoveredConnId && typeof SpatialMemory !== 'undefined') {
SpatialMemory.clearHighlight();
}
_hoveredConnId = null;
}
// ─── HELPERS ────────────────────────────────────────────
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _truncate(str, n) {
return str.length > n ? str.slice(0, n - 1) + '\u2026' : str;
}
function isOpen() {
return _panel != null && _panel.style.display !== 'none';
}
return { init, show, hide, isOpen };
})();
export { MemoryConnections };

View File

@@ -1,180 +0,0 @@
// ═══════════════════════════════════════════════════════════
// MNEMOSYNE — Memory Inspect Panel (issue #1227)
// ═══════════════════════════════════════════════════════════
//
// Side-panel detail view for memory crystals.
// Opens when a crystal is clicked; auto-closes on empty-space click.
//
// Usage from app.js:
// MemoryInspect.init({ onNavigate: fn });
// MemoryInspect.show(memData, regionDef);
// MemoryInspect.hide();
// MemoryInspect.isOpen();
// ═══════════════════════════════════════════════════════════
const MemoryInspect = (() => {
let _panel = null;
let _onNavigate = null; // callback(memId) — navigate to a linked memory
// ─── INIT ────────────────────────────────────────────────
function init(opts = {}) {
_onNavigate = opts.onNavigate || null;
_panel = document.getElementById('memory-inspect-panel');
if (!_panel) {
console.warn('[MemoryInspect] Panel element #memory-inspect-panel not found in DOM');
}
}
// ─── SHOW ────────────────────────────────────────────────
function show(data, regionDef) {
if (!_panel) return;
const region = regionDef || {};
const colorHex = region.color
? '#' + region.color.toString(16).padStart(6, '0')
: '#4af0c0';
const strength = data.strength != null ? data.strength : 0.7;
const vitality = Math.round(Math.max(0, Math.min(1, strength)) * 100);
let vitalityColor = '#4af0c0';
if (vitality < 30) vitalityColor = '#ff4466';
else if (vitality < 60) vitalityColor = '#ffaa22';
const ts = data.timestamp ? new Date(data.timestamp) : null;
const created = ts && !isNaN(ts) ? ts.toLocaleString() : '—';
// Linked memories
let linksHtml = '';
if (data.connections && data.connections.length > 0) {
linksHtml = data.connections
.map(id => `<button class="mi-link-btn" data-memid="${_esc(id)}">${_esc(id)}</button>`)
.join('');
} else {
linksHtml = '<span class="mi-empty">No linked memories</span>';
}
_panel.innerHTML = `
<div class="mi-header" style="border-left:3px solid ${colorHex}">
<span class="mi-region-glyph">${region.glyph || '\u25C8'}</span>
<div class="mi-header-text">
<div class="mi-id" title="${_esc(data.id || '')}">${_esc(_truncate(data.id || '\u2014', 28))}</div>
<div class="mi-region" style="color:${colorHex}">${_esc(region.label || data.category || '\u2014')}</div>
</div>
<button class="mi-close" id="mi-close-btn" aria-label="Close inspect panel">\u2715</button>
</div>
<div class="mi-body">
<div class="mi-section">
<div class="mi-section-label">CONTENT</div>
<div class="mi-content">${_esc(data.content || '(empty)')}</div>
</div>
<div class="mi-section">
<div class="mi-section-label">VITALITY</div>
<div class="mi-vitality-row">
<div class="mi-vitality-bar-track">
<div class="mi-vitality-bar" style="width:${vitality}%;background:${vitalityColor}"></div>
</div>
<span class="mi-vitality-pct" style="color:${vitalityColor}">${vitality}%</span>
</div>
</div>
<div class="mi-section">
<div class="mi-section-label">LINKED MEMORIES</div>
<div class="mi-links" id="mi-links">${linksHtml}</div>
</div>
<div class="mi-section">
<div class="mi-section-label">META</div>
<div class="mi-meta-row">
<span class="mi-meta-key">Source</span>
<span class="mi-meta-val">${_esc(data.source || '\u2014')}</span>
</div>
<div class="mi-meta-row">
<span class="mi-meta-key">Created</span>
<span class="mi-meta-val">${created}</span>
</div>
</div>
<div class="mi-actions">
<button class="mi-action-btn" id="mi-copy-btn">\u2398 Copy</button>
</div>
</div>
`;
// Wire close button
const closeBtn = _panel.querySelector('#mi-close-btn');
if (closeBtn) closeBtn.addEventListener('click', hide);
// Wire copy button
const copyBtn = _panel.querySelector('#mi-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const text = data.content || '';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = '\u2713 Copied';
setTimeout(() => { copyBtn.textContent = '\u2398 Copy'; }, 1500);
}).catch(() => _fallbackCopy(text));
} else {
_fallbackCopy(text);
}
});
}
// Wire link navigation
const linksContainer = _panel.querySelector('#mi-links');
if (linksContainer) {
linksContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.mi-link-btn');
if (btn && _onNavigate) _onNavigate(btn.dataset.memid);
});
}
_panel.style.display = 'flex';
// Trigger CSS animation
requestAnimationFrame(() => _panel.classList.add('mi-visible'));
}
// ─── HIDE ─────────────────────────────────────────────────
function hide() {
if (!_panel) return;
_panel.classList.remove('mi-visible');
// Wait for CSS transition before hiding
const onEnd = () => {
_panel.style.display = 'none';
_panel.removeEventListener('transitionend', onEnd);
};
_panel.addEventListener('transitionend', onEnd);
// Safety fallback if transition doesn't fire
setTimeout(() => { if (_panel) _panel.style.display = 'none'; }, 350);
}
// ─── QUERY ────────────────────────────────────────────────
function isOpen() {
return _panel != null && _panel.style.display !== 'none';
}
// ─── HELPERS ──────────────────────────────────────────────
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _truncate(str, n) {
return str.length > n ? str.slice(0, n - 1) + '\u2026' : str;
}
function _fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
return { init, show, hide, isOpen };
})();
export { MemoryInspect };

View File

@@ -0,0 +1,305 @@
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — MEMORY PULSE ENGINE
// ═══════════════════════════════════════════
//
// Holographic ripple propagation: when a memory crystal is accessed,
// a visual pulse wave radiates outward through the connection graph,
// illuminating linked memories in decreasing intensity by hop distance.
//
// This makes the archive feel alive — one thought echoing through
// the holographic field of related knowledge.
//
// Issue: Mnemosyne Pulse Effect
// ═══════════════════════════════════════════
const MemoryPulse = (() => {
let _scene = null;
let _spatialMemory = null;
let _activePulses = []; // Currently propagating pulse waves
let _pulseRings = []; // Active ring meshes being rendered
let _connectionFlashes = []; // Active connection line flashes
const PULSE_SPEED = 8; // Units per second propagation
const PULSE_MAX_HOPS = 5; // Max graph depth to traverse
const RING_DURATION = 1.5; // Seconds each ring is visible
const RING_MAX_RADIUS = 2.0; // Max expansion of pulse ring
const FLASH_DURATION = 0.8; // Seconds connection lines flash
const BASE_INTENSITY = 3.0; // Emissive boost at pulse origin
const HOP_DECAY = 0.65; // Intensity multiplier per hop
// ─── INIT ────────────────────────────────────────────
function init(scene, spatialMemory) {
_scene = scene;
_spatialMemory = spatialMemory;
console.info('[Mnemosyne] Pulse engine initialized');
}
// ─── TRIGGER PULSE ──────────────────────────────────
/**
* Fire a pulse from a memory crystal. Propagates through
* connected memories by BFS, creating visual rings and
* connection line flashes at each hop.
* @param {string} sourceId - Memory ID to pulse from
*/
function triggerPulse(sourceId) {
if (!_scene || !_spatialMemory) return;
const memories = _spatialMemory.getAllMemories();
const source = memories.find(m => m.id === sourceId);
if (!source) return;
// BFS through connection graph
const visited = new Set();
const queue = [{ id: sourceId, hop: 0, delay: 0 }];
visited.add(sourceId);
const memMap = {};
memories.forEach(m => { memMap[m.id] = m; });
while (queue.length > 0) {
const { id, hop, delay } = queue.shift();
if (hop > PULSE_MAX_HOPS) continue;
const mem = memMap[id];
if (!mem) continue;
// Schedule ring spawn
_scheduleRing(id, hop, delay);
// Schedule connection flashes to neighbors
const connections = mem.connections || [];
connections.forEach(targetId => {
if (visited.has(targetId)) return;
visited.add(targetId);
const target = memMap[targetId];
if (!target) return;
const travelDelay = delay + _travelTime(mem, target);
_scheduleConnectionFlash(id, targetId, delay, travelDelay);
queue.push({ id: targetId, hop: hop + 1, delay: travelDelay });
});
}
}
// ─── TRAVEL TIME ────────────────────────────────────
function _travelTime(src, dst) {
const sp = src.position || [0, 0, 0];
const dp = dst.position || [0, 0, 0];
const dx = sp[0] - dp[0], dy = sp[1] - dp[1], dz = sp[2] - dp[2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
return dist / PULSE_SPEED;
}
// ─── SCHEDULE RING ──────────────────────────────────
function _scheduleRing(memId, hop, delay) {
const startTime = performance.now() + delay * 1000;
_activePulses.push({
type: 'ring',
memId,
hop,
startTime,
duration: RING_DURATION,
intensity: BASE_INTENSITY * Math.pow(HOP_DECAY, hop),
});
}
// ─── SCHEDULE CONNECTION FLASH ─────────────────────
function _scheduleConnectionFlash(fromId, toId, startDelay, endDelay) {
const startTime = performance.now() + startDelay * 1000;
_activePulses.push({
type: 'flash',
fromId,
toId,
startTime,
duration: endDelay - startDelay + FLASH_DURATION,
intensity: BASE_INTENSITY * Math.pow(HOP_DECAY, 0),
});
}
// ─── UPDATE (called per frame) ──────────────────────
function update(delta) {
const now = performance.now();
// Process scheduled pulses
for (let i = _activePulses.length - 1; i >= 0; i--) {
const pulse = _activePulses[i];
if (now < pulse.startTime) continue; // Not yet active
const elapsed = (now - pulse.startTime) / 1000;
const progress = Math.min(1, elapsed / pulse.duration);
if (progress >= 1) {
_activePulses.splice(i, 1);
continue;
}
if (pulse.type === 'ring') {
_renderRing(pulse, elapsed, progress);
} else if (pulse.type === 'flash') {
_renderConnectionFlash(pulse, elapsed, progress);
}
}
// Update existing ring meshes
for (let i = _pulseRings.length - 1; i >= 0; i--) {
const ring = _pulseRings[i];
ring.age += delta;
if (ring.age >= ring.maxAge) {
if (ring.mesh.parent) ring.mesh.parent.remove(ring.mesh);
ring.mesh.geometry.dispose();
ring.mesh.material.dispose();
_pulseRings.splice(i, 1);
continue;
}
const t = ring.age / ring.maxAge;
const scale = 1 + t * RING_MAX_RADIUS;
ring.mesh.scale.set(scale, scale, scale);
ring.mesh.material.opacity = ring.baseOpacity * (1 - t * t);
}
// Update connection flashes
for (let i = _connectionFlashes.length - 1; i >= 0; i--) {
const flash = _connectionFlashes[i];
flash.age += delta;
if (flash.age >= flash.maxAge) {
// Restore original material
if (flash.line && flash.line.material) {
flash.line.material.opacity = flash.originalOpacity;
flash.line.material.color.setHex(flash.originalColor);
}
_connectionFlashes.splice(i, 1);
continue;
}
const t = flash.age / flash.maxAge;
if (flash.line && flash.line.material) {
// Pulse opacity with travel effect
const wave = Math.sin(t * Math.PI);
flash.line.material.opacity = flash.originalOpacity + wave * 0.6;
flash.line.material.color.setHex(
_lerpColor(flash.originalColor, flash.flashColor, wave * 0.8)
);
}
}
}
// ─── RENDER RING ────────────────────────────────────
function _renderRing(pulse, elapsed, progress) {
// Find crystal position
const allMeshes = _spatialMemory.getCrystalMeshes();
let sourceMesh = null;
const memories = _spatialMemory.getAllMemories();
for (const mem of memories) {
if (mem.id === pulse.memId) {
// Find matching mesh
sourceMesh = allMeshes.find(m => m.userData.memId === pulse.memId);
break;
}
}
if (!sourceMesh) return;
// Only create ring once (check if we already have one for this pulse)
if (pulse._ringCreated) return;
pulse._ringCreated = true;
const ringGeo = new THREE.RingGeometry(0.1, 0.15, 32);
const region = memories.find(m => m.id === pulse.memId);
const color = _getRegionColor(region ? region.category : 'working');
const ringMat = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
opacity: 0.8 * pulse.intensity / BASE_INTENSITY,
side: THREE.DoubleSide,
depthWrite: false,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.position.copy(sourceMesh.position);
ring.position.y += 0.1; // Slight offset above crystal
ring.rotation.x = -Math.PI / 2; // Flat on XZ plane
ring.lookAt(ring.position.x, ring.position.y + 1, ring.position.z);
_scene.add(ring);
_pulseRings.push({
mesh: ring,
age: 0,
maxAge: RING_DURATION,
baseOpacity: ringMat.opacity,
});
}
// ─── RENDER CONNECTION FLASH ────────────────────────
function _renderConnectionFlash(pulse, elapsed, progress) {
if (pulse._flashCreated) return;
// Find the connection line between from and to
const fromMesh = _findMesh(pulse.fromId);
const toMesh = _findMesh(pulse.toId);
if (!fromMesh || !toMesh) return;
// Create a temporary line for the flash
const points = [fromMesh.position.clone(), toMesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({
color: 0x4af0c0,
transparent: true,
opacity: 0.0,
linewidth: 2,
});
const line = new THREE.Line(geo, mat);
_scene.add(line);
pulse._flashCreated = true;
_connectionFlashes.push({
line,
age: 0,
maxAge: pulse.duration,
originalOpacity: 0.0,
originalColor: 0x334455,
flashColor: 0x4af0c0,
});
}
// ─── HELPERS ────────────────────────────────────────
function _findMesh(memId) {
const meshes = _spatialMemory.getCrystalMeshes();
return meshes.find(m => m.userData.memId === memId) || null;
}
function _getRegionColor(category) {
const colors = {
documents: 0x4af0c0,
projects: 0xff6b35,
code: 0x7b5cff,
social: 0xff4488,
working: 0xffd700,
archive: 0x445566,
};
return colors[category] || colors.working;
}
function _lerpColor(a, b, t) {
const ar = (a >> 16) & 0xff, ag = (a >> 8) & 0xff, ab = a & 0xff;
const br = (b >> 16) & 0xff, bg = (b >> 8) & 0xff, bb = b & 0xff;
const rr = Math.round(ar + (br - ar) * t);
const rg = Math.round(ag + (bg - ag) * t);
const rb = Math.round(ab + (bb - ab) * t);
return (rr << 16) | (rg << 8) | rb;
}
// ─── PUBLIC API ─────────────────────────────────────
return {
init,
triggerPulse,
update,
};
})();
export { MemoryPulse };

View File

@@ -241,247 +241,3 @@ class MnemosyneArchive:
"oldest_entry": oldest_entry,
"newest_entry": newest_entry,
}
def _build_adjacency(self) -> dict[str, set[str]]:
"""Build adjacency dict from entry links. Only includes valid references."""
adj: dict[str, set[str]] = {eid: set() for eid in self._entries}
for eid, entry in self._entries.items():
for linked_id in entry.links:
if linked_id in self._entries and linked_id != eid:
adj[eid].add(linked_id)
adj[linked_id].add(eid)
return adj
def graph_clusters(self, min_size: int = 1) -> list[dict]:
"""Find connected component clusters in the holographic graph.
Uses BFS to discover groups of entries that are reachable from each
other through their links. Returns clusters sorted by size descending.
Args:
min_size: Minimum cluster size to include (filters out isolated entries).
Returns:
List of dicts with keys: cluster_id, size, entries, topics, density
"""
adj = self._build_adjacency()
visited: set[str] = set()
clusters: list[dict] = []
cluster_id = 0
for eid in self._entries:
if eid in visited:
continue
# BFS from this entry
component: list[str] = []
queue = [eid]
while queue:
current = queue.pop(0)
if current in visited:
continue
visited.add(current)
component.append(current)
for neighbor in adj.get(current, set()):
if neighbor not in visited:
queue.append(neighbor)
# Single-entry clusters are orphans
if len(component) < min_size:
continue
# Collect topics from cluster entries
cluster_topics: dict[str, int] = {}
internal_edges = 0
for cid in component:
entry = self._entries[cid]
for t in entry.topics:
cluster_topics[t] = cluster_topics.get(t, 0) + 1
internal_edges += len(adj.get(cid, set()))
internal_edges //= 2 # undirected, counted twice
# Density: actual edges / possible edges
n = len(component)
max_edges = n * (n - 1) // 2
density = round(internal_edges / max_edges, 4) if max_edges > 0 else 0.0
# Top topics by frequency
top_topics = sorted(cluster_topics.items(), key=lambda x: x[1], reverse=True)[:5]
clusters.append({
"cluster_id": cluster_id,
"size": n,
"entries": component,
"top_topics": [t for t, _ in top_topics],
"internal_edges": internal_edges,
"density": density,
})
cluster_id += 1
clusters.sort(key=lambda c: c["size"], reverse=True)
return clusters
def hub_entries(self, limit: int = 10) -> list[dict]:
"""Find the most connected entries (highest degree centrality).
These are the "hubs" of the holographic graph — entries that bridge
many topics and attract many links.
Args:
limit: Maximum number of hubs to return.
Returns:
List of dicts with keys: entry, degree, inbound, outbound, topics
"""
adj = self._build_adjacency()
inbound: dict[str, int] = {eid: 0 for eid in self._entries}
for entry in self._entries.values():
for lid in entry.links:
if lid in inbound:
inbound[lid] += 1
hubs = []
for eid, entry in self._entries.items():
degree = len(adj.get(eid, set()))
if degree == 0:
continue
hubs.append({
"entry": entry,
"degree": degree,
"inbound": inbound.get(eid, 0),
"outbound": len(entry.links),
"topics": entry.topics,
})
hubs.sort(key=lambda h: h["degree"], reverse=True)
return hubs[:limit]
def bridge_entries(self) -> list[dict]:
"""Find articulation points — entries whose removal would split a cluster.
These are "bridge" entries in the holographic graph. Removing them
disconnects members that were previously reachable through the bridge.
Uses Tarjan's algorithm for finding articulation points.
Returns:
List of dicts with keys: entry, cluster_size, bridges_between
"""
adj = self._build_adjacency()
# Find clusters first
clusters = self.graph_clusters(min_size=3)
if not clusters:
return []
# For each cluster, run Tarjan's algorithm
bridges: list[dict] = []
for cluster in clusters:
members = set(cluster["entries"])
if len(members) < 3:
continue
# Build subgraph adjacency
sub_adj = {eid: adj[eid] & members for eid in members}
# Tarjan's DFS for articulation points
discovery: dict[str, int] = {}
low: dict[str, int] = {}
parent: dict[str, Optional[str]] = {}
ap: set[str] = set()
timer = [0]
def dfs(u: str):
children = 0
discovery[u] = low[u] = timer[0]
timer[0] += 1
for v in sub_adj[u]:
if v not in discovery:
children += 1
parent[v] = u
dfs(v)
low[u] = min(low[u], low[v])
# u is AP if: root with 2+ children, or non-root with low[v] >= disc[u]
if parent.get(u) is None and children > 1:
ap.add(u)
if parent.get(u) is not None and low[v] >= discovery[u]:
ap.add(u)
elif v != parent.get(u):
low[u] = min(low[u], discovery[v])
for eid in members:
if eid not in discovery:
parent[eid] = None
dfs(eid)
# For each articulation point, estimate what it bridges
for ap_id in ap:
ap_entry = self._entries[ap_id]
# Remove it temporarily and count resulting components
temp_adj = {k: v.copy() for k, v in sub_adj.items()}
del temp_adj[ap_id]
for k in temp_adj:
temp_adj[k].discard(ap_id)
# BFS count components after removal
temp_visited: set[str] = set()
component_count = 0
for mid in members:
if mid == ap_id or mid in temp_visited:
continue
component_count += 1
queue = [mid]
while queue:
cur = queue.pop(0)
if cur in temp_visited:
continue
temp_visited.add(cur)
for nb in temp_adj.get(cur, set()):
if nb not in temp_visited:
queue.append(nb)
if component_count > 1:
bridges.append({
"entry": ap_entry,
"cluster_size": cluster["size"],
"components_after_removal": component_count,
"topics": ap_entry.topics,
})
bridges.sort(key=lambda b: b["components_after_removal"], reverse=True)
return bridges
def rebuild_links(self, threshold: Optional[float] = None) -> int:
"""Recompute all links from scratch.
Clears existing links and re-applies the holographic linker to every
entry pair. Useful after bulk ingestion or threshold changes.
Args:
threshold: Override the linker's default similarity threshold.
Returns:
Total number of links created.
"""
if threshold is not None:
old_threshold = self.linker.threshold
self.linker.threshold = threshold
# Clear all links
for entry in self._entries.values():
entry.links = []
entries = list(self._entries.values())
total_links = 0
# Re-link each entry against all others
for entry in entries:
candidates = [e for e in entries if e.id != entry.id]
new_links = self.linker.apply_links(entry, candidates)
total_links += new_links
if threshold is not None:
self.linker.threshold = old_threshold
self._save()
return total_links

View File

@@ -1,8 +1,7 @@
"""CLI interface for Mnemosyne.
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
mnemosyne topics, mnemosyne remove, mnemosyne export,
mnemosyne clusters, mnemosyne hubs, mnemosyne bridges, mnemosyne rebuild
mnemosyne topics, mnemosyne remove, mnemosyne export
"""
from __future__ import annotations
@@ -91,58 +90,6 @@ def cmd_export(args):
print(json.dumps(data, indent=2))
def cmd_clusters(args):
archive = MnemosyneArchive()
clusters = archive.graph_clusters(min_size=args.min_size)
if not clusters:
print("No clusters found.")
return
for c in clusters:
print(f"Cluster {c['cluster_id']}: {c['size']} entries, density={c['density']}")
print(f" Topics: {', '.join(c['top_topics']) if c['top_topics'] else '(none)'}")
if args.verbose:
for eid in c["entries"]:
entry = archive.get(eid)
if entry:
print(f" [{eid[:8]}] {entry.title}")
print()
def cmd_hubs(args):
archive = MnemosyneArchive()
hubs = archive.hub_entries(limit=args.limit)
if not hubs:
print("No hubs found.")
return
for h in hubs:
e = h["entry"]
print(f"[{e.id[:8]}] {e.title}")
print(f" Degree: {h['degree']} (in: {h['inbound']}, out: {h['outbound']})")
print(f" Topics: {', '.join(h['topics']) if h['topics'] else '(none)'}")
print()
def cmd_bridges(args):
archive = MnemosyneArchive()
bridges = archive.bridge_entries()
if not bridges:
print("No bridge entries found.")
return
for b in bridges:
e = b["entry"]
print(f"[{e.id[:8]}] {e.title}")
print(f" Bridges {b['components_after_removal']} components (cluster: {b['cluster_size']} entries)")
print(f" Topics: {', '.join(b['topics']) if b['topics'] else '(none)'}")
print()
def cmd_rebuild(args):
archive = MnemosyneArchive()
threshold = args.threshold if args.threshold else None
total = archive.rebuild_links(threshold=threshold)
print(f"Rebuilt links: {total} connections across {archive.count} entries")
def main():
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
sub = parser.add_subparsers(dest="command")
@@ -172,18 +119,6 @@ def main():
ex.add_argument("-q", "--query", default="", help="Keyword filter")
ex.add_argument("-t", "--topics", default="", help="Comma-separated topic filter")
cl = sub.add_parser("clusters", help="Show graph clusters (connected components)")
cl.add_argument("-m", "--min-size", type=int, default=1, help="Minimum cluster size")
cl.add_argument("-v", "--verbose", action="store_true", help="List entries in each cluster")
hu = sub.add_parser("hubs", help="Show most connected entries (hub analysis)")
hu.add_argument("-n", "--limit", type=int, default=10, help="Max hubs to show")
sub.add_parser("bridges", help="Show bridge entries (articulation points)")
rb = sub.add_parser("rebuild", help="Recompute all links from scratch")
rb.add_argument("-t", "--threshold", type=float, default=None, help="Similarity threshold override")
args = parser.parse_args()
if not args.command:
parser.print_help()
@@ -197,10 +132,6 @@ def main():
"topics": cmd_topics,
"remove": cmd_remove,
"export": cmd_export,
"clusters": cmd_clusters,
"hubs": cmd_hubs,
"bridges": cmd_bridges,
"rebuild": cmd_rebuild,
}
dispatch[args.command](args)

View File

@@ -1,271 +0,0 @@
"""Tests for Mnemosyne graph cluster analysis features.
Tests: graph_clusters, hub_entries, bridge_entries, rebuild_links.
"""
import pytest
from pathlib import Path
import tempfile
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
@pytest.fixture
def archive():
"""Create a fresh archive in a temp directory."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
a = MnemosyneArchive(archive_path=path)
yield a
def _make_entry(title="Test", content="test content", topics=None):
return ArchiveEntry(title=title, content=content, topics=topics or [])
class TestGraphClusters:
"""Test graph_clusters() connected component discovery."""
def test_empty_archive(self, archive):
clusters = archive.graph_clusters()
assert clusters == []
def test_single_orphan(self, archive):
archive.add(_make_entry("Lone entry"), auto_link=False)
# min_size=1 includes orphans
clusters = archive.graph_clusters(min_size=1)
assert len(clusters) == 1
assert clusters[0]["size"] == 1
assert clusters[0]["density"] == 0.0
def test_single_orphan_filtered(self, archive):
archive.add(_make_entry("Lone entry"), auto_link=False)
clusters = archive.graph_clusters(min_size=2)
assert clusters == []
def test_two_linked_entries(self, archive):
"""Two manually linked entries form a cluster."""
e1 = archive.add(_make_entry("Alpha dogs", "canine training"), auto_link=False)
e2 = archive.add(_make_entry("Beta cats", "feline behavior"), auto_link=False)
# Manual link
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert len(clusters) == 1
assert clusters[0]["size"] == 2
assert clusters[0]["internal_edges"] == 1
assert clusters[0]["density"] == 1.0 # 1 edge out of 1 possible
def test_two_separate_clusters(self, archive):
"""Two disconnected groups form separate clusters."""
a1 = archive.add(_make_entry("AI models", "neural networks"), auto_link=False)
a2 = archive.add(_make_entry("AI training", "gradient descent"), auto_link=False)
b1 = archive.add(_make_entry("Cooking pasta", "italian recipes"), auto_link=False)
b2 = archive.add(_make_entry("Cooking sauces", "tomato basil"), auto_link=False)
# Link cluster A
a1.links.append(a2.id)
a2.links.append(a1.id)
# Link cluster B
b1.links.append(b2.id)
b2.links.append(b1.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert len(clusters) == 2
sizes = sorted(c["size"] for c in clusters)
assert sizes == [2, 2]
def test_cluster_topics(self, archive):
"""Cluster includes aggregated topics."""
e1 = archive.add(_make_entry("Alpha", "content", topics=["ai", "models"]), auto_link=False)
e2 = archive.add(_make_entry("Beta", "content", topics=["ai", "training"]), auto_link=False)
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert "ai" in clusters[0]["top_topics"]
def test_density_calculation(self, archive):
"""Triangle (3 nodes, 3 edges) has density 1.0."""
e1 = archive.add(_make_entry("A", "aaa"), auto_link=False)
e2 = archive.add(_make_entry("B", "bbb"), auto_link=False)
e3 = archive.add(_make_entry("C", "ccc"), auto_link=False)
# Fully connected triangle
for e, others in [(e1, [e2, e3]), (e2, [e1, e3]), (e3, [e1, e2])]:
for o in others:
e.links.append(o.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert len(clusters) == 1
assert clusters[0]["internal_edges"] == 3
assert clusters[0]["density"] == 1.0 # 3 edges / 3 possible
def test_chain_density(self, archive):
"""A-B-C chain has density 2/3 (2 edges out of 3 possible)."""
e1 = archive.add(_make_entry("A", "aaa"), auto_link=False)
e2 = archive.add(_make_entry("B", "bbb"), auto_link=False)
e3 = archive.add(_make_entry("C", "ccc"), auto_link=False)
# Chain: A-B-C
e1.links.append(e2.id)
e2.links.extend([e1.id, e3.id])
e3.links.append(e2.id)
archive._save()
clusters = archive.graph_clusters(min_size=2)
assert abs(clusters[0]["density"] - 2/3) < 0.01
class TestHubEntries:
"""Test hub_entries() degree centrality ranking."""
def test_empty(self, archive):
assert archive.hub_entries() == []
def test_no_links(self, archive):
archive.add(_make_entry("Lone"), auto_link=False)
assert archive.hub_entries() == []
def test_hub_ordering(self, archive):
"""Entry with most links is ranked first."""
e1 = archive.add(_make_entry("Hub", "central node"), auto_link=False)
e2 = archive.add(_make_entry("Spoke 1", "content"), auto_link=False)
e3 = archive.add(_make_entry("Spoke 2", "content"), auto_link=False)
e4 = archive.add(_make_entry("Spoke 3", "content"), auto_link=False)
# e1 connects to all spokes
e1.links.extend([e2.id, e3.id, e4.id])
e2.links.append(e1.id)
e3.links.append(e1.id)
e4.links.append(e1.id)
archive._save()
hubs = archive.hub_entries()
assert len(hubs) == 4
assert hubs[0]["entry"].id == e1.id
assert hubs[0]["degree"] == 3
def test_limit(self, archive):
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
assert len(archive.hub_entries(limit=1)) == 1
def test_inbound_outbound(self, archive):
"""Inbound counts links TO an entry, outbound counts links FROM it."""
e1 = archive.add(_make_entry("Source", ""), auto_link=False)
e2 = archive.add(_make_entry("Target", ""), auto_link=False)
# Only e1 links to e2
e1.links.append(e2.id)
archive._save()
hubs = archive.hub_entries()
h1 = next(h for h in hubs if h["entry"].id == e1.id)
h2 = next(h for h in hubs if h["entry"].id == e2.id)
assert h1["inbound"] == 0
assert h1["outbound"] == 1
assert h2["inbound"] == 1
assert h2["outbound"] == 0
class TestBridgeEntries:
"""Test bridge_entries() articulation point detection."""
def test_empty(self, archive):
assert archive.bridge_entries() == []
def test_no_bridges_in_triangle(self, archive):
"""Fully connected triangle has no articulation points."""
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e3 = archive.add(_make_entry("C", ""), auto_link=False)
for e, others in [(e1, [e2, e3]), (e2, [e1, e3]), (e3, [e1, e2])]:
for o in others:
e.links.append(o.id)
archive._save()
assert archive.bridge_entries() == []
def test_bridge_in_chain(self, archive):
"""A-B-C chain: B is the articulation point."""
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e3 = archive.add(_make_entry("C", ""), auto_link=False)
e1.links.append(e2.id)
e2.links.extend([e1.id, e3.id])
e3.links.append(e2.id)
archive._save()
bridges = archive.bridge_entries()
assert len(bridges) == 1
assert bridges[0]["entry"].id == e2.id
assert bridges[0]["components_after_removal"] == 2
def test_no_bridges_in_small_cluster(self, archive):
"""Two-node clusters are too small for bridge detection."""
e1 = archive.add(_make_entry("A", ""), auto_link=False)
e2 = archive.add(_make_entry("B", ""), auto_link=False)
e1.links.append(e2.id)
e2.links.append(e1.id)
archive._save()
assert archive.bridge_entries() == []
class TestRebuildLinks:
"""Test rebuild_links() full recomputation."""
def test_empty_archive(self, archive):
assert archive.rebuild_links() == 0
def test_creates_links(self, archive):
"""Rebuild creates links between similar entries."""
archive.add(_make_entry("Alpha dogs canine training", "obedience training"), auto_link=False)
archive.add(_make_entry("Beta dogs canine behavior", "behavior training"), auto_link=False)
archive.add(_make_entry("Cat food feline nutrition", "fish meals"), auto_link=False)
total = archive.rebuild_links()
assert total > 0
# Check that dog entries are linked to each other
entries = list(archive._entries.values())
dog_entries = [e for e in entries if "dog" in e.title.lower()]
assert any(len(e.links) > 0 for e in dog_entries)
def test_override_threshold(self, archive):
"""Lower threshold creates more links."""
archive.add(_make_entry("Alpha dogs", "training"), auto_link=False)
archive.add(_make_entry("Beta cats", "training"), auto_link=False)
archive.add(_make_entry("Gamma birds", "training"), auto_link=False)
# Very low threshold = more links
low_links = archive.rebuild_links(threshold=0.01)
# Reset
for e in archive._entries.values():
e.links = []
# Higher threshold = fewer links
high_links = archive.rebuild_links(threshold=0.9)
assert low_links >= high_links
def test_rebuild_persists(self, archive):
"""Rebuild saves to disk."""
archive.add(_make_entry("Alpha dogs", "training"), auto_link=False)
archive.add(_make_entry("Beta dogs", "training"), auto_link=False)
archive.rebuild_links()
# Reload and verify links survived
archive2 = MnemosyneArchive(archive_path=archive.path)
entries = list(archive2._entries.values())
total_links = sum(len(e.links) for e in entries)
assert total_links > 0

364
style.css
View File

@@ -1713,367 +1713,3 @@ canvas#nexus-canvas {
transform: translateX(16px);
background: #4af0c0;
}
/* ═══ MNEMOSYNE: Memory Inspect Panel (issue #1227) ═══ */
.memory-inspect-panel {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%) translateX(20px);
width: 320px;
max-height: 80vh;
background: rgba(10, 12, 20, 0.94);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(74, 240, 192, 0.25);
border-radius: 12px;
display: flex;
flex-direction: column;
z-index: 200;
opacity: 0;
transition: opacity 0.25s ease, transform 0.25s ease;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
pointer-events: none;
}
.memory-inspect-panel.mi-visible {
opacity: 1;
transform: translateY(-50%) translateX(0);
pointer-events: auto;
}
.mi-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px 12px;
border-bottom: 1px solid rgba(74, 240, 192, 0.12);
flex-shrink: 0;
}
.mi-region-glyph {
font-size: 20px;
flex-shrink: 0;
}
.mi-header-text {
flex: 1;
min-width: 0;
}
.mi-id {
color: var(--color-text-bright);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mi-region {
font-size: 11px;
margin-top: 2px;
letter-spacing: 0.3px;
}
.mi-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.35);
font-size: 15px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.mi-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.mi-body {
overflow-y: auto;
padding: 12px 0 8px;
flex: 1;
}
.mi-body::-webkit-scrollbar { width: 4px; }
.mi-body::-webkit-scrollbar-track { background: transparent; }
.mi-body::-webkit-scrollbar-thumb { background: rgba(74, 240, 192, 0.2); border-radius: 2px; }
.mi-section {
padding: 6px 16px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.mi-section:last-child { border-bottom: none; }
.mi-section-label {
color: rgba(74, 240, 192, 0.6);
font-size: 9px;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 6px;
}
.mi-content {
color: var(--color-text);
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
max-height: 140px;
overflow-y: auto;
}
.mi-content::-webkit-scrollbar { width: 3px; }
.mi-content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; }
.mi-vitality-row {
display: flex;
align-items: center;
gap: 10px;
}
.mi-vitality-bar-track {
flex: 1;
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
}
.mi-vitality-bar {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.mi-vitality-pct {
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
width: 34px;
text-align: right;
}
.mi-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mi-link-btn {
background: rgba(123, 92, 255, 0.12);
border: 1px solid rgba(123, 92, 255, 0.35);
color: #b8a0ff;
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mi-link-btn:hover {
background: rgba(123, 92, 255, 0.25);
border-color: #7b5cff;
color: #fff;
}
.mi-empty {
color: rgba(255, 255, 255, 0.3);
font-size: 11px;
font-style: italic;
}
.mi-meta-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 11px;
margin-bottom: 4px;
}
.mi-meta-key {
color: rgba(255, 255, 255, 0.4);
flex-shrink: 0;
}
.mi-meta-val {
color: var(--color-text);
text-align: right;
word-break: break-all;
}
.mi-actions {
padding: 8px 16px 4px;
display: flex;
gap: 8px;
}
.mi-action-btn {
background: rgba(74, 240, 192, 0.08);
border: 1px solid rgba(74, 240, 192, 0.25);
color: #4af0c0;
font-size: 11px;
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.mi-action-btn:hover {
background: rgba(74, 240, 192, 0.18);
border-color: #4af0c0;
}
/* ═══ MNEMOSYNE: Memory Connections Panel ═══ */
.memory-connections-panel {
position: fixed;
top: 50%;
right: 280px;
transform: translateY(-50%) translateX(12px);
width: 260px;
max-height: 70vh;
background: rgba(10, 12, 18, 0.92);
border: 1px solid rgba(74, 240, 192, 0.15);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
z-index: 310;
display: flex;
flex-direction: column;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-family: var(--font-mono, monospace);
}
.memory-connections-panel.mc-visible {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.mc-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.mc-title {
color: rgba(74, 240, 192, 0.8);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mc-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.mc-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.mc-section {
padding: 8px 14px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.mc-section:last-child { border-bottom: none; }
.mc-section-label {
color: rgba(74, 240, 192, 0.5);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.mc-conn-list, .mc-suggest-list {
max-height: 200px;
overflow-y: auto;
}
.mc-conn-list::-webkit-scrollbar, .mc-suggest-list::-webkit-scrollbar { width: 3px; }
.mc-conn-list::-webkit-scrollbar-thumb, .mc-suggest-list::-webkit-scrollbar-thumb {
background: rgba(74, 240, 192, 0.15);
border-radius: 2px;
}
.mc-conn-item, .mc-suggest-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: 5px;
margin-bottom: 4px;
transition: background 0.15s ease;
}
.mc-conn-item:hover {
background: rgba(74, 240, 192, 0.06);
}
.mc-suggest-item:hover {
background: rgba(123, 92, 255, 0.06);
}
.mc-conn-info, .mc-suggest-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.mc-conn-label, .mc-suggest-label {
display: block;
color: var(--color-text, #ccc);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mc-conn-meta, .mc-suggest-meta {
display: block;
color: rgba(255, 255, 255, 0.3);
font-size: 9px;
margin-top: 1px;
}
.mc-conn-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 8px;
}
.mc-btn {
background: none;
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
padding: 2px 6px;
line-height: 1;
transition: all 0.15s ease;
}
.mc-btn-nav:hover {
border-color: #4af0c0;
color: #4af0c0;
background: rgba(74, 240, 192, 0.08);
}
.mc-btn-remove:hover {
border-color: #ff4466;
color: #ff4466;
background: rgba(255, 68, 102, 0.08);
}
.mc-btn-add {
border-color: rgba(123, 92, 255, 0.3);
color: rgba(123, 92, 255, 0.7);
}
.mc-btn-add:hover {
border-color: #7b5cff;
color: #7b5cff;
background: rgba(123, 92, 255, 0.12);
}
.mc-empty {
color: rgba(255, 255, 255, 0.25);
font-size: 11px;
font-style: italic;
padding: 4px 0;
}