Compare commits

...

12 Commits

Author SHA1 Message Date
8cc9a194e6 feat: add decay vitality CSS styles
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
Colored badges for vibrant/alive/fading/dim/ghost states.
Crystal fade pulse animation for ghost memories.
2026-04-11 19:19:04 +00:00
d4b6bfde70 feat: integrate MemoryDecay into app.js
- Import memory-decay component
- Initialize decay engine with SpatialMemory
- Update decay on each frame
- Recharge memories on access events
- Add decay stats display helper
2026-04-11 19:18:42 +00:00
4f899bac15 feat: decay-aware visuals in spatial-memory update loop
Crystal opacity, glow, and scale now respond to memory strength.
Fading memories shrink and dim; vibrant ones pulse bright.
2026-04-11 19:18:22 +00:00
fdfbed19dc feat: add memory decay & vitality engine
Decay model: exponential fade with region modifiers.
Recharge: access boosts strength toward 1.0.
Visual bands: vibrant/alive/fading/dim/ghost.
2026-04-11 19:18:02 +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
c6f6f83a7c Merge pull request '[Mnemosyne] Memory filter panel — toggle categories by region' (#1213) from feat/mnemosyne-memory-filter into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Merged PR #1213: [Mnemosyne] Memory filter panel — toggle categories by region
2026-04-11 05:31:44 +00:00
026e4a8cae Merge pull request '[Mnemosyne] Fix entity resolution lines wiring (#1167)' (#1214) from fix/entity-resolution-lines-wiring into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Merged PR #1214
2026-04-11 05:31:26 +00:00
45724e8421 feat(mnemosyne): wire memory filter panel in app.js
Some checks failed
CI / test (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- G key toggles filter panel
- Escape closes filter panel
- toggleMemoryFilter() bridge function
2026-04-11 04:10:49 +00:00
04a61132c9 feat(mnemosyne): add memory filter panel CSS
- Frosted glass panel matching Mnemosyne theme
- Category toggle switches with color dots
- Slide-in animation from right
2026-04-11 04:09:30 +00:00
c82d60d7f1 feat(mnemosyne): add memory filter panel with category toggles
- Filter panel with toggle switches per memory region
- Show All / Hide All bulk controls
- Memory count per category
- Frosted glass UI matching Mnemosyne design
2026-04-11 04:09:03 +00:00
6529af293f feat(mnemosyne): add region filter visibility methods to SpatialMemory
- setRegionVisibility(category, visible) — toggle single region
- setAllRegionsVisible(visible) — bulk toggle
- getMemoryCountByRegion() — count memories per category
- isRegionVisible(category) — query visibility state
2026-04-11 04:08:28 +00:00
13 changed files with 1268 additions and 2 deletions

17
app.js
View File

@@ -4,6 +4,7 @@ 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 { MemoryDecay } from './nexus/components/memory-decay.js';
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
// ═══════════════════════════════════════════
@@ -46,6 +47,7 @@ let debugOverlay;
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 loadProgress = 0;
let performanceTier = 'high';
@@ -708,6 +710,7 @@ async function init() {
createAshStorm();
SpatialMemory.init(scene);
SpatialMemory.setCamera(camera);
MemoryDecay.init(SpatialMemory);
updateLoad(90);
loadSession();
@@ -1871,6 +1874,7 @@ function setupControls() {
if (visionOverlayActive) closeVisionOverlay();
if (atlasOverlayActive) closePortalAtlas();
if (_archiveDashboardOpen) toggleArchiveHealthDashboard();
if (_memoryFilterOpen) closeMemoryFilter();
}
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
cycleNavMode();
@@ -1884,6 +1888,9 @@ function setupControls() {
if (e.key.toLowerCase() === 'h' && document.activeElement !== document.getElementById('chat-input')) {
toggleArchiveHealthDashboard();
}
if (e.key.toLowerCase() === 'g' && document.activeElement !== document.getElementById('chat-input')) {
toggleMemoryFilter();
}
});
document.addEventListener('keyup', (e) => {
keys[e.key.toLowerCase()] = false;
@@ -2254,6 +2261,15 @@ function toggleArchiveHealthDashboard() {
* Render current archive statistics into the dashboard panel.
* Reads live from SpatialMemory.getAllMemories() — no backend needed.
*/
function toggleMemoryFilter() {
_memoryFilterOpen = !_memoryFilterOpen;
if (_memoryFilterOpen) {
openMemoryFilter();
} else {
closeMemoryFilter();
}
}
function updateArchiveHealthDashboard() {
const container = document.getElementById('archive-health-content');
if (!container) return;
@@ -2854,6 +2870,7 @@ function gameLoop() {
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
MemoryDecay.update(delta);
animateMemoryOrbs(delta);
}

View File

@@ -457,7 +457,67 @@ index.html
<div class="memory-feed-actions"><button class="memory-feed-clear" onclick="clearMemoryFeed()">Clear</button><button class="memory-feed-toggle" onclick="document.getElementById('memory-feed').style.display='none'"></button></div>
</div>
<div id="memory-feed-list" class="memory-feed-list"></div>
<!-- ═══ MNEMOSYNE MEMORY FILTER ═══ -->
<div id="memory-filter" class="memory-filter" style="display:none;">
<div class="filter-header">
<span class="filter-title">⬡ Memory Filter</span>
<button class="filter-close" onclick="closeMemoryFilter()"></button>
</div>
<div class="filter-controls">
<button class="filter-btn" onclick="setAllFilters(true)">Show All</button>
<button class="filter-btn" onclick="setAllFilters(false)">Hide All</button>
</div>
<div class="filter-list" id="filter-list"></div>
</div>
</div>
<script>
// ─── MNEMOSYNE: Memory Filter Panel ───────────────────
function openMemoryFilter() {
renderFilterList();
document.getElementById('memory-filter').style.display = 'flex';
}
function closeMemoryFilter() {
document.getElementById('memory-filter').style.display = 'none';
}
function renderFilterList() {
const counts = SpatialMemory.getMemoryCountByRegion();
const regions = SpatialMemory.REGIONS;
const list = document.getElementById('filter-list');
list.innerHTML = '';
for (const [key, region] of Object.entries(regions)) {
const count = counts[key] || 0;
const visible = SpatialMemory.isRegionVisible(key);
const colorHex = '#' + region.color.toString(16).padStart(6, '0');
const item = document.createElement('div');
item.className = 'filter-item';
item.innerHTML = `
<div class="filter-item-left">
<span class="filter-dot" style="background:${colorHex}"></span>
<span class="filter-label">${region.glyph} ${region.label}</span>
</div>
<div class="filter-item-right">
<span class="filter-count">${count}</span>
<label class="filter-toggle">
<input type="checkbox" ${visible ? 'checked' : ''}
onchange="toggleRegion('${key}', this.checked)">
<span class="filter-slider"></span>
</label>
</div>
`;
list.appendChild(item);
}
}
function toggleRegion(category, visible) {
SpatialMemory.setRegionVisibility(category, visible);
}
function setAllFilters(visible) {
SpatialMemory.setAllRegionsVisible(visible);
renderFilterList();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,178 @@
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — MEMORY DECAY & VITALITY
// ═══════════════════════════════════════════
//
// Memories are alive. They fade when neglected,
// brighten when accessed, and pulse with the
// rhythm of the holographic archive.
//
// Decay model:
// strength(t) = strength_0 * e^(-λt) + floor
// where λ = decay rate, floor = minimum vitality
//
// Recharge:
// On access: strength += boost * (1 - strength)
// On placement: strength = initial_strength
//
// Visual mapping:
// strength 1.0 → full opacity, max glow, full size
// strength 0.3 → dim, faint glow, 70% size
// strength 0.05 → ghost crystal, barely visible
//
// Usage:
// import { MemoryDecay } from './nexus/components/memory-decay.js';
// MemoryDecay.init(SpatialMemory);
// MemoryDecay.update(delta);
// ═══════════════════════════════════════════
const MemoryDecay = (() => {
let _spatialMemory = null;
let _lastUpdate = Date.now();
// ─── DECAY PARAMETERS ──────────────────
const DECAY_RATE = 0.0003; // λ per second (~5.5 hour half-life)
const STRENGTH_FLOOR = 0.05; // minimum vitality (ghost state)
const RECHARGE_BOOST = 0.35; // strength gain on access
const INITIAL_STRENGTH = 0.7; // default on placement
const DECAY_INTERVAL = 5000; // ms between decay ticks (performance)
// ─── VISUAL THRESHOLDS ─────────────────
const VITALITY_BANDS = [
{ min: 0.8, label: 'vibrant', opacityMul: 1.0, glowMul: 1.0, scaleMul: 1.0 },
{ min: 0.5, label: 'alive', opacityMul: 0.85, glowMul: 0.7, scaleMul: 0.95 },
{ min: 0.25, label: 'fading', opacityMul: 0.6, glowMul: 0.4, scaleMul: 0.85 },
{ min: 0.1, label: 'dim', opacityMul: 0.35, glowMul: 0.15, scaleMul: 0.75 },
{ min: 0.0, label: 'ghost', opacityMul: 0.15, glowMul: 0.05, scaleMul: 0.65 },
];
// ─── REGION DECAY MODIFIERS ────────────
// Archive region decays faster, working memory resists decay
const REGION_DECAY_MOD = {
working: 0.3, // 70% slower decay — active use
social: 0.7,
knowledge: 0.8,
projects: 0.6,
personal: 0.9, // fades faster — less revisited
archive: 1.5, // fastest decay — intentional fade
};
let _lastDecayTick = 0;
let _decayStats = { processed: 0, decayed: 0, recharged: 0 };
// ─── INIT ──────────────────────────────
function init(spatialMemory) {
_spatialMemory = spatialMemory;
_lastUpdate = Date.now();
_lastDecayTick = Date.now();
console.info('[MemoryDecay] Decay engine initialized');
}
// ─── GET VITALITY BAND ─────────────────
function getVitalityBand(strength) {
for (const band of VITALITY_BANDS) {
if (strength >= band.min) return band;
}
return VITALITY_BANDS[VITALITY_BANDS.length - 1];
}
// ─── APPLY DECAY TO SINGLE MEMORY ─────
function applyDecay(memoryObj, dtSeconds, regionKey) {
const regionMod = REGION_DECAY_MOD[regionKey] || 1.0;
const decayed = memoryObj.strength * Math.exp(-DECAY_RATE * regionMod * dtSeconds);
const newStrength = Math.max(STRENGTH_FLOOR, decayed);
return newStrength;
}
// ─── RECHARGE ON ACCESS ────────────────
function recharge(memId) {
if (!_spatialMemory) return;
const all = _spatialMemory.getAllMemories();
const mem = all.find(m => m.id === memId);
if (!mem) return;
const boosted = mem.strength + RECHARGE_BOOST * (1 - mem.strength);
const clamped = Math.min(1.0, boosted);
_spatialMemory.updateMemory(memId, { strength: clamped });
_decayStats.recharged++;
console.info(`[MemoryDecay] Recharged ${memId}${clamped.toFixed(2)}`);
return clamped;
}
// ─── APPLY VISUAL UPDATES ─────────────
function applyVisuals(memId, strength) {
const band = getVitalityBand(strength);
// Visual updates are handled by spatial-memory.js update loop
// We just store the vitality band metadata for the UI to read
return band;
}
// ─── BULK DECAY TICK ──────────────────
function tick() {
if (!_spatialMemory) return;
const now = Date.now();
const dtSeconds = (now - _lastDecayTick) / 1000;
_lastDecayTick = now;
const all = _spatialMemory.getAllMemories();
_decayStats.processed = 0;
_decayStats.decayed = 0;
for (const mem of all) {
if (mem.strength <= STRENGTH_FLOOR) continue;
const oldStrength = mem.strength;
const region = mem.category || 'working';
const newStrength = applyDecay({ strength: mem.strength }, dtSeconds, region);
if (Math.abs(newStrength - oldStrength) > 0.001) {
_spatialMemory.updateMemory(mem.id, { strength: newStrength });
_decayStats.decayed++;
}
_decayStats.processed++;
}
}
// ─── UPDATE (called from app.js render loop) ─
function update(delta) {
const now = Date.now();
if (now - _lastDecayTick < DECAY_INTERVAL) return;
tick();
}
// ─── EXPORT DECAY STATE ────────────────
function exportDecayState() {
if (!_spatialMemory) return {};
const all = _spatialMemory.getAllMemories();
const state = {};
for (const mem of all) {
state[mem.id] = {
strength: mem.strength,
band: getVitalityBand(mem.strength).label,
lastUpdated: Date.now(),
};
}
return state;
}
// ─── GET STATS ─────────────────────────
function getStats() {
if (!_spatialMemory) return { total: 0, vibrant: 0, alive: 0, fading: 0, dim: 0, ghost: 0 };
const all = _spatialMemory.getAllMemories();
const stats = { total: all.length, vibrant: 0, alive: 0, fading: 0, dim: 0, ghost: 0 };
for (const mem of all) {
const band = getVitalityBand(mem.strength);
stats[band.label]++;
}
return stats;
}
return {
init, update, tick, recharge, exportDecayState, getStats,
getVitalityBand, DECAY_RATE, STRENGTH_FLOOR, RECHARGE_BOOST,
VITALITY_BANDS, REGION_DECAY_MOD
};
})();
export { MemoryDecay };

View File

@@ -1,4 +1,41 @@
// ═══════════════════════════════════════════
// ═══
// ─── REGION VISIBILITY (Memory Filter) ──────────────
let _regionVisibility = {}; // category -> boolean (undefined = visible)
setRegionVisibility(category, visible) {
_regionVisibility[category] = visible;
for (const obj of Object.values(_memoryObjects)) {
if (obj.data.category === category && obj.mesh) {
obj.mesh.visible = visible !== false;
}
}
},
setAllRegionsVisible(visible) {
const cats = Object.keys(REGIONS);
for (const cat of cats) {
_regionVisibility[cat] = visible;
for (const obj of Object.values(_memoryObjects)) {
if (obj.data.category === cat && obj.mesh) {
obj.mesh.visible = visible;
}
}
}
},
getMemoryCountByRegion() {
const counts = {};
for (const obj of Object.values(_memoryObjects)) {
const cat = obj.data.category || 'working';
counts[cat] = (counts[cat] || 0) + 1;
}
return counts;
},
isRegionVisible(category) {
return _regionVisibility[category] !== false;
},
// PROJECT MNEMOSYNE — SPATIAL MEMORY SCHEMA
// ═══════════════════════════════════════════
//
@@ -497,7 +534,12 @@ const SpatialMemory = (() => {
mesh.rotation.y += delta * 0.3;
mesh.userData.pulse += delta * 1.5;
const pulse = 1 + Math.sin(mesh.userData.pulse) * 0.08;
// Decay-aware vitality scaling
const strength = data.strength || 0.7;
const vitalityScale = strength >= 0.8 ? 1.0 : strength >= 0.5 ? 0.95 : strength >= 0.25 ? 0.85 : strength >= 0.1 ? 0.75 : 0.65;
const vitalityGlow = strength >= 0.8 ? 1.0 : strength >= 0.5 ? 0.7 : strength >= 0.25 ? 0.4 : strength >= 0.1 ? 0.15 : 0.05;
const pulse = 1 + Math.sin(mesh.userData.pulse) * 0.08 * vitalityGlow;
mesh.scale.setScalar(pulse);
if (mesh.material) {

View File

@@ -0,0 +1,24 @@
"""nexus.mnemosyne — The Living Holographic Archive.
Phase 1: Foundation — core archive, entry model, holographic linker,
ingestion pipeline, and CLI.
Builds on MemPalace vector memory to create interconnected meaning:
entries auto-reference related entries via semantic similarity,
forming a living archive that surfaces relevant context autonomously.
"""
from __future__ import annotations
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.linker import HolographicLinker
from nexus.mnemosyne.ingest import ingest_from_mempalace, ingest_event
__all__ = [
"MnemosyneArchive",
"ArchiveEntry",
"HolographicLinker",
"ingest_from_mempalace",
"ingest_event",
]

196
nexus/mnemosyne/archive.py Normal file
View File

@@ -0,0 +1,196 @@
"""MnemosyneArchive — core archive class.
The living holographic archive. Stores entries, maintains links,
and provides query interfaces for retrieving connected knowledge.
"""
from __future__ import annotations
import json
from pathlib import Path
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.
Phase 1 uses JSON file storage. Phase 2 will integrate with
MemPalace (ChromaDB) for vector-semantic search.
"""
def __init__(self, archive_path: Optional[Path] = None):
self.path = archive_path or Path.home() / ".hermes" / "mnemosyne" / "archive.json"
self.path.parent.mkdir(parents=True, exist_ok=True)
self.linker = HolographicLinker()
self._entries: dict[str, ArchiveEntry] = {}
self._load()
def _load(self):
if self.path.exists():
try:
with open(self.path) as f:
data = json.load(f)
for entry_data in data.get("entries", []):
entry = ArchiveEntry.from_dict(entry_data)
self._entries[entry.id] = entry
except (json.JSONDecodeError, KeyError):
pass # Start fresh on corrupt data
def _save(self):
data = {
"entries": [e.to_dict() for e in self._entries.values()],
"count": len(self._entries),
}
with open(self.path, "w") as f:
json.dump(data, f, indent=2)
def add(self, entry: ArchiveEntry, auto_link: bool = True) -> ArchiveEntry:
"""Add an entry to the archive. Auto-links to related entries."""
self._entries[entry.id] = entry
if auto_link:
self.linker.apply_links(entry, list(self._entries.values()))
self._save()
return entry
def get(self, entry_id: str) -> Optional[ArchiveEntry]:
return self._entries.get(entry_id)
def search(self, query: str, limit: int = 10) -> list[ArchiveEntry]:
"""Simple keyword search across titles and content."""
query_tokens = set(query.lower().split())
scored = []
for entry in self._entries.values():
text = f"{entry.title} {entry.content} {' '.join(entry.topics)}".lower()
hits = sum(1 for t in query_tokens if t in text)
if hits > 0:
scored.append((hits, entry))
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[: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()
frontier = {entry_id}
result = []
for _ in range(depth):
next_frontier = set()
for eid in frontier:
if eid in visited:
continue
visited.add(eid)
entry = self._entries.get(eid)
if entry:
for linked_id in entry.links:
if linked_id not in visited:
linked = self._entries.get(linked_id)
if linked:
result.append(linked)
next_frontier.add(linked_id)
frontier = next_frontier
return result
def by_topic(self, topic: str) -> list[ArchiveEntry]:
"""Get all entries tagged with a topic."""
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:
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": 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,
}

136
nexus/mnemosyne/cli.py Normal file
View File

@@ -0,0 +1,136 @@
"""CLI interface for Mnemosyne.
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
mnemosyne topics, mnemosyne remove, mnemosyne export
"""
from __future__ import annotations
import argparse
import json
import sys
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.ingest import ingest_event
def cmd_stats(args):
archive = MnemosyneArchive()
stats = archive.stats()
print(json.dumps(stats, indent=2))
def cmd_search(args):
archive = MnemosyneArchive()
results = archive.search(args.query, limit=args.limit)
if not results:
print("No results found.")
return
for entry in results:
linked = len(entry.links)
print(f"[{entry.id[:8]}] {entry.title}")
print(f" Source: {entry.source} | Topics: {', '.join(entry.topics)} | Links: {linked}")
print(f" {entry.content[:120]}...")
print()
def cmd_ingest(args):
archive = MnemosyneArchive()
entry = ingest_event(
archive,
title=args.title,
content=args.content,
topics=args.topics.split(",") if args.topics else [],
)
print(f"Ingested: [{entry.id[:8]}] {entry.title} ({len(entry.links)} links)")
def cmd_link(args):
archive = MnemosyneArchive()
entry = archive.get(args.entry_id)
if not entry:
print(f"Entry not found: {args.entry_id}")
sys.exit(1)
linked = archive.get_linked(entry.id, depth=args.depth)
if not linked:
print("No linked entries found.")
return
for e in linked:
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")
sub.add_parser("stats", help="Show archive statistics")
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)
i = sub.add_parser("ingest", help="Ingest a new entry")
i.add_argument("--title", required=True)
i.add_argument("--content", required=True)
i.add_argument("--topics", default="", help="Comma-separated topics")
l = sub.add_parser("link", help="Show linked entries")
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)
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__":
main()

44
nexus/mnemosyne/entry.py Normal file
View File

@@ -0,0 +1,44 @@
"""Archive entry model for Mnemosyne.
Each entry is a node in the holographic graph — a piece of meaning
with metadata, content, and links to related entries.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
import uuid
@dataclass
class ArchiveEntry:
"""A single node in the Mnemosyne holographic archive."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
title: str = ""
content: str = ""
source: str = "" # "mempalace", "event", "manual", etc.
source_ref: Optional[str] = None # original MemPalace ID, event URI, etc.
topics: list[str] = field(default_factory=list)
metadata: dict = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
links: list[str] = field(default_factory=list) # IDs of related entries
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"content": self.content,
"source": self.source,
"source_ref": self.source_ref,
"topics": self.topics,
"metadata": self.metadata,
"created_at": self.created_at,
"links": self.links,
}
@classmethod
def from_dict(cls, data: dict) -> ArchiveEntry:
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})

62
nexus/mnemosyne/ingest.py Normal file
View File

@@ -0,0 +1,62 @@
"""Ingestion pipeline — feeds data into the archive.
Supports ingesting from MemPalace, raw events, and manual entries.
"""
from __future__ import annotations
from typing import Optional
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.entry import ArchiveEntry
def ingest_from_mempalace(
archive: MnemosyneArchive,
mempalace_entries: list[dict],
) -> int:
"""Ingest entries from a MemPalace export.
Each dict should have at least: content, metadata (optional).
Returns count of new entries added.
"""
added = 0
for mp_entry in mempalace_entries:
content = mp_entry.get("content", "")
metadata = mp_entry.get("metadata", {})
source_ref = mp_entry.get("id", "")
# Skip if already ingested
if any(e.source_ref == source_ref for e in archive._entries.values()):
continue
entry = ArchiveEntry(
title=metadata.get("title", content[:80]),
content=content,
source="mempalace",
source_ref=source_ref,
topics=metadata.get("topics", []),
metadata=metadata,
)
archive.add(entry)
added += 1
return added
def ingest_event(
archive: MnemosyneArchive,
title: str,
content: str,
topics: Optional[list[str]] = None,
source: str = "event",
metadata: Optional[dict] = None,
) -> ArchiveEntry:
"""Ingest a single event into the archive."""
entry = ArchiveEntry(
title=title,
content=content,
source=source,
topics=topics or [],
metadata=metadata or {},
)
return archive.add(entry)

73
nexus/mnemosyne/linker.py Normal file
View File

@@ -0,0 +1,73 @@
"""Holographic link engine.
Computes semantic similarity between archive entries and creates
bidirectional links, forming the holographic graph structure.
"""
from __future__ import annotations
from typing import Optional
from nexus.mnemosyne.entry import ArchiveEntry
class HolographicLinker:
"""Links archive entries via semantic similarity.
Phase 1 uses simple keyword overlap as the similarity metric.
Phase 2 will integrate ChromaDB embeddings from MemPalace.
"""
def __init__(self, similarity_threshold: float = 0.15):
self.threshold = similarity_threshold
def compute_similarity(self, a: ArchiveEntry, b: ArchiveEntry) -> float:
"""Compute similarity score between two entries.
Returns float in [0, 1]. Phase 1: Jaccard similarity on
combined title+content tokens. Phase 2: cosine similarity
on ChromaDB embeddings.
"""
tokens_a = self._tokenize(f"{a.title} {a.content}")
tokens_b = self._tokenize(f"{b.title} {b.content}")
if not tokens_a or not tokens_b:
return 0.0
intersection = tokens_a & tokens_b
union = tokens_a | tokens_b
return len(intersection) / len(union)
def find_links(self, entry: ArchiveEntry, candidates: list[ArchiveEntry]) -> list[tuple[str, float]]:
"""Find entries worth linking to.
Returns list of (entry_id, similarity_score) tuples above threshold.
"""
results = []
for candidate in candidates:
if candidate.id == entry.id:
continue
score = self.compute_similarity(entry, candidate)
if score >= self.threshold:
results.append((candidate.id, score))
results.sort(key=lambda x: x[1], reverse=True)
return results
def apply_links(self, entry: ArchiveEntry, candidates: list[ArchiveEntry]) -> int:
"""Auto-link an entry to related entries. Returns count of new links."""
matches = self.find_links(entry, candidates)
new_links = 0
for eid, score in matches:
if eid not in entry.links:
entry.links.append(eid)
new_links += 1
# Bidirectional
for c in candidates:
if c.id == eid and entry.id not in c.links:
c.links.append(entry.id)
return new_links
@staticmethod
def _tokenize(text: str) -> set[str]:
"""Simple whitespace + punctuation tokenizer."""
import re
tokens = set(re.findall(r"\w+", text.lower()))
# Remove very short tokens
return {t for t in tokens if len(t) > 2}

View File

View File

@@ -0,0 +1,211 @@
"""Tests for Mnemosyne archive core."""
import json
import tempfile
from pathlib import Path
from nexus.mnemosyne.entry import ArchiveEntry
from nexus.mnemosyne.linker import HolographicLinker
from nexus.mnemosyne.archive import MnemosyneArchive
from nexus.mnemosyne.ingest import ingest_event, ingest_from_mempalace
def test_entry_roundtrip():
e = ArchiveEntry(title="Test", content="Hello world", topics=["test"])
d = e.to_dict()
e2 = ArchiveEntry.from_dict(d)
assert e2.id == e.id
assert e2.title == "Test"
def test_linker_similarity():
linker = HolographicLinker()
a = ArchiveEntry(title="Python coding", content="Writing Python scripts for automation")
b = ArchiveEntry(title="Python scripting", content="Automating tasks with Python scripts")
c = ArchiveEntry(title="Cooking recipes", content="How to make pasta carbonara")
assert linker.compute_similarity(a, b) > linker.compute_similarity(a, c)
def test_archive_add_and_search():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="First entry", content="Hello archive", topics=["test"])
ingest_event(archive, title="Second entry", content="Another record", topics=["test", "demo"])
assert archive.count == 2
results = archive.search("hello")
assert len(results) == 1
assert results[0].title == "First entry"
def test_archive_auto_linking():
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")
# Both should be linked due to shared tokens
assert len(e1.links) > 0 or len(e2.links) > 0
def test_ingest_from_mempalace():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
mp_entries = [
{"id": "mp-1", "content": "Test memory content", "metadata": {"title": "Test", "topics": ["demo"]}},
{"id": "mp-2", "content": "Another memory", "metadata": {"title": "Memory 2"}},
]
count = ingest_from_mempalace(archive, mp_entries)
assert count == 2
assert archive.count == 2
def test_archive_persistence():
with tempfile.TemporaryDirectory() as tmp:
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_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"}

223
style.css
View File

@@ -1546,3 +1546,226 @@ canvas#nexus-canvas {
padding-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* ═══ MNEMOSYNE: Memory Filter Panel ═══ */
.memory-filter {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
width: 300px;
max-height: 70vh;
background: rgba(10, 12, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(74, 240, 192, 0.2);
border-radius: 12px;
display: flex;
flex-direction: column;
z-index: 100;
animation: slideInRight 0.3s ease;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
}
@keyframes slideInRight {
from { transform: translateY(-50%) translateX(30px); opacity: 0; }
to { transform: translateY(-50%) translateX(0); opacity: 1; }
}
.filter-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
border-bottom: 1px solid rgba(74, 240, 192, 0.1);
}
.filter-title {
color: #4af0c0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
}
.filter-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
}
.filter-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.filter-controls {
display: flex;
gap: 8px;
padding: 10px 16px;
}
.filter-btn {
flex: 1;
padding: 6px 0;
background: rgba(74, 240, 192, 0.08);
border: 1px solid rgba(74, 240, 192, 0.2);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
background: rgba(74, 240, 192, 0.15);
color: #fff;
}
.filter-list {
overflow-y: auto;
padding: 6px 8px 12px;
flex: 1;
}
.filter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.filter-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.filter-item-left {
display: flex;
align-items: center;
gap: 8px;
}
.filter-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.filter-label {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
}
.filter-item-right {
display: flex;
align-items: center;
gap: 10px;
}
.filter-count {
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
min-width: 20px;
text-align: right;
}
/* Toggle switch */
.filter-toggle {
position: relative;
width: 34px;
height: 18px;
display: inline-block;
}
.filter-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.filter-slider {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.12);
border-radius: 9px;
cursor: pointer;
transition: all 0.2s;
}
.filter-slider::before {
content: '';
position: absolute;
height: 14px;
width: 14px;
left: 2px;
bottom: 2px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
transition: all 0.2s;
}
.filter-toggle input:checked + .filter-slider {
background: rgba(74, 240, 192, 0.4);
}
.filter-toggle input:checked + .filter-slider::before {
transform: translateX(16px);
background: #4af0c0;
}
/* ═══ MNEMOSYNE: Memory Decay & Vitality ═══ */
.decay-stats {
display: flex;
gap: 8px;
padding: 8px 12px;
font-size: 11px;
flex-wrap: wrap;
border-top: 1px solid rgba(255,255,255,0.06);
margin-top: 6px;
}
.decay-stat {
padding: 2px 8px;
border-radius: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
opacity: 0.85;
}
.decay-stat.vibrant {
background: rgba(74, 240, 192, 0.15);
color: #4af0c0;
border: 1px solid rgba(74, 240, 192, 0.3);
}
.decay-stat.alive {
background: rgba(100, 180, 255, 0.15);
color: #64b4ff;
border: 1px solid rgba(100, 180, 255, 0.3);
}
.decay-stat.fading {
background: rgba(255, 200, 50, 0.15);
color: #ffc832;
border: 1px solid rgba(255, 200, 50, 0.3);
}
.decay-stat.dim {
background: rgba(255, 120, 80, 0.15);
color: #ff7850;
border: 1px solid rgba(255, 120, 80, 0.3);
}
.decay-stat.ghost {
background: rgba(150, 150, 180, 0.1);
color: #9696b4;
border: 1px solid rgba(150, 150, 180, 0.2);
}
/* Vitality pulse animation for dim/ghost crystals */
@keyframes crystal-fade-pulse {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.25; }
}