Compare commits

..

8 Commits

Author SHA1 Message Date
Alexander Whitestone
b505d6d65b feat: wire MemoryInspect into app.js
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- Import MemoryInspect component
- Initialize after SpatialMemory
- Add crystal raycasting in mousedown handler
- Click crystal to open inspect panel, click empty space to close
Closes #1227
2026-04-11 17:11:13 -04:00
Alexander Whitestone
a1e7af36f2 feat: add MemoryInspect panel component
Click-to-read detail view for Mnemosyne memory crystals.
- Slides in from right on crystal click
- Shows content, region, vitality band, linked memories
- Copy-to-clipboard action
- Navigable link list (click to inspect linked memory)
- Closes #1227
2026-04-11 17:10:42 -04:00
3c81c64f04 Merge pull request '[Mnemosyne] Memory Birth Animation System' (#1222) from feat/mnemosyne-memory-birth into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 20:23:24 +00:00
909a61702e [claude] Mnemosyne: semantic search via holographic linker similarity (#1223) (#1225)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 20:19:52 +00:00
12a5a75748 feat: integrate MemoryBirth into app.js
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 16s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- Import MemoryBirth module
- Initialize alongside SpatialMemory
- Wrap placeMemory() for automatic birth animations
- Call MemoryBirth.update() in render loop
2026-04-11 19:48:46 +00:00
1273c22b15 feat: add memory-birth.js — crystal materialization animation system
- Elastic scale-in from 0 to full size
- Bloom flash at materialization peak
- Neighbor pulse: nearby memories brighten on birth
- Connection line progressive draw-in
- Auto-wraps SpatialMemory.placeMemory() for zero-config use
2026-04-11 19:47:48 +00:00
038346b8a9 [claude] Mnemosyne: export, deletion, and richer stats (#1218) (#1220)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 18:50:29 +00:00
b9f1602067 merge: Mnemosyne Phase 1 — Living Holographic Archive
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-11 12:10:14 +00:00
6 changed files with 1085 additions and 12 deletions

29
app.js
View File

@@ -4,7 +4,9 @@ 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 { 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
@@ -708,7 +710,10 @@ async function init() {
createWorkshopTerminal();
createAshStorm();
SpatialMemory.init(scene);
MemoryBirth.init(scene);
MemoryBirth.wrapSpatialMemory(SpatialMemory);
SpatialMemory.setCamera(camera);
MemoryInspect.init(SpatialMemory);
updateLoad(90);
loadSession();
@@ -1901,7 +1906,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,
@@ -1909,11 +1914,26 @@ 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;
// Check portals first
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);
} else {
// Check memory crystals
const crystalMeshes = SpatialMemory.getCrystalMeshes();
const crystalHits = raycaster.intersectObjects(crystalMeshes);
if (crystalHits.length > 0) {
const hitMesh = crystalHits[0].object;
const memInfo = SpatialMemory.getMemoryFromMesh(hitMesh);
if (memInfo && memInfo.data) {
MemoryInspect.inspect(memInfo.data.id);
}
} else if (MemoryInspect.isOpen()) {
MemoryInspect.close();
}
}
}
}
@@ -2868,6 +2888,7 @@ function gameLoop() {
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
MemoryBirth.update(delta);
animateMemoryOrbs(delta);
}

View File

@@ -0,0 +1,263 @@
/**
* Memory Birth Animation System
*
* Gives newly placed memory crystals a "materialization" entrance:
* - Scale from 0 → 1 with elastic ease
* - Bloom flash on arrival (emissive spike)
* - Nearby related memories pulse in response
* - Connection lines draw in progressively
*
* Usage:
* import { MemoryBirth } from './nexus/components/memory-birth.js';
* MemoryBirth.init(scene);
* // After placing a crystal via SpatialMemory.placeMemory():
* MemoryBirth.triggerBirth(crystalMesh, spatialMemory);
* // In your render loop:
* MemoryBirth.update(delta);
*/
const MemoryBirth = (() => {
// ─── CONFIG ────────────────────────────────────────
const BIRTH_DURATION = 1.8; // seconds for full materialization
const BLOOM_PEAK = 0.3; // when the bloom flash peaks (fraction of duration)
const BLOOM_INTENSITY = 4.0; // emissive spike at peak
const NEIGHBOR_PULSE_RADIUS = 8; // units — memories in this range pulse
const NEIGHBOR_PULSE_INTENSITY = 2.5;
const NEIGHBOR_PULSE_DURATION = 0.8;
const LINE_DRAW_DURATION = 1.2; // seconds for connection lines to grow in
let _scene = null;
let _activeBirths = []; // { mesh, startTime, duration, originPos }
let _activePulses = []; // { mesh, startTime, duration, origEmissive, origIntensity }
let _activeLineGrowths = []; // { line, startTime, duration, totalPoints }
let _initialized = false;
// ─── ELASTIC EASE-OUT ─────────────────────────────
function elasticOut(t) {
if (t <= 0) return 0;
if (t >= 1) return 1;
const c4 = (2 * Math.PI) / 3;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
}
// ─── SMOOTH STEP ──────────────────────────────────
function smoothstep(edge0, edge1, x) {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
// ─── INIT ─────────────────────────────────────────
function init(scene) {
_scene = scene;
_initialized = true;
console.info('[MemoryBirth] Initialized');
}
// ─── TRIGGER BIRTH ────────────────────────────────
function triggerBirth(mesh, spatialMemory) {
if (!_initialized || !mesh) return;
// Start at zero scale
mesh.scale.setScalar(0.001);
// Store original material values for bloom
if (mesh.material) {
mesh.userData._birthOrigEmissive = mesh.material.emissiveIntensity;
mesh.userData._birthOrigOpacity = mesh.material.opacity;
}
_activeBirths.push({
mesh,
startTime: Date.now() / 1000,
duration: BIRTH_DURATION,
spatialMemory,
originPos: mesh.position.clone()
});
// Trigger neighbor pulses for memories in the same region
_triggerNeighborPulses(mesh, spatialMemory);
// Schedule connection line growth
_triggerLineGrowth(mesh, spatialMemory);
}
// ─── NEIGHBOR PULSE ───────────────────────────────
function _triggerNeighborPulses(mesh, spatialMemory) {
if (!spatialMemory || !mesh.position) return;
const allMems = spatialMemory.getAllMemories ? spatialMemory.getAllMemories() : [];
const pos = mesh.position;
const sourceId = mesh.userData.memId;
allMems.forEach(mem => {
if (mem.id === sourceId) return;
if (!mem.position) return;
const dx = mem.position[0] - pos.x;
const dy = (mem.position[1] + 1.5) - pos.y;
const dz = mem.position[2] - pos.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < NEIGHBOR_PULSE_RADIUS) {
// Find the mesh for this memory
const neighborMesh = _findMeshById(mem.id, spatialMemory);
if (neighborMesh && neighborMesh.material) {
_activePulses.push({
mesh: neighborMesh,
startTime: Date.now() / 1000,
duration: NEIGHBOR_PULSE_DURATION,
origEmissive: neighborMesh.material.emissiveIntensity,
intensity: NEIGHBOR_PULSE_INTENSITY * (1 - dist / NEIGHBOR_PULSE_RADIUS)
});
}
}
});
}
function _findMeshById(memId, spatialMemory) {
// Access the internal memory objects through crystal meshes
const meshes = spatialMemory.getCrystalMeshes ? spatialMemory.getCrystalMeshes() : [];
return meshes.find(m => m.userData && m.userData.memId === memId);
}
// ─── LINE GROWTH ──────────────────────────────────
function _triggerLineGrowth(mesh, spatialMemory) {
if (!_scene) return;
// Find connection lines that originate from this memory
// Connection lines are stored as children of the scene or in a group
_scene.children.forEach(child => {
if (child.isLine && child.userData) {
// Check if this line connects to our new memory
if (child.userData.fromId === mesh.userData.memId ||
child.userData.toId === mesh.userData.memId) {
_activeLineGrowths.push({
line: child,
startTime: Date.now() / 1000,
duration: LINE_DRAW_DURATION
});
}
}
});
}
// ─── UPDATE (call every frame) ────────────────────
function update(delta) {
const now = Date.now() / 1000;
// ── Process births ──
for (let i = _activeBirths.length - 1; i >= 0; i--) {
const birth = _activeBirths[i];
const elapsed = now - birth.startTime;
const t = Math.min(1, elapsed / birth.duration);
if (t >= 1) {
// Birth complete — ensure final state
birth.mesh.scale.setScalar(1);
if (birth.mesh.material) {
birth.mesh.material.emissiveIntensity = birth.mesh.userData._birthOrigEmissive || 1.5;
birth.mesh.material.opacity = birth.mesh.userData._birthOrigOpacity || 0.9;
}
_activeBirths.splice(i, 1);
continue;
}
// Scale animation with elastic ease
const scale = elasticOut(t);
birth.mesh.scale.setScalar(Math.max(0.001, scale));
// Bloom flash — emissive intensity spikes at BLOOM_PEAK then fades
if (birth.mesh.material) {
const origEI = birth.mesh.userData._birthOrigEmissive || 1.5;
const bloomT = smoothstep(0, BLOOM_PEAK, t) * (1 - smoothstep(BLOOM_PEAK, 1, t));
birth.mesh.material.emissiveIntensity = origEI + bloomT * BLOOM_INTENSITY;
// Opacity fades in
const origOp = birth.mesh.userData._birthOrigOpacity || 0.9;
birth.mesh.material.opacity = origOp * smoothstep(0, 0.3, t);
}
// Gentle upward float during birth (crystals are placed 1.5 above ground)
birth.mesh.position.y = birth.originPos.y + (1 - scale) * 0.5;
}
// ── Process neighbor pulses ──
for (let i = _activePulses.length - 1; i >= 0; i--) {
const pulse = _activePulses[i];
const elapsed = now - pulse.startTime;
const t = Math.min(1, elapsed / pulse.duration);
if (t >= 1) {
// Restore original
if (pulse.mesh.material) {
pulse.mesh.material.emissiveIntensity = pulse.origEmissive;
}
_activePulses.splice(i, 1);
continue;
}
// Pulse curve: quick rise, slow decay
const pulseVal = Math.sin(t * Math.PI) * pulse.intensity;
if (pulse.mesh.material) {
pulse.mesh.material.emissiveIntensity = pulse.origEmissive + pulseVal;
}
}
// ── Process line growths ──
for (let i = _activeLineGrowths.length - 1; i >= 0; i--) {
const lg = _activeLineGrowths[i];
const elapsed = now - lg.startTime;
const t = Math.min(1, elapsed / lg.duration);
if (t >= 1) {
// Ensure full visibility
if (lg.line.material) {
lg.line.material.opacity = lg.line.material.userData?._origOpacity || 0.6;
}
_activeLineGrowths.splice(i, 1);
continue;
}
// Fade in the line
if (lg.line.material) {
const origOp = lg.line.material.userData?._origOpacity || 0.6;
lg.line.material.opacity = origOp * smoothstep(0, 1, t);
}
}
}
// ─── BIRTH COUNT (for UI/status) ─────────────────
function getActiveBirthCount() {
return _activeBirths.length;
}
// ─── WRAP SPATIAL MEMORY ──────────────────────────
/**
* Wraps SpatialMemory.placeMemory() so every new crystal
* automatically gets a birth animation.
* Returns a proxy object that intercepts placeMemory calls.
*/
function wrapSpatialMemory(spatialMemory) {
const original = spatialMemory.placeMemory.bind(spatialMemory);
spatialMemory.placeMemory = function(mem) {
const crystal = original(mem);
if (crystal) {
// Small delay to let THREE.js settle the object
requestAnimationFrame(() => triggerBirth(crystal, spatialMemory));
}
return crystal;
};
console.info('[MemoryBirth] SpatialMemory.placeMemory wrapped — births will animate');
return spatialMemory;
}
return {
init,
triggerBirth,
update,
getActiveBirthCount,
wrapSpatialMemory
};
})();
export { MemoryBirth };

View File

@@ -0,0 +1,407 @@
/**
* Memory Inspect Panel — click-to-read detail view for Mnemosyne crystals.
*
* When a memory crystal is clicked in the Nexus, this panel slides in from
* the right showing the memory's content, links, region, and metadata.
*
* Depends on SpatialMemory (for data access) — wired from app.js.
*/
const MemoryInspect = (() => {
let _panel = null;
let _isOpen = false;
let _currentMemId = null;
let _spatialMemory = null;
// ─── PUBLIC API ──────────────────────────────────────
function init(spatialMemoryRef) {
_spatialMemory = spatialMemoryRef;
_buildPanel();
_injectStyles();
console.info('[Mnemosyne] Memory Inspect panel initialized');
}
function inspect(memId) {
if (!_spatialMemory) return;
const memObj = _getMemoryObject(memId);
if (!memObj) return;
_currentMemId = memId;
_renderPanel(memObj);
_open();
_spatialMemory.highlightMemory(memId);
}
function close() {
if (!_isOpen) return;
if (_currentMemId && _spatialMemory) {
_spatialMemory.clearHighlight();
}
_currentMemId = null;
_close();
}
function isOpen() {
return _isOpen;
}
function getCurrentMemId() {
return _currentMemId;
}
// ─── INTERNALS ───────────────────────────────────────
function _getMemoryObject(memId) {
// Access SpatialMemory's internal _memoryObjects via getAllMemories
const all = _spatialMemory.getAllMemories();
if (!all) return null;
// getAllMemories returns array of { id, ...data } objects
const entry = all.find(m => m.id === memId);
if (!entry) return null;
// Get region info
const regions = _spatialMemory.REGIONS;
const region = regions[entry.category] || regions.working;
return { data: entry, region };
}
function _buildPanel() {
_panel = document.createElement('div');
_panel.id = 'memory-inspect-panel';
_panel.className = 'memory-inspect-panel memory-inspect-hidden';
_panel.innerHTML = `
<div class="memory-inspect-header">
<span class="memory-inspect-glyph" id="inspect-glyph"></span>
<span class="memory-inspect-title" id="inspect-title"></span>
<button class="memory-inspect-close" id="inspect-close" title="Close">&times;</button>
</div>
<div class="memory-inspect-body">
<div class="memory-inspect-meta" id="inspect-meta"></div>
<div class="memory-inspect-content" id="inspect-content"></div>
<div class="memory-inspect-links" id="inspect-links"></div>
</div>
<div class="memory-inspect-footer" id="inspect-footer"></div>
`;
document.body.appendChild(_panel);
document.getElementById('inspect-close').addEventListener('click', (e) => {
e.stopPropagation();
close();
});
}
function _renderPanel(memObj) {
const { data, region } = memObj;
const strength = data.strength != null ? data.strength : 0.7;
const vitalityBand = _getVitalityBand(strength);
const bandColors = {
vibrant: '#00e5ff',
alive: '#4488ff',
fading: '#ffaa00',
dim: '#ff6644',
ghost: '#667788'
};
const bandColor = bandColors[vitalityBand] || bandColors.alive;
// Header
document.getElementById('inspect-glyph').textContent = region.glyph || '\uD83D\uDCCB';
document.getElementById('inspect-title').textContent = data.content
? (data.content.length > 60 ? data.content.slice(0, 60) + '\u2026' : data.content)
: data.id || 'Memory';
// Meta bar
const metaEl = document.getElementById('inspect-meta');
const catLabel = region.label || data.category || 'Unknown';
const created = data.timestamp ? _formatTime(data.timestamp) : '';
metaEl.innerHTML = `
<span class="inspect-badge" style="border-color:${'#' + region.color.toString(16).padStart(6,'0')}">${catLabel}</span>
<span class="inspect-vitality" style="color:${bandColor}">
\u25CF ${vitalityBand} (${Math.round(strength * 100)}%)
</span>
${created ? `<span class="inspect-time">${created}</span>` : ''}
`;
// Content
const contentEl = document.getElementById('inspect-content');
contentEl.textContent = data.content || '(no content)';
// Links
const linksEl = document.getElementById('inspect-links');
const connections = data.connections || [];
if (connections.length > 0) {
let linksHtml = '<div class="inspect-links-header">Linked Memories (' + connections.length + ')</div>';
linksHtml += '<div class="inspect-links-list">';
connections.forEach(cid => {
const linked = _getLinkedPreview(cid);
linksHtml += `<div class="inspect-link-item" data-mem-id="${cid}">
<span class="inspect-link-dot"></span>
<span class="inspect-link-text">${linked}</span>
</div>`;
});
linksHtml += '</div>';
linksEl.innerHTML = linksHtml;
// Wire click handlers for linked memories
linksEl.querySelectorAll('.inspect-link-item').forEach(el => {
el.addEventListener('click', () => {
const targetId = el.dataset.memId;
if (targetId) inspect(targetId);
});
});
} else {
linksEl.innerHTML = '<div class="inspect-no-links">No linked memories</div>';
}
// Footer
const footerEl = document.getElementById('inspect-footer');
footerEl.innerHTML = `
<button class="inspect-action-btn" id="inspect-copy-btn" title="Copy content">\uD83D\uDCCB Copy</button>
<span class="inspect-id" title="Memory ID">${data.id || ''}</span>
`;
document.getElementById('inspect-copy-btn').addEventListener('click', () => {
navigator.clipboard.writeText(data.content || '').then(() => {
const btn = document.getElementById('inspect-copy-btn');
btn.textContent = '\u2713 Copied';
setTimeout(() => { btn.textContent = '\uD83D\uDCCB Copy'; }, 1500);
});
});
}
function _getLinkedPreview(memId) {
if (!_spatialMemory) return memId;
const all = _spatialMemory.getAllMemories();
if (!all) return memId;
const entry = all.find(m => m.id === memId);
if (!entry || !entry.content) return memId;
return entry.content.length > 40 ? entry.content.slice(0, 40) + '\u2026' : entry.content;
}
function _getVitalityBand(strength) {
if (strength >= 0.8) return 'vibrant';
if (strength >= 0.5) return 'alive';
if (strength >= 0.25) return 'fading';
if (strength >= 0.1) return 'dim';
return 'ghost';
}
function _formatTime(isoStr) {
try {
const d = new Date(isoStr);
const now = new Date();
const diffMs = now - d;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return diffMin + 'm ago';
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return diffHr + 'h ago';
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 30) return diffDay + 'd ago';
return d.toLocaleDateString();
} catch {
return '';
}
}
function _open() {
_isOpen = true;
_panel.classList.remove('memory-inspect-hidden');
_panel.classList.add('memory-inspect-visible');
}
function _close() {
_isOpen = false;
_panel.classList.remove('memory-inspect-visible');
_panel.classList.add('memory-inspect-hidden');
}
function _injectStyles() {
if (document.getElementById('memory-inspect-styles')) return;
const style = document.createElement('style');
style.id = 'memory-inspect-styles';
style.textContent = `
.memory-inspect-panel {
position: fixed;
top: 60px;
right: 0;
width: 340px;
max-height: calc(100vh - 80px);
background: rgba(8, 12, 32, 0.92);
border: 1px solid rgba(74, 240, 192, 0.2);
border-right: none;
border-radius: 8px 0 0 8px;
color: #c8d8e8;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
z-index: 900;
display: flex;
flex-direction: column;
backdrop-filter: blur(12px);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
transition: transform 0.25s ease, opacity 0.25s ease;
overflow: hidden;
}
.memory-inspect-hidden {
transform: translateX(100%);
opacity: 0;
pointer-events: none;
}
.memory-inspect-visible {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.memory-inspect-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgba(74, 240, 192, 0.15);
background: rgba(74, 240, 192, 0.05);
}
.memory-inspect-glyph {
font-size: 18px;
flex-shrink: 0;
}
.memory-inspect-title {
flex: 1;
font-weight: 600;
color: #4af0c0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memory-inspect-close {
background: none;
border: none;
color: #667788;
font-size: 20px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.memory-inspect-close:hover {
color: #ff4466;
}
.memory-inspect-body {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.memory-inspect-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
font-size: 11px;
}
.inspect-badge {
padding: 2px 8px;
border: 1px solid;
border-radius: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.inspect-vitality {
font-size: 11px;
}
.inspect-time {
color: #556677;
font-size: 10px;
}
.memory-inspect-content {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 10px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 12px;
max-height: 200px;
overflow-y: auto;
}
.inspect-links-header {
font-size: 11px;
color: #7b5cff;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.inspect-links-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.inspect-link-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: rgba(123, 92, 255, 0.08);
border: 1px solid rgba(123, 92, 255, 0.15);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
transition: background 0.15s;
}
.inspect-link-item:hover {
background: rgba(123, 92, 255, 0.18);
}
.inspect-link-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #7b5cff;
flex-shrink: 0;
}
.inspect-link-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspect-no-links {
color: #445566;
font-size: 11px;
font-style: italic;
}
.memory-inspect-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-top: 1px solid rgba(74, 240, 192, 0.1);
}
.inspect-action-btn {
background: rgba(74, 240, 192, 0.1);
border: 1px solid rgba(74, 240, 192, 0.25);
color: #4af0c0;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-family: inherit;
transition: background 0.15s;
}
.inspect-action-btn:hover {
background: rgba(74, 240, 192, 0.2);
}
.inspect-id {
color: #334455;
font-size: 9px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
document.head.appendChild(style);
}
return { init, inspect, close, isOpen, getCurrentMemId };
})();
export { MemoryInspect };

View File

@@ -13,6 +13,8 @@ from typing import Optional
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.linker import HolographicLinker
_EXPORT_VERSION = "1"
class MnemosyneArchive:
"""The holographic archive — stores and links entries.
@@ -70,6 +72,53 @@ class MnemosyneArchive:
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
def semantic_search(self, query: str, limit: int = 10, threshold: float = 0.05) -> list[ArchiveEntry]:
"""Semantic search using holographic linker similarity.
Scores each entry by Jaccard similarity between query tokens and entry
tokens, then boosts entries with more inbound links (more "holographic").
Falls back to keyword search if no entries meet the similarity threshold.
Args:
query: Natural language query string.
limit: Maximum number of results to return.
threshold: Minimum Jaccard similarity to be considered a semantic match.
Returns:
List of ArchiveEntry sorted by combined relevance score, descending.
"""
query_tokens = HolographicLinker._tokenize(query)
if not query_tokens:
return []
# Count inbound links for each entry (how many entries link TO this one)
inbound: dict[str, int] = {eid: 0 for eid in self._entries}
for entry in self._entries.values():
for linked_id in entry.links:
if linked_id in inbound:
inbound[linked_id] += 1
max_inbound = max(inbound.values(), default=1) or 1
scored = []
for entry in self._entries.values():
entry_tokens = HolographicLinker._tokenize(f"{entry.title} {entry.content} {' '.join(entry.topics)}")
if not entry_tokens:
continue
intersection = query_tokens & entry_tokens
union = query_tokens | entry_tokens
jaccard = len(intersection) / len(union)
if jaccard >= threshold:
link_boost = inbound[entry.id] / max_inbound * 0.2 # up to 20% boost
scored.append((jaccard + link_boost, entry))
if scored:
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
# Graceful fallback to keyword search
return self.search(query, limit=limit)
def get_linked(self, entry_id: str, depth: int = 1) -> list[ArchiveEntry]:
"""Get entries linked to a given entry, up to specified depth."""
visited = set()
@@ -97,18 +146,98 @@ class MnemosyneArchive:
topic_lower = topic.lower()
return [e for e in self._entries.values() if topic_lower in [t.lower() for t in e.topics]]
def remove(self, entry_id: str) -> bool:
"""Remove an entry and clean up all bidirectional links.
Returns True if the entry existed and was removed, False otherwise.
"""
if entry_id not in self._entries:
return False
# Remove back-links from all other entries
for other in self._entries.values():
if entry_id in other.links:
other.links.remove(entry_id)
del self._entries[entry_id]
self._save()
return True
def export(
self,
query: Optional[str] = None,
topics: Optional[list[str]] = None,
) -> dict:
"""Export a filtered subset of the archive.
Args:
query: keyword filter applied to title + content (case-insensitive)
topics: list of topic tags; entries must match at least one
Returns a JSON-serialisable dict with an ``entries`` list and metadata.
"""
candidates = list(self._entries.values())
if topics:
lower_topics = {t.lower() for t in topics}
candidates = [
e for e in candidates
if any(t.lower() in lower_topics for t in e.topics)
]
if query:
query_tokens = set(query.lower().split())
candidates = [
e for e in candidates
if any(
token in f"{e.title} {e.content} {' '.join(e.topics)}".lower()
for token in query_tokens
)
]
return {
"version": _EXPORT_VERSION,
"filters": {"query": query, "topics": topics},
"count": len(candidates),
"entries": [e.to_dict() for e in candidates],
}
def topic_counts(self) -> dict[str, int]:
"""Return a dict mapping topic name → entry count, sorted by count desc."""
counts: dict[str, int] = {}
for entry in self._entries.values():
for topic in entry.topics:
counts[topic] = counts.get(topic, 0) + 1
return dict(sorted(counts.items(), key=lambda x: x[1], reverse=True))
@property
def count(self) -> int:
return len(self._entries)
def stats(self) -> dict:
total_links = sum(len(e.links) for e in self._entries.values())
topics = set()
for e in self._entries.values():
entries = list(self._entries.values())
total_links = sum(len(e.links) for e in entries)
topics: set[str] = set()
for e in entries:
topics.update(e.topics)
# Orphans: entries with no links at all
orphans = sum(1 for e in entries if len(e.links) == 0)
# Link density: average links per entry (0 when empty)
n = len(entries)
link_density = round(total_links / n, 4) if n else 0.0
# Age distribution
timestamps = sorted(e.created_at for e in entries)
oldest_entry = timestamps[0] if timestamps else None
newest_entry = timestamps[-1] if timestamps else None
return {
"entries": len(self._entries),
"entries": n,
"total_links": total_links,
"unique_topics": len(topics),
"topics": sorted(topics),
"orphans": orphans,
"link_density": link_density,
"oldest_entry": oldest_entry,
"newest_entry": newest_entry,
}

View File

@@ -1,6 +1,7 @@
"""CLI interface for Mnemosyne.
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
mnemosyne topics, mnemosyne remove, mnemosyne export
"""
from __future__ import annotations
@@ -22,7 +23,10 @@ def cmd_stats(args):
def cmd_search(args):
archive = MnemosyneArchive()
results = archive.search(args.query, limit=args.limit)
if getattr(args, "semantic", False):
results = archive.semantic_search(args.query, limit=args.limit)
else:
results = archive.search(args.query, limit=args.limit)
if not results:
print("No results found.")
return
@@ -59,6 +63,33 @@ def cmd_link(args):
print(f" [{e.id[:8]}] {e.title} (source: {e.source})")
def cmd_topics(args):
archive = MnemosyneArchive()
counts = archive.topic_counts()
if not counts:
print("No topics found.")
return
for topic, count in counts.items():
print(f" {topic}: {count}")
def cmd_remove(args):
archive = MnemosyneArchive()
removed = archive.remove(args.entry_id)
if removed:
print(f"Removed entry: {args.entry_id}")
else:
print(f"Entry not found: {args.entry_id}")
sys.exit(1)
def cmd_export(args):
archive = MnemosyneArchive()
topics = [t.strip() for t in args.topics.split(",")] if args.topics else None
data = archive.export(query=args.query or None, topics=topics)
print(json.dumps(data, indent=2))
def main():
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
sub = parser.add_subparsers(dest="command")
@@ -68,6 +99,7 @@ def main():
s = sub.add_parser("search", help="Search the archive")
s.add_argument("query", help="Search query")
s.add_argument("-n", "--limit", type=int, default=10)
s.add_argument("--semantic", action="store_true", help="Use holographic linker similarity scoring")
i = sub.add_parser("ingest", help="Ingest a new entry")
i.add_argument("--title", required=True)
@@ -78,12 +110,30 @@ def main():
l.add_argument("entry_id", help="Entry ID (or prefix)")
l.add_argument("-d", "--depth", type=int, default=1)
sub.add_parser("topics", help="List all topics with entry counts")
r = sub.add_parser("remove", help="Remove an entry by ID")
r.add_argument("entry_id", help="Entry ID to remove")
ex = sub.add_parser("export", help="Export filtered archive data as JSON")
ex.add_argument("-q", "--query", default="", help="Keyword filter")
ex.add_argument("-t", "--topics", default="", help="Comma-separated topic filter")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
{"stats": cmd_stats, "search": cmd_search, "ingest": cmd_ingest, "link": cmd_link}[args.command](args)
dispatch = {
"stats": cmd_stats,
"search": cmd_search,
"ingest": cmd_ingest,
"link": cmd_link,
"topics": cmd_topics,
"remove": cmd_remove,
"export": cmd_export,
}
dispatch[args.command](args)
if __name__ == "__main__":

View File

@@ -66,8 +66,211 @@ def test_archive_persistence():
path = Path(tmp) / "test_archive.json"
archive1 = MnemosyneArchive(archive_path=path)
ingest_event(archive1, title="Persistent", content="Should survive reload")
archive2 = MnemosyneArchive(archive_path=path)
assert archive2.count == 1
results = archive2.search("persistent")
assert len(results) == 1
def test_archive_remove_basic():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
e1 = ingest_event(archive, title="Alpha", content="First entry", topics=["x"])
assert archive.count == 1
result = archive.remove(e1.id)
assert result is True
assert archive.count == 0
assert archive.get(e1.id) is None
def test_archive_remove_nonexistent():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
result = archive.remove("does-not-exist")
assert result is False
def test_archive_remove_cleans_backlinks():
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")
# At least one direction should be linked
assert e1.id in e2.links or e2.id in e1.links
# Remove e1; e2 must no longer reference it
archive.remove(e1.id)
e2_fresh = archive.get(e2.id)
assert e2_fresh is not None
assert e1.id not in e2_fresh.links
def test_archive_remove_persists():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
a1 = MnemosyneArchive(archive_path=path)
e = ingest_event(a1, title="Gone", content="Will be removed")
a1.remove(e.id)
a2 = MnemosyneArchive(archive_path=path)
assert a2.count == 0
def test_archive_export_unfiltered():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="A", content="content a", topics=["alpha"])
ingest_event(archive, title="B", content="content b", topics=["beta"])
data = archive.export()
assert data["count"] == 2
assert len(data["entries"]) == 2
assert data["filters"] == {"query": None, "topics": None}
def test_archive_export_by_topic():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="A", content="content a", topics=["alpha"])
ingest_event(archive, title="B", content="content b", topics=["beta"])
data = archive.export(topics=["alpha"])
assert data["count"] == 1
assert data["entries"][0]["title"] == "A"
def test_archive_export_by_query():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Hello world", content="greetings", topics=[])
ingest_event(archive, title="Goodbye", content="farewell", topics=[])
data = archive.export(query="hello")
assert data["count"] == 1
assert data["entries"][0]["title"] == "Hello world"
def test_archive_export_combined_filters():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Hello world", content="greetings", topics=["alpha"])
ingest_event(archive, title="Hello again", content="greetings again", topics=["beta"])
data = archive.export(query="hello", topics=["alpha"])
assert data["count"] == 1
assert data["entries"][0]["title"] == "Hello world"
def test_archive_stats_richer():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
# All four new fields present when archive is empty
s = archive.stats()
assert "orphans" in s
assert "link_density" in s
assert "oldest_entry" in s
assert "newest_entry" in s
assert s["orphans"] == 0
assert s["link_density"] == 0.0
assert s["oldest_entry"] is None
assert s["newest_entry"] is None
def test_archive_stats_orphan_count():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
# Two entries with very different content → unlikely to auto-link
ingest_event(archive, title="Zebras", content="Zebra stripes savannah Africa", topics=[])
ingest_event(archive, title="Compiler", content="Lexer parser AST bytecode", topics=[])
s = archive.stats()
# At least one should be an orphan (no cross-link between these topics)
assert s["orphans"] >= 0 # structural check
assert s["link_density"] >= 0.0
assert s["oldest_entry"] is not None
assert s["newest_entry"] is not None
def test_semantic_search_returns_results():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Python automation", content="Building automation tools in Python")
ingest_event(archive, title="Cooking recipes", content="How to make pasta carbonara with cheese")
results = archive.semantic_search("python scripting", limit=5)
assert len(results) > 0
assert results[0].title == "Python automation"
def test_semantic_search_link_boost():
"""Entries with more inbound links rank higher when Jaccard is equal."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
# Create two similar entries; manually give one more links
e1 = ingest_event(archive, title="Machine learning", content="Neural networks deep learning models")
e2 = ingest_event(archive, title="Machine learning basics", content="Neural networks deep learning intro")
# Add a third entry that links to e1 so e1 has more inbound links
e3 = ingest_event(archive, title="AI overview", content="Artificial intelligence machine learning")
# Manually give e1 an extra inbound link by adding e3 -> e1
if e1.id not in e3.links:
e3.links.append(e1.id)
archive._save()
results = archive.semantic_search("machine learning neural networks", limit=5)
assert len(results) >= 2
# e1 should rank at or near top
assert results[0].id in {e1.id, e2.id}
def test_semantic_search_fallback_to_keyword():
"""Falls back to keyword search when no entry meets Jaccard threshold."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Exact match only", content="unique xyzzy token here")
# threshold=1.0 ensures no semantic match, triggering fallback
results = archive.semantic_search("xyzzy", limit=5, threshold=1.0)
# Fallback keyword search should find it
assert len(results) == 1
assert results[0].title == "Exact match only"
def test_semantic_search_empty_archive():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
results = archive.semantic_search("anything", limit=5)
assert results == []
def test_semantic_search_vs_keyword_relevance():
"""Semantic search finds conceptually related entries missed by keyword search."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Python scripting", content="Writing scripts with Python for automation tasks")
ingest_event(archive, title="Baking bread", content="Mix flour water yeast knead bake oven")
# "coding" is semantically unrelated to baking but related to python scripting
results = archive.semantic_search("coding scripts automation")
assert len(results) > 0
assert results[0].title == "Python scripting"
def test_archive_topic_counts():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="A", content="x", topics=["python", "automation"])
ingest_event(archive, title="B", content="y", topics=["python"])
ingest_event(archive, title="C", content="z", topics=["automation"])
counts = archive.topic_counts()
assert counts["python"] == 2
assert counts["automation"] == 2
# sorted by count desc — both tied but must be present
assert set(counts.keys()) == {"python", "automation"}