Compare commits
11 Commits
fix/entity
...
feat/mnemo
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e170074ce | |||
| 27c211268f | |||
| ddf4f8cd3d | |||
| c0bb6bf918 | |||
| b9f1602067 | |||
| c6f6f83a7c | |||
| 026e4a8cae | |||
| 45724e8421 | |||
| 04a61132c9 | |||
| c82d60d7f1 | |||
| 6529af293f |
14
app.js
14
app.js
@@ -46,6 +46,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';
|
||||
|
||||
@@ -1871,6 +1872,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 +1886,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 +2259,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;
|
||||
|
||||
60
index.html
60
index.html
@@ -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>
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════
|
||||
//
|
||||
|
||||
24
nexus/mnemosyne/__init__.py
Normal file
24
nexus/mnemosyne/__init__.py
Normal 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",
|
||||
]
|
||||
195
nexus/mnemosyne/archive.py
Normal file
195
nexus/mnemosyne/archive.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""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 datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.linker import HolographicLinker
|
||||
|
||||
|
||||
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 remove(self, entry_id: str) -> bool:
|
||||
"""Remove an entry and clean all bidirectional links to it.
|
||||
|
||||
Returns True if entry was found and removed, False otherwise.
|
||||
"""
|
||||
if entry_id not in self._entries:
|
||||
return False
|
||||
# Remove bidirectional links
|
||||
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 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 export(
|
||||
self,
|
||||
topics: Optional[list[str]] = None,
|
||||
source: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Export archive data, optionally filtered.
|
||||
|
||||
Args:
|
||||
topics: Only include entries with at least one matching topic.
|
||||
source: Only include entries from this source.
|
||||
since: ISO date string — only include entries created after this.
|
||||
|
||||
Returns:
|
||||
Dict with entries, count, and export metadata.
|
||||
"""
|
||||
entries = list(self._entries.values())
|
||||
|
||||
if topics:
|
||||
topics_lower = {t.lower() for t in topics}
|
||||
entries = [e for e in entries if topics_lower & {t.lower() for t in e.topics}]
|
||||
|
||||
if source:
|
||||
entries = [e for e in entries if e.source == source]
|
||||
|
||||
if since:
|
||||
entries = [e for e in entries if e.created_at >= since]
|
||||
|
||||
return {
|
||||
"entries": [e.to_dict() for e in entries],
|
||||
"count": len(entries),
|
||||
"filters": {
|
||||
"topics": topics,
|
||||
"source": source,
|
||||
"since": since,
|
||||
},
|
||||
}
|
||||
|
||||
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 list_topics(self) -> dict[str, int]:
|
||||
"""Return dict mapping each topic to its entry count."""
|
||||
topic_counts: dict[str, int] = {}
|
||||
for entry in self._entries.values():
|
||||
for topic in entry.topics:
|
||||
topic_counts[topic] = topic_counts.get(topic, 0) + 1
|
||||
return dict(sorted(topic_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()
|
||||
for e in entries:
|
||||
topics.update(e.topics)
|
||||
|
||||
# Orphans: entries with no links
|
||||
orphans = sum(1 for e in entries if not e.links)
|
||||
|
||||
# Link density: ratio of actual links to possible links
|
||||
n = len(entries)
|
||||
max_links = n * (n - 1) if n > 1 else 0
|
||||
link_density = total_links / max_links if max_links > 0 else 0.0
|
||||
|
||||
# Entry age range
|
||||
timestamps = [e.created_at for e in entries if e.created_at]
|
||||
oldest = min(timestamps) if timestamps else None
|
||||
newest = max(timestamps) if timestamps else None
|
||||
|
||||
return {
|
||||
"entries": len(entries),
|
||||
"total_links": total_links,
|
||||
"orphans": orphans,
|
||||
"link_density": round(link_density, 4),
|
||||
"unique_topics": len(topics),
|
||||
"topics": sorted(topics),
|
||||
"oldest_entry": oldest,
|
||||
"newest_entry": newest,
|
||||
}
|
||||
140
nexus/mnemosyne/cli.py
Normal file
140
nexus/mnemosyne/cli.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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()
|
||||
topics = archive.list_topics()
|
||||
if not topics:
|
||||
print("No topics found.")
|
||||
return
|
||||
for topic, count in topics.items():
|
||||
print(f" {topic}: {count} {'entry' if count == 1 else 'entries'}")
|
||||
|
||||
|
||||
def cmd_remove(args):
|
||||
archive = MnemosyneArchive()
|
||||
entry = archive.get(args.entry_id)
|
||||
if not entry:
|
||||
print(f"Entry not found: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
title = entry.title
|
||||
if archive.remove(args.entry_id):
|
||||
print(f"Removed: [{args.entry_id[:8]}] {title}")
|
||||
else:
|
||||
print(f"Failed to remove: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
archive = MnemosyneArchive()
|
||||
topics = args.topics.split(",") if args.topics else None
|
||||
data = archive.export(topics=topics, source=args.source, since=args.since)
|
||||
output = json.dumps(data, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output)
|
||||
print(f"Exported {data['count']} entries to {args.output}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
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")
|
||||
r.add_argument("entry_id", help="Entry ID (or prefix)")
|
||||
|
||||
e = sub.add_parser("export", help="Export archive data")
|
||||
e.add_argument("--topics", default=None, help="Filter by comma-separated topics")
|
||||
e.add_argument("--source", default=None, help="Filter by source")
|
||||
e.add_argument("--since", default=None, help="Filter by date (ISO format)")
|
||||
e.add_argument("-o", "--output", default=None, help="Output file path")
|
||||
|
||||
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, "topics": cmd_topics, "remove": cmd_remove,
|
||||
"export": cmd_export}[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
44
nexus/mnemosyne/entry.py
Normal file
44
nexus/mnemosyne/entry.py
Normal 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
62
nexus/mnemosyne/ingest.py
Normal 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
73
nexus/mnemosyne/linker.py
Normal 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}
|
||||
0
nexus/mnemosyne/tests/__init__.py
Normal file
0
nexus/mnemosyne/tests/__init__.py
Normal file
145
nexus/mnemosyne/tests/test_archive.py
Normal file
145
nexus/mnemosyne/tests/test_archive.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""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():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Entry one", content="First content", topics=["test"])
|
||||
e2 = ingest_event(archive, title="Entry two", content="Second content", topics=["test"])
|
||||
assert archive.count == 2
|
||||
|
||||
# Remove e1 — should also clean links from e2
|
||||
assert archive.remove(e1.id) is True
|
||||
assert archive.count == 1
|
||||
assert e1.id not in e2.links
|
||||
assert archive.get(e1.id) is None
|
||||
|
||||
# Remove non-existent
|
||||
assert archive.remove("nonexistent") is False
|
||||
|
||||
|
||||
def test_archive_export():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Python entry", content="About Python", topics=["coding", "python"])
|
||||
ingest_event(archive, title="Cooking entry", content="About food", topics=["cooking"])
|
||||
ingest_event(archive, title="Another python", content="More Python", topics=["python"])
|
||||
|
||||
# Export all
|
||||
data = archive.export()
|
||||
assert data["count"] == 3
|
||||
|
||||
# Export by topic
|
||||
data = archive.export(topics=["python"])
|
||||
assert data["count"] == 2
|
||||
|
||||
data = archive.export(topics=["cooking"])
|
||||
assert data["count"] == 1
|
||||
|
||||
# Export by non-existent topic
|
||||
data = archive.export(topics=["nonexistent"])
|
||||
assert data["count"] == 0
|
||||
|
||||
|
||||
def test_archive_list_topics():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="E1", content="c", topics=["alpha", "beta"])
|
||||
ingest_event(archive, title="E2", content="c", topics=["beta", "gamma"])
|
||||
ingest_event(archive, title="E3", content="c", topics=["beta"])
|
||||
|
||||
topics = archive.list_topics()
|
||||
assert topics["beta"] == 3
|
||||
assert topics["alpha"] == 1
|
||||
assert topics["gamma"] == 1
|
||||
|
||||
|
||||
def test_archive_richer_stats():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Linked entry", content="Shared content here", topics=["test"])
|
||||
e2 = ingest_event(archive, title="Linked content", content="Shared entry here", topics=["test"])
|
||||
ingest_event(archive, title="Orphan entry", content="Nothing in common cooking", topics=["food"])
|
||||
|
||||
stats = archive.stats()
|
||||
assert stats["entries"] == 3
|
||||
assert stats["orphans"] >= 1 # At least the cooking entry should be orphan
|
||||
assert 0 <= stats["link_density"] <= 1
|
||||
assert stats["oldest_entry"] is not None
|
||||
assert stats["newest_entry"] is not None
|
||||
167
style.css
167
style.css
@@ -1546,3 +1546,170 @@ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user