Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander Whitestone
c961cf9122 test(mnemosyne): add graph_data() tests
Some checks failed
CI / test (pull_request) Failing after 12s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- empty archive returns empty nodes/edges
- nodes have all required fields
- edges have weights in [0,1]
- topic_filter returns subgraph
- bidirectional edges deduplicated
2026-04-11 18:14:34 -04:00
Alexander Whitestone
a1c038672b feat(mnemosyne): add graph_data() for 3D constellation export
Returns {nodes, edges} with live link weights. Supports topic_filter
for subgraph extraction. Edges are deduplicated (bidirectional links
become single undirected edges).

Closes #1232
2026-04-11 18:14:16 -04:00
ed5ed011c2 [claude] Memory Inspect Panel — click-to-read detail view (#1227) (#1229)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 21:17:42 +00:00
6 changed files with 568 additions and 1 deletions

54
app.js
View File

@@ -6,6 +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';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -48,6 +49,7 @@ 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,6 +714,7 @@ async function init() {
MemoryBirth.init(scene);
MemoryBirth.wrapSpatialMemory(SpatialMemory);
SpatialMemory.setCamera(camera);
MemoryInspect.init({ onNavigate: _navigateToMemory });
updateLoad(90);
loadSession();
@@ -1903,6 +1906,8 @@ function setupControls() {
mouseDown = true;
orbitState.lastX = e.clientX;
orbitState.lastY = e.clientY;
_clickStartX = e.clientX;
_clickStartY = e.clientY;
// Raycasting for portals
if (!portalOverlayActive) {
@@ -1921,7 +1926,37 @@ function setupControls() {
}
}
});
document.addEventListener('mouseup', () => { mouseDown = false; });
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();
}
}
}
});
document.addEventListener('mousemove', (e) => {
if (!mouseDown) return;
if (document.activeElement === document.getElementById('chat-input')) return;
@@ -2152,6 +2187,23 @@ 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;

View File

@@ -473,6 +473,9 @@ 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>
<script>
// ─── MNEMOSYNE: Memory Filter Panel ───────────────────

View File

@@ -0,0 +1,180 @@
// ═══════════════════════════════════════════════════════════
// 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

@@ -212,6 +212,65 @@ class MnemosyneArchive:
def count(self) -> int:
return len(self._entries)
def graph_data(
self,
topic_filter: Optional[str] = None,
) -> dict:
"""Export the full connection graph for 3D constellation visualization.
Returns a dict with:
- nodes: list of {id, title, topics, source, created_at}
- edges: list of {source, target, weight} from holographic links
Args:
topic_filter: If set, only include entries matching this topic
and edges between them.
"""
entries = list(self._entries.values())
if topic_filter:
topic_lower = topic_filter.lower()
entries = [
e for e in entries
if topic_lower in [t.lower() for t in e.topics]
]
entry_ids = {e.id for e in entries}
nodes = [
{
"id": e.id,
"title": e.title,
"topics": e.topics,
"source": e.source,
"created_at": e.created_at,
}
for e in entries
]
# Build edges from links, dedup (A→B and B→A become one edge)
seen_edges: set[tuple[str, str]] = set()
edges = []
for e in entries:
for linked_id in e.links:
if linked_id not in entry_ids:
continue
pair = (min(e.id, linked_id), max(e.id, linked_id))
if pair in seen_edges:
continue
seen_edges.add(pair)
# Compute weight via linker for live similarity score
linked = self._entries.get(linked_id)
if linked:
weight = self.linker.compute_similarity(e, linked)
edges.append({
"source": pair[0],
"target": pair[1],
"weight": round(weight, 4),
})
return {"nodes": nodes, "edges": edges}
def stats(self) -> dict:
entries = list(self._entries.values())
total_links = sum(len(e.links) for e in entries)

View File

@@ -262,6 +262,75 @@ def test_semantic_search_vs_keyword_relevance():
assert results[0].title == "Python scripting"
def test_graph_data_empty_archive():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
data = archive.graph_data()
assert data == {"nodes": [], "edges": []}
def test_graph_data_nodes_and_edges():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python", topics=["code"])
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python", topics=["code"])
e3 = ingest_event(archive, title="Cooking", content="Making pasta carbonara", topics=["food"])
data = archive.graph_data()
assert len(data["nodes"]) == 3
# All node fields present
for node in data["nodes"]:
assert "id" in node
assert "title" in node
assert "topics" in node
assert "source" in node
assert "created_at" in node
# e1 and e2 should be linked (shared Python/automation tokens)
edge_pairs = {(e["source"], e["target"]) for e in data["edges"]}
e1e2 = (min(e1.id, e2.id), max(e1.id, e2.id))
assert e1e2 in edge_pairs or (e1e2[1], e1e2[0]) in edge_pairs
# All edges have weights
for edge in data["edges"]:
assert "weight" in edge
assert 0 <= edge["weight"] <= 1
def test_graph_data_topic_filter():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
e1 = ingest_event(archive, title="A", content="code stuff", topics=["code"])
e2 = ingest_event(archive, title="B", content="more code", topics=["code"])
ingest_event(archive, title="C", content="food stuff", topics=["food"])
data = archive.graph_data(topic_filter="code")
node_ids = {n["id"] for n in data["nodes"]}
assert e1.id in node_ids
assert e2.id in node_ids
assert len(data["nodes"]) == 2
def test_graph_data_deduplicates_edges():
"""Bidirectional links should produce a single edge, not two."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python")
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python")
data = archive.graph_data()
# Count how many edges connect e1 and e2
e1e2_edges = [
e for e in data["edges"]
if {e["source"], e["target"]} == {e1.id, e2.id}
]
assert len(e1e2_edges) <= 1, "Should not have duplicate bidirectional edges"
def test_archive_topic_counts():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"

204
style.css
View File

@@ -1713,3 +1713,207 @@ 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;
}