Compare commits
5 Commits
feat/mnemo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a378aa576e | |||
|
|
5446d3dc59 | ||
|
|
58c75a29bd | ||
| b3939179b9 | |||
| a14bf80631 |
@@ -477,6 +477,10 @@ index.html
|
||||
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel">
|
||||
</div>
|
||||
|
||||
<!-- Memory Connections Panel (Mnemosyne) -->
|
||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── MNEMOSYNE: Memory Filter Panel ───────────────────
|
||||
function openMemoryFilter() {
|
||||
|
||||
291
nexus/components/memory-connections.js
Normal file
291
nexus/components/memory-connections.js
Normal file
@@ -0,0 +1,291 @@
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// MNEMOSYNE — Memory Connection Panel
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
//
|
||||
// Interactive panel for browsing, adding, and removing memory
|
||||
// connections. Opens as a sub-panel from MemoryInspect when
|
||||
// a memory crystal is selected.
|
||||
//
|
||||
// Usage from app.js:
|
||||
// MemoryConnections.init({
|
||||
// onNavigate: fn(memId), // fly to another memory
|
||||
// onConnectionChange: fn(memId, newConnections) // update hooks
|
||||
// });
|
||||
// MemoryConnections.show(memData, allMemories);
|
||||
// MemoryConnections.hide();
|
||||
//
|
||||
// Depends on: SpatialMemory (for updateMemory + highlightMemory)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
const MemoryConnections = (() => {
|
||||
let _panel = null;
|
||||
let _onNavigate = null;
|
||||
let _onConnectionChange = null;
|
||||
let _currentMemId = null;
|
||||
let _hoveredConnId = null;
|
||||
|
||||
// ─── INIT ────────────────────────────────────────────────
|
||||
function init(opts = {}) {
|
||||
_onNavigate = opts.onNavigate || null;
|
||||
_onConnectionChange = opts.onConnectionChange || null;
|
||||
_panel = document.getElementById('memory-connections-panel');
|
||||
if (!_panel) {
|
||||
console.warn('[MemoryConnections] Panel element #memory-connections-panel not found in DOM');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SHOW ────────────────────────────────────────────────
|
||||
function show(memData, allMemories) {
|
||||
if (!_panel || !memData) return;
|
||||
|
||||
_currentMemId = memData.id;
|
||||
const connections = memData.connections || [];
|
||||
const connectedSet = new Set(connections);
|
||||
|
||||
// Build lookup for connected memories
|
||||
const memLookup = {};
|
||||
(allMemories || []).forEach(m => { memLookup[m.id] = m; });
|
||||
|
||||
// Connected memories list
|
||||
let connectedHtml = '';
|
||||
if (connections.length > 0) {
|
||||
connectedHtml = connections.map(cid => {
|
||||
const cm = memLookup[cid];
|
||||
const label = cm ? _truncate(cm.content || cid, 40) : cid;
|
||||
const cat = cm ? cm.category : '';
|
||||
const strength = cm ? Math.round((cm.strength || 0.7) * 100) : 70;
|
||||
return `
|
||||
<div class="mc-conn-item" data-memid="${_esc(cid)}">
|
||||
<div class="mc-conn-info">
|
||||
<span class="mc-conn-label" title="${_esc(cid)}">${_esc(label)}</span>
|
||||
<span class="mc-conn-meta">${_esc(cat)} · ${strength}%</span>
|
||||
</div>
|
||||
<div class="mc-conn-actions">
|
||||
<button class="mc-btn mc-btn-nav" data-nav="${_esc(cid)}" title="Navigate to memory">⮞</button>
|
||||
<button class="mc-btn mc-btn-remove" data-remove="${_esc(cid)}" title="Remove connection">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
connectedHtml = '<div class="mc-empty">No connections yet</div>';
|
||||
}
|
||||
|
||||
// Find nearby unconnected memories (same region, then other regions)
|
||||
const suggestions = _findSuggestions(memData, allMemories, connectedSet);
|
||||
let suggestHtml = '';
|
||||
if (suggestions.length > 0) {
|
||||
suggestHtml = suggestions.map(s => {
|
||||
const label = _truncate(s.content || s.id, 36);
|
||||
const cat = s.category || '';
|
||||
const proximity = s._proximity || '';
|
||||
return `
|
||||
<div class="mc-suggest-item" data-memid="${_esc(s.id)}">
|
||||
<div class="mc-suggest-info">
|
||||
<span class="mc-suggest-label" title="${_esc(s.id)}">${_esc(label)}</span>
|
||||
<span class="mc-suggest-meta">${_esc(cat)} · ${_esc(proximity)}</span>
|
||||
</div>
|
||||
<button class="mc-btn mc-btn-add" data-add="${_esc(s.id)}" title="Add connection">+</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
suggestHtml = '<div class="mc-empty">No nearby memories to connect</div>';
|
||||
}
|
||||
|
||||
_panel.innerHTML = `
|
||||
<div class="mc-header">
|
||||
<span class="mc-title">⬡ Connections</span>
|
||||
<button class="mc-close" id="mc-close-btn" aria-label="Close connections panel">✕</button>
|
||||
</div>
|
||||
<div class="mc-section">
|
||||
<div class="mc-section-label">LINKED (${connections.length})</div>
|
||||
<div class="mc-conn-list" id="mc-conn-list">${connectedHtml}</div>
|
||||
</div>
|
||||
<div class="mc-section">
|
||||
<div class="mc-section-label">SUGGESTED</div>
|
||||
<div class="mc-suggest-list" id="mc-suggest-list">${suggestHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire close button
|
||||
_panel.querySelector('#mc-close-btn')?.addEventListener('click', hide);
|
||||
|
||||
// Wire navigation buttons
|
||||
_panel.querySelectorAll('[data-nav]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (_onNavigate) _onNavigate(btn.dataset.nav);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire remove buttons
|
||||
_panel.querySelectorAll('[data-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _removeConnection(btn.dataset.remove));
|
||||
});
|
||||
|
||||
// Wire add buttons
|
||||
_panel.querySelectorAll('[data-add]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _addConnection(btn.dataset.add));
|
||||
});
|
||||
|
||||
// Wire hover highlight for connection items
|
||||
_panel.querySelectorAll('.mc-conn-item').forEach(item => {
|
||||
item.addEventListener('mouseenter', () => _highlightConnection(item.dataset.memid));
|
||||
item.addEventListener('mouseleave', _clearConnectionHighlight);
|
||||
});
|
||||
|
||||
_panel.style.display = 'flex';
|
||||
requestAnimationFrame(() => _panel.classList.add('mc-visible'));
|
||||
}
|
||||
|
||||
// ─── HIDE ────────────────────────────────────────────────
|
||||
function hide() {
|
||||
if (!_panel) return;
|
||||
_clearConnectionHighlight();
|
||||
_panel.classList.remove('mc-visible');
|
||||
const onEnd = () => {
|
||||
_panel.style.display = 'none';
|
||||
_panel.removeEventListener('transitionend', onEnd);
|
||||
};
|
||||
_panel.addEventListener('transitionend', onEnd);
|
||||
setTimeout(() => { if (_panel) _panel.style.display = 'none'; }, 350);
|
||||
_currentMemId = null;
|
||||
}
|
||||
|
||||
// ─── SUGGESTION ENGINE ──────────────────────────────────
|
||||
function _findSuggestions(memData, allMemories, connectedSet) {
|
||||
if (!allMemories) return [];
|
||||
|
||||
const suggestions = [];
|
||||
const pos = memData.position || [0, 0, 0];
|
||||
const sameRegion = memData.category || 'working';
|
||||
|
||||
for (const m of allMemories) {
|
||||
if (m.id === memData.id) continue;
|
||||
if (connectedSet.has(m.id)) continue;
|
||||
|
||||
const mpos = m.position || [0, 0, 0];
|
||||
const dist = Math.sqrt(
|
||||
(pos[0] - mpos[0]) ** 2 +
|
||||
(pos[1] - mpos[1]) ** 2 +
|
||||
(pos[2] - mpos[2]) ** 2
|
||||
);
|
||||
|
||||
// Categorize proximity
|
||||
let proximity = 'nearby';
|
||||
if (m.category === sameRegion) {
|
||||
proximity = dist < 5 ? 'same region · close' : 'same region';
|
||||
} else {
|
||||
proximity = dist < 10 ? 'adjacent' : 'distant';
|
||||
}
|
||||
|
||||
suggestions.push({ ...m, _dist: dist, _proximity: proximity });
|
||||
}
|
||||
|
||||
// Sort: same region first, then by distance
|
||||
suggestions.sort((a, b) => {
|
||||
const aSame = a.category === sameRegion ? 0 : 1;
|
||||
const bSame = b.category === sameRegion ? 0 : 1;
|
||||
if (aSame !== bSame) return aSame - bSame;
|
||||
return a._dist - b._dist;
|
||||
});
|
||||
|
||||
return suggestions.slice(0, 8); // Cap at 8 suggestions
|
||||
}
|
||||
|
||||
// ─── CONNECTION ACTIONS ─────────────────────────────────
|
||||
function _addConnection(targetId) {
|
||||
if (!_currentMemId) return;
|
||||
|
||||
// Get current memory data via SpatialMemory
|
||||
const allMems = typeof SpatialMemory !== 'undefined' ? SpatialMemory.getAllMemories() : [];
|
||||
const current = allMems.find(m => m.id === _currentMemId);
|
||||
if (!current) return;
|
||||
|
||||
const conns = [...(current.connections || [])];
|
||||
if (conns.includes(targetId)) return;
|
||||
|
||||
conns.push(targetId);
|
||||
|
||||
// Update SpatialMemory
|
||||
if (typeof SpatialMemory !== 'undefined') {
|
||||
SpatialMemory.updateMemory(_currentMemId, { connections: conns });
|
||||
}
|
||||
|
||||
// Also create reverse connection on target
|
||||
const target = allMems.find(m => m.id === targetId);
|
||||
if (target) {
|
||||
const targetConns = [...(target.connections || [])];
|
||||
if (!targetConns.includes(_currentMemId)) {
|
||||
targetConns.push(_currentMemId);
|
||||
SpatialMemory.updateMemory(targetId, { connections: targetConns });
|
||||
}
|
||||
}
|
||||
|
||||
if (_onConnectionChange) _onConnectionChange(_currentMemId, conns);
|
||||
|
||||
// Re-render panel
|
||||
const updatedMem = { ...current, connections: conns };
|
||||
show(updatedMem, allMems);
|
||||
}
|
||||
|
||||
function _removeConnection(targetId) {
|
||||
if (!_currentMemId) return;
|
||||
|
||||
const allMems = typeof SpatialMemory !== 'undefined' ? SpatialMemory.getAllMemories() : [];
|
||||
const current = allMems.find(m => m.id === _currentMemId);
|
||||
if (!current) return;
|
||||
|
||||
const conns = (current.connections || []).filter(c => c !== targetId);
|
||||
|
||||
if (typeof SpatialMemory !== 'undefined') {
|
||||
SpatialMemory.updateMemory(_currentMemId, { connections: conns });
|
||||
}
|
||||
|
||||
// Also remove reverse connection
|
||||
const target = allMems.find(m => m.id === targetId);
|
||||
if (target) {
|
||||
const targetConns = (target.connections || []).filter(c => c !== _currentMemId);
|
||||
SpatialMemory.updateMemory(targetId, { connections: targetConns });
|
||||
}
|
||||
|
||||
if (_onConnectionChange) _onConnectionChange(_currentMemId, conns);
|
||||
|
||||
const updatedMem = { ...current, connections: conns };
|
||||
show(updatedMem, allMems);
|
||||
}
|
||||
|
||||
// ─── 3D HIGHLIGHT ───────────────────────────────────────
|
||||
function _highlightConnection(memId) {
|
||||
_hoveredConnId = memId;
|
||||
if (typeof SpatialMemory !== 'undefined') {
|
||||
SpatialMemory.highlightMemory(memId);
|
||||
}
|
||||
}
|
||||
|
||||
function _clearConnectionHighlight() {
|
||||
if (_hoveredConnId && typeof SpatialMemory !== 'undefined') {
|
||||
SpatialMemory.clearHighlight();
|
||||
}
|
||||
_hoveredConnId = null;
|
||||
}
|
||||
|
||||
// ─── HELPERS ────────────────────────────────────────────
|
||||
function _esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _truncate(str, n) {
|
||||
return str.length > n ? str.slice(0, n - 1) + '\u2026' : str;
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return _panel != null && _panel.style.display !== 'none';
|
||||
}
|
||||
|
||||
return { init, show, hide, isOpen };
|
||||
})();
|
||||
|
||||
export { MemoryConnections };
|
||||
BIN
nexus/mnemosyne/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
nexus/mnemosyne/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
nexus/mnemosyne/__pycache__/archive.cpython-311.pyc
Normal file
BIN
nexus/mnemosyne/__pycache__/archive.cpython-311.pyc
Normal file
Binary file not shown.
BIN
nexus/mnemosyne/__pycache__/cli.cpython-311.pyc
Normal file
BIN
nexus/mnemosyne/__pycache__/cli.cpython-311.pyc
Normal file
Binary file not shown.
BIN
nexus/mnemosyne/__pycache__/entry.cpython-311.pyc
Normal file
BIN
nexus/mnemosyne/__pycache__/entry.cpython-311.pyc
Normal file
Binary file not shown.
BIN
nexus/mnemosyne/__pycache__/ingest.cpython-311.pyc
Normal file
BIN
nexus/mnemosyne/__pycache__/ingest.cpython-311.pyc
Normal file
Binary file not shown.
BIN
nexus/mnemosyne/__pycache__/linker.cpython-311.pyc
Normal file
BIN
nexus/mnemosyne/__pycache__/linker.cpython-311.pyc
Normal file
Binary file not shown.
@@ -7,10 +7,11 @@ and provides query interfaces for retrieving connected knowledge.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.entry import ArchiveEntry, _compute_content_hash
|
||||
from nexus.mnemosyne.linker import HolographicLinker
|
||||
|
||||
_EXPORT_VERSION = "1"
|
||||
@@ -49,14 +50,83 @@ class MnemosyneArchive:
|
||||
with open(self.path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def find_duplicate(self, entry: ArchiveEntry) -> Optional[ArchiveEntry]:
|
||||
"""Return an existing entry with the same content hash, or None."""
|
||||
for existing in self._entries.values():
|
||||
if existing.content_hash == entry.content_hash and existing.id != entry.id:
|
||||
return existing
|
||||
return None
|
||||
|
||||
def add(self, entry: ArchiveEntry, auto_link: bool = True) -> ArchiveEntry:
|
||||
"""Add an entry to the archive. Auto-links to related entries."""
|
||||
"""Add an entry to the archive. Auto-links to related entries.
|
||||
|
||||
If an entry with the same content hash already exists, returns the
|
||||
existing entry without creating a duplicate.
|
||||
"""
|
||||
duplicate = self.find_duplicate(entry)
|
||||
if duplicate is not None:
|
||||
return duplicate
|
||||
self._entries[entry.id] = entry
|
||||
if auto_link:
|
||||
self.linker.apply_links(entry, list(self._entries.values()))
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def update_entry(
|
||||
self,
|
||||
entry_id: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
auto_link: bool = True,
|
||||
) -> ArchiveEntry:
|
||||
"""Update title, content, and/or metadata on an existing entry.
|
||||
|
||||
Bumps ``updated_at`` and re-runs auto-linking when content changes.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the entry to update.
|
||||
title: New title, or None to leave unchanged.
|
||||
content: New content, or None to leave unchanged.
|
||||
metadata: Dict to merge into existing metadata (replaces keys present).
|
||||
auto_link: If True, re-run holographic linker after content change.
|
||||
|
||||
Returns:
|
||||
The updated ArchiveEntry.
|
||||
|
||||
Raises:
|
||||
KeyError: If entry_id does not exist.
|
||||
"""
|
||||
entry = self._entries.get(entry_id)
|
||||
if entry is None:
|
||||
raise KeyError(entry_id)
|
||||
|
||||
content_changed = False
|
||||
if title is not None and title != entry.title:
|
||||
entry.title = title
|
||||
content_changed = True
|
||||
if content is not None and content != entry.content:
|
||||
entry.content = content
|
||||
content_changed = True
|
||||
if metadata is not None:
|
||||
entry.metadata.update(metadata)
|
||||
|
||||
if content_changed:
|
||||
entry.content_hash = _compute_content_hash(entry.title, entry.content)
|
||||
|
||||
entry.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if content_changed and auto_link:
|
||||
# Clear old links from this entry and re-run linker
|
||||
for other in self._entries.values():
|
||||
if entry_id in other.links:
|
||||
other.links.remove(entry_id)
|
||||
entry.links = []
|
||||
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)
|
||||
|
||||
@@ -581,6 +651,68 @@ class MnemosyneArchive:
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def _parse_dt(dt_str: str) -> datetime:
|
||||
"""Parse an ISO datetime string. Assumes UTC if no timezone is specified."""
|
||||
dt = datetime.fromisoformat(dt_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
def by_date_range(self, start: str, end: str) -> list[ArchiveEntry]:
|
||||
"""Return entries whose ``created_at`` falls within [start, end] (inclusive).
|
||||
|
||||
Args:
|
||||
start: ISO datetime string for the range start (e.g. "2024-01-01" or
|
||||
"2024-01-01T00:00:00Z"). Timezone-naive strings are treated as UTC.
|
||||
end: ISO datetime string for the range end. Timezone-naive strings are
|
||||
treated as UTC.
|
||||
|
||||
Returns:
|
||||
List of ArchiveEntry sorted by ``created_at`` ascending.
|
||||
"""
|
||||
start_dt = self._parse_dt(start)
|
||||
end_dt = self._parse_dt(end)
|
||||
results = []
|
||||
for entry in self._entries.values():
|
||||
entry_dt = self._parse_dt(entry.created_at)
|
||||
if start_dt <= entry_dt <= end_dt:
|
||||
results.append(entry)
|
||||
results.sort(key=lambda e: e.created_at)
|
||||
return results
|
||||
|
||||
def temporal_neighbors(self, entry_id: str, window_days: int = 7) -> list[ArchiveEntry]:
|
||||
"""Return entries created within ``window_days`` of a given entry.
|
||||
|
||||
The reference entry itself is excluded from results.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the anchor entry.
|
||||
window_days: Number of days around the anchor's ``created_at`` to search.
|
||||
|
||||
Returns:
|
||||
List of ArchiveEntry sorted by ``created_at`` ascending.
|
||||
|
||||
Raises:
|
||||
KeyError: If ``entry_id`` does not exist in the archive.
|
||||
"""
|
||||
anchor = self._entries.get(entry_id)
|
||||
if anchor is None:
|
||||
raise KeyError(entry_id)
|
||||
anchor_dt = self._parse_dt(anchor.created_at)
|
||||
delta = timedelta(days=window_days)
|
||||
window_start = anchor_dt - delta
|
||||
window_end = anchor_dt + delta
|
||||
results = []
|
||||
for entry in self._entries.values():
|
||||
if entry.id == entry_id:
|
||||
continue
|
||||
entry_dt = self._parse_dt(entry.created_at)
|
||||
if window_start <= entry_dt <= window_end:
|
||||
results.append(entry)
|
||||
results.sort(key=lambda e: e.created_at)
|
||||
return results
|
||||
|
||||
def rebuild_links(self, threshold: Optional[float] = None) -> int:
|
||||
"""Recompute all links from scratch.
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
|
||||
mnemosyne topics, mnemosyne remove, mnemosyne export,
|
||||
mnemosyne clusters, mnemosyne hubs, mnemosyne bridges, mnemosyne rebuild,
|
||||
mnemosyne tag, mnemosyne untag, mnemosyne retag
|
||||
mnemosyne tag, mnemosyne untag, mnemosyne retag,
|
||||
mnemosyne timeline, mnemosyne neighbors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -180,6 +181,38 @@ def cmd_retag(args):
|
||||
print(f" Topics: {', '.join(entry.topics) if entry.topics else '(none)'}")
|
||||
|
||||
|
||||
def cmd_timeline(args):
|
||||
archive = MnemosyneArchive()
|
||||
try:
|
||||
results = archive.by_date_range(args.start, args.end)
|
||||
except ValueError as e:
|
||||
print(f"Invalid date format: {e}")
|
||||
sys.exit(1)
|
||||
if not results:
|
||||
print("No entries found in that date range.")
|
||||
return
|
||||
for entry in results:
|
||||
print(f"[{entry.id[:8]}] {entry.created_at[:10]} {entry.title}")
|
||||
print(f" Topics: {', '.join(entry.topics) if entry.topics else '(none)'}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_neighbors(args):
|
||||
archive = MnemosyneArchive()
|
||||
try:
|
||||
results = archive.temporal_neighbors(args.entry_id, window_days=args.days)
|
||||
except KeyError:
|
||||
print(f"Entry not found: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
if not results:
|
||||
print("No temporal neighbors found.")
|
||||
return
|
||||
for entry in results:
|
||||
print(f"[{entry.id[:8]}] {entry.created_at[:10]} {entry.title}")
|
||||
print(f" Topics: {', '.join(entry.topics) if entry.topics else '(none)'}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
@@ -233,6 +266,14 @@ def main():
|
||||
rt.add_argument("entry_id", help="Entry ID")
|
||||
rt.add_argument("tags", help="Comma-separated new tag list")
|
||||
|
||||
tl = sub.add_parser("timeline", help="Show entries within an ISO date range")
|
||||
tl.add_argument("start", help="Start datetime (ISO format, e.g. 2024-01-01 or 2024-01-01T00:00:00Z)")
|
||||
tl.add_argument("end", help="End datetime (ISO format)")
|
||||
|
||||
nb = sub.add_parser("neighbors", help="Show entries temporally near a given entry")
|
||||
nb.add_argument("entry_id", help="Anchor entry ID")
|
||||
nb.add_argument("--days", type=int, default=7, help="Window in days (default: 7)")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
@@ -253,6 +294,8 @@ def main():
|
||||
"tag": cmd_tag,
|
||||
"untag": cmd_untag,
|
||||
"retag": cmd_retag,
|
||||
"timeline": cmd_timeline,
|
||||
"neighbors": cmd_neighbors,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
|
||||
@@ -6,12 +6,19 @@ with metadata, content, and links to related entries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
|
||||
def _compute_content_hash(title: str, content: str) -> str:
|
||||
"""Compute SHA-256 of title+content for deduplication."""
|
||||
raw = f"{title}\x00{content}".encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveEntry:
|
||||
"""A single node in the Mnemosyne holographic archive."""
|
||||
@@ -24,7 +31,13 @@ class ArchiveEntry:
|
||||
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())
|
||||
updated_at: Optional[str] = None # Set on mutation; None means same as created_at
|
||||
links: list[str] = field(default_factory=list) # IDs of related entries
|
||||
content_hash: Optional[str] = None # SHA-256 of title+content for dedup
|
||||
|
||||
def __post_init__(self):
|
||||
if self.content_hash is None:
|
||||
self.content_hash = _compute_content_hash(self.title, self.content)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -36,7 +49,9 @@ class ArchiveEntry:
|
||||
"topics": self.topics,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"links": self.links,
|
||||
"content_hash": self.content_hash,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
BIN
nexus/mnemosyne/tests/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
nexus/mnemosyne/tests/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
@@ -491,3 +492,364 @@ def test_tag_persistence_across_reload():
|
||||
fresh = a2.get(e.id)
|
||||
assert "beta" in fresh.topics
|
||||
assert "alpha" not in fresh.topics
|
||||
|
||||
|
||||
# --- content_hash and updated_at field tests ---
|
||||
|
||||
def test_entry_has_content_hash():
|
||||
e = ArchiveEntry(title="Hello", content="world")
|
||||
assert e.content_hash is not None
|
||||
assert len(e.content_hash) == 64 # SHA-256 hex
|
||||
|
||||
|
||||
def test_entry_content_hash_deterministic():
|
||||
e1 = ArchiveEntry(title="Hello", content="world")
|
||||
e2 = ArchiveEntry(title="Hello", content="world")
|
||||
assert e1.content_hash == e2.content_hash
|
||||
|
||||
|
||||
def test_entry_content_hash_differs_on_different_content():
|
||||
e1 = ArchiveEntry(title="Hello", content="world")
|
||||
e2 = ArchiveEntry(title="Hello", content="different")
|
||||
assert e1.content_hash != e2.content_hash
|
||||
|
||||
|
||||
def test_entry_updated_at_defaults_none():
|
||||
e = ArchiveEntry(title="T", content="c")
|
||||
assert e.updated_at is None
|
||||
|
||||
|
||||
def test_entry_roundtrip_includes_new_fields():
|
||||
e = ArchiveEntry(title="T", content="c")
|
||||
d = e.to_dict()
|
||||
assert "content_hash" in d
|
||||
assert "updated_at" in d
|
||||
e2 = ArchiveEntry.from_dict(d)
|
||||
assert e2.content_hash == e.content_hash
|
||||
assert e2.updated_at == e.updated_at
|
||||
|
||||
|
||||
# --- content deduplication tests ---
|
||||
|
||||
def test_add_deduplication_same_content():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Dup", content="Same content here")
|
||||
e2 = ingest_event(archive, title="Dup", content="Same content here")
|
||||
# Should NOT have created a second entry
|
||||
assert archive.count == 1
|
||||
assert e1.id == e2.id
|
||||
|
||||
|
||||
def test_add_deduplication_different_content():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="Content one")
|
||||
ingest_event(archive, title="B", content="Content two")
|
||||
assert archive.count == 2
|
||||
|
||||
|
||||
def test_find_duplicate_returns_existing():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Dup", content="Same content here")
|
||||
probe = ArchiveEntry(title="Dup", content="Same content here")
|
||||
dup = archive.find_duplicate(probe)
|
||||
assert dup is not None
|
||||
assert dup.id == e1.id
|
||||
|
||||
|
||||
def test_find_duplicate_returns_none_for_unique():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="Some content")
|
||||
probe = ArchiveEntry(title="B", content="Totally different content")
|
||||
assert archive.find_duplicate(probe) is None
|
||||
|
||||
|
||||
def test_find_duplicate_empty_archive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
probe = ArchiveEntry(title="X", content="y")
|
||||
assert archive.find_duplicate(probe) is None
|
||||
|
||||
|
||||
# --- update_entry tests ---
|
||||
|
||||
def test_update_entry_title():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(archive, title="Old title", content="Some content")
|
||||
archive.update_entry(e.id, title="New title")
|
||||
fresh = archive.get(e.id)
|
||||
assert fresh.title == "New title"
|
||||
assert fresh.content == "Some content"
|
||||
|
||||
|
||||
def test_update_entry_content():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(archive, title="T", content="Old content")
|
||||
archive.update_entry(e.id, content="New content")
|
||||
fresh = archive.get(e.id)
|
||||
assert fresh.content == "New content"
|
||||
|
||||
|
||||
def test_update_entry_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(archive, title="T", content="c")
|
||||
archive.update_entry(e.id, metadata={"key": "value"})
|
||||
fresh = archive.get(e.id)
|
||||
assert fresh.metadata["key"] == "value"
|
||||
|
||||
|
||||
def test_update_entry_bumps_updated_at():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(archive, title="T", content="c")
|
||||
assert e.updated_at is None
|
||||
archive.update_entry(e.id, title="Updated")
|
||||
fresh = archive.get(e.id)
|
||||
assert fresh.updated_at is not None
|
||||
|
||||
|
||||
def test_update_entry_refreshes_content_hash():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(archive, title="T", content="Original content")
|
||||
old_hash = e.content_hash
|
||||
archive.update_entry(e.id, content="Completely new content")
|
||||
fresh = archive.get(e.id)
|
||||
assert fresh.content_hash != old_hash
|
||||
|
||||
|
||||
def test_update_entry_missing_raises():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
try:
|
||||
archive.update_entry("nonexistent-id", title="X")
|
||||
assert False, "Expected KeyError"
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def test_update_entry_persists_across_reload():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
a1 = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(a1, title="Before", content="Before content")
|
||||
a1.update_entry(e.id, title="After", content="After content")
|
||||
|
||||
a2 = MnemosyneArchive(archive_path=path)
|
||||
fresh = a2.get(e.id)
|
||||
assert fresh.title == "After"
|
||||
assert fresh.content == "After content"
|
||||
assert fresh.updated_at is not None
|
||||
|
||||
|
||||
def test_update_entry_no_change_no_crash():
|
||||
"""Calling update_entry with all None args should not fail."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(archive, title="T", content="c")
|
||||
result = archive.update_entry(e.id)
|
||||
assert result.title == "T"
|
||||
|
||||
|
||||
# --- by_date_range tests ---
|
||||
|
||||
def _make_entry_at(archive: MnemosyneArchive, title: str, dt: datetime) -> ArchiveEntry:
|
||||
"""Helper: ingest an entry and backdate its created_at."""
|
||||
e = ingest_event(archive, title=title, content=title)
|
||||
e.created_at = dt.isoformat()
|
||||
archive._save()
|
||||
return e
|
||||
|
||||
|
||||
def test_by_date_range_empty_archive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
results = archive.by_date_range("2024-01-01", "2024-12-31")
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_by_date_range_returns_matching_entries():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
jan = datetime(2024, 1, 15, tzinfo=timezone.utc)
|
||||
mar = datetime(2024, 3, 10, tzinfo=timezone.utc)
|
||||
jun = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||
e1 = _make_entry_at(archive, "Jan entry", jan)
|
||||
e2 = _make_entry_at(archive, "Mar entry", mar)
|
||||
e3 = _make_entry_at(archive, "Jun entry", jun)
|
||||
|
||||
results = archive.by_date_range("2024-01-01", "2024-04-01")
|
||||
ids = {e.id for e in results}
|
||||
assert e1.id in ids
|
||||
assert e2.id in ids
|
||||
assert e3.id not in ids
|
||||
|
||||
|
||||
def test_by_date_range_boundary_inclusive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
exact = datetime(2024, 3, 1, tzinfo=timezone.utc)
|
||||
e = _make_entry_at(archive, "Exact boundary", exact)
|
||||
|
||||
results = archive.by_date_range("2024-03-01T00:00:00+00:00", "2024-03-01T00:00:00+00:00")
|
||||
assert len(results) == 1
|
||||
assert results[0].id == e.id
|
||||
|
||||
|
||||
def test_by_date_range_no_results():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
jan = datetime(2024, 1, 15, tzinfo=timezone.utc)
|
||||
_make_entry_at(archive, "Jan entry", jan)
|
||||
|
||||
results = archive.by_date_range("2023-01-01", "2023-12-31")
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_by_date_range_timezone_naive_treated_as_utc():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
dt = datetime(2024, 6, 15, tzinfo=timezone.utc)
|
||||
e = _make_entry_at(archive, "Summer", dt)
|
||||
|
||||
# Timezone-naive start/end should still match
|
||||
results = archive.by_date_range("2024-06-01", "2024-07-01")
|
||||
assert any(r.id == e.id for r in results)
|
||||
|
||||
|
||||
def test_by_date_range_sorted_ascending():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
dates = [
|
||||
datetime(2024, 3, 5, tzinfo=timezone.utc),
|
||||
datetime(2024, 1, 10, tzinfo=timezone.utc),
|
||||
datetime(2024, 2, 20, tzinfo=timezone.utc),
|
||||
]
|
||||
for i, dt in enumerate(dates):
|
||||
_make_entry_at(archive, f"Entry {i}", dt)
|
||||
|
||||
results = archive.by_date_range("2024-01-01", "2024-12-31")
|
||||
assert len(results) == 3
|
||||
assert results[0].created_at < results[1].created_at < results[2].created_at
|
||||
|
||||
|
||||
def test_by_date_range_single_entry_archive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
dt = datetime(2024, 5, 1, tzinfo=timezone.utc)
|
||||
e = _make_entry_at(archive, "Only", dt)
|
||||
|
||||
assert archive.by_date_range("2024-01-01", "2024-12-31") == [e]
|
||||
assert archive.by_date_range("2025-01-01", "2025-12-31") == []
|
||||
|
||||
|
||||
# --- temporal_neighbors tests ---
|
||||
|
||||
def test_temporal_neighbors_empty_archive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
e = ingest_event(archive, title="Lone", content="c")
|
||||
results = archive.temporal_neighbors(e.id, window_days=7)
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_temporal_neighbors_missing_entry_raises():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
try:
|
||||
archive.temporal_neighbors("nonexistent-id")
|
||||
assert False, "Expected KeyError"
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def test_temporal_neighbors_returns_within_window():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
anchor_dt = datetime(2024, 4, 10, tzinfo=timezone.utc)
|
||||
near_dt = datetime(2024, 4, 14, tzinfo=timezone.utc) # +4 days — within 7
|
||||
far_dt = datetime(2024, 4, 20, tzinfo=timezone.utc) # +10 days — outside 7
|
||||
|
||||
anchor = _make_entry_at(archive, "Anchor", anchor_dt)
|
||||
near = _make_entry_at(archive, "Near", near_dt)
|
||||
far = _make_entry_at(archive, "Far", far_dt)
|
||||
|
||||
results = archive.temporal_neighbors(anchor.id, window_days=7)
|
||||
ids = {e.id for e in results}
|
||||
assert near.id in ids
|
||||
assert far.id not in ids
|
||||
assert anchor.id not in ids
|
||||
|
||||
|
||||
def test_temporal_neighbors_excludes_anchor():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
dt = datetime(2024, 4, 10, tzinfo=timezone.utc)
|
||||
anchor = _make_entry_at(archive, "Anchor", dt)
|
||||
same = _make_entry_at(archive, "Same day", dt)
|
||||
|
||||
results = archive.temporal_neighbors(anchor.id, window_days=0)
|
||||
ids = {e.id for e in results}
|
||||
assert anchor.id not in ids
|
||||
assert same.id in ids
|
||||
|
||||
|
||||
def test_temporal_neighbors_custom_window():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
anchor_dt = datetime(2024, 4, 10, tzinfo=timezone.utc)
|
||||
within_3 = datetime(2024, 4, 12, tzinfo=timezone.utc) # +2 days
|
||||
outside_3 = datetime(2024, 4, 15, tzinfo=timezone.utc) # +5 days
|
||||
|
||||
anchor = _make_entry_at(archive, "Anchor", anchor_dt)
|
||||
e_near = _make_entry_at(archive, "Near", within_3)
|
||||
e_far = _make_entry_at(archive, "Far", outside_3)
|
||||
|
||||
results = archive.temporal_neighbors(anchor.id, window_days=3)
|
||||
ids = {e.id for e in results}
|
||||
assert e_near.id in ids
|
||||
assert e_far.id not in ids
|
||||
|
||||
|
||||
def test_temporal_neighbors_sorted_ascending():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
anchor_dt = datetime(2024, 6, 15, tzinfo=timezone.utc)
|
||||
anchor = _make_entry_at(archive, "Anchor", anchor_dt)
|
||||
for offset in [5, 1, 3]:
|
||||
_make_entry_at(archive, f"Offset {offset}", anchor_dt + timedelta(days=offset))
|
||||
|
||||
results = archive.temporal_neighbors(anchor.id, window_days=7)
|
||||
assert len(results) == 3
|
||||
assert results[0].created_at < results[1].created_at < results[2].created_at
|
||||
|
||||
|
||||
def test_temporal_neighbors_boundary_inclusive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = MnemosyneArchive(archive_path=Path(tmp) / "a.json")
|
||||
anchor_dt = datetime(2024, 6, 15, tzinfo=timezone.utc)
|
||||
boundary_dt = anchor_dt + timedelta(days=7) # exactly at window edge
|
||||
|
||||
anchor = _make_entry_at(archive, "Anchor", anchor_dt)
|
||||
boundary = _make_entry_at(archive, "Boundary", boundary_dt)
|
||||
|
||||
results = archive.temporal_neighbors(anchor.id, window_days=7)
|
||||
assert any(r.id == boundary.id for r in results)
|
||||
|
||||
160
style.css
160
style.css
@@ -1917,3 +1917,163 @@ canvas#nexus-canvas {
|
||||
background: rgba(74, 240, 192, 0.18);
|
||||
border-color: #4af0c0;
|
||||
}
|
||||
|
||||
/* ═══ MNEMOSYNE: Memory Connections Panel ═══ */
|
||||
.memory-connections-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 280px;
|
||||
transform: translateY(-50%) translateX(12px);
|
||||
width: 260px;
|
||||
max-height: 70vh;
|
||||
background: rgba(10, 12, 18, 0.92);
|
||||
border: 1px solid rgba(74, 240, 192, 0.15);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
z-index: 310;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.memory-connections-panel.mc-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.mc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.mc-title {
|
||||
color: rgba(74, 240, 192, 0.8);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mc-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.mc-close:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mc-section {
|
||||
padding: 8px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.mc-section:last-child { border-bottom: none; }
|
||||
|
||||
.mc-section-label {
|
||||
color: rgba(74, 240, 192, 0.5);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mc-conn-list, .mc-suggest-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mc-conn-list::-webkit-scrollbar, .mc-suggest-list::-webkit-scrollbar { width: 3px; }
|
||||
.mc-conn-list::-webkit-scrollbar-thumb, .mc-suggest-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(74, 240, 192, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mc-conn-item, .mc-suggest-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 4px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.mc-conn-item:hover {
|
||||
background: rgba(74, 240, 192, 0.06);
|
||||
}
|
||||
.mc-suggest-item:hover {
|
||||
background: rgba(123, 92, 255, 0.06);
|
||||
}
|
||||
|
||||
.mc-conn-info, .mc-suggest-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-conn-label, .mc-suggest-label {
|
||||
display: block;
|
||||
color: var(--color-text, #ccc);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-conn-meta, .mc-suggest-meta {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 9px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.mc-conn-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.mc-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.mc-btn-nav:hover {
|
||||
border-color: #4af0c0;
|
||||
color: #4af0c0;
|
||||
background: rgba(74, 240, 192, 0.08);
|
||||
}
|
||||
.mc-btn-remove:hover {
|
||||
border-color: #ff4466;
|
||||
color: #ff4466;
|
||||
background: rgba(255, 68, 102, 0.08);
|
||||
}
|
||||
.mc-btn-add {
|
||||
border-color: rgba(123, 92, 255, 0.3);
|
||||
color: rgba(123, 92, 255, 0.7);
|
||||
}
|
||||
.mc-btn-add:hover {
|
||||
border-color: #7b5cff;
|
||||
color: #7b5cff;
|
||||
background: rgba(123, 92, 255, 0.12);
|
||||
}
|
||||
|
||||
.mc-empty {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user