Compare commits

...

5 Commits

Author SHA1 Message Date
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
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
8 changed files with 824 additions and 2 deletions

58
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
@@ -47,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';
@@ -708,7 +711,10 @@ async function init() {
createWorkshopTerminal();
createAshStorm();
SpatialMemory.init(scene);
MemoryBirth.init(scene);
MemoryBirth.wrapSpatialMemory(SpatialMemory);
SpatialMemory.setCamera(camera);
MemoryInspect.init({ onNavigate: _navigateToMemory });
updateLoad(90);
loadSession();
@@ -1900,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) {
@@ -1918,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;
@@ -2149,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;
@@ -2868,6 +2923,7 @@ function gameLoop() {
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
MemoryBirth.update(delta);
animateMemoryOrbs(delta);
}

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,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,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

@@ -72,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()

View File

@@ -23,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
@@ -96,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)

View File

@@ -197,6 +197,71 @@ def test_archive_stats_orphan_count():
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"

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;
}