Compare commits
4 Commits
feat/mnemo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a378aa576e | |||
|
|
5446d3dc59 | ||
|
|
58c75a29bd | ||
| b3939179b9 |
@@ -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 };
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@ and provides query interfaces for retrieving connected knowledge.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -651,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.
|
||||
|
||||
@@ -685,116 +747,3 @@ class MnemosyneArchive:
|
||||
|
||||
self._save()
|
||||
return total_links
|
||||
|
||||
def by_date_range(
|
||||
self,
|
||||
start: str,
|
||||
end: str,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[ArchiveEntry]:
|
||||
"""Return entries whose created_at falls within [start, end].
|
||||
|
||||
Args:
|
||||
start: ISO datetime string (inclusive). Can be a date-only string
|
||||
like "2026-03-01" (treated as start of that day UTC).
|
||||
end: ISO datetime string (inclusive). Can be a date-only string
|
||||
like "2026-04-01" (treated as end of that day UTC).
|
||||
limit: Maximum entries to return (None = all).
|
||||
|
||||
Returns:
|
||||
List of ArchiveEntry sorted by created_at ascending.
|
||||
"""
|
||||
start_dt = self._parse_datetime(start, end_of_day=False)
|
||||
end_dt = self._parse_datetime(end, end_of_day=True)
|
||||
|
||||
matched = []
|
||||
for entry in self._entries.values():
|
||||
entry_dt = self._parse_datetime(entry.created_at, end_of_day=False)
|
||||
if entry_dt is None:
|
||||
continue
|
||||
if start_dt <= entry_dt <= end_dt:
|
||||
matched.append(entry)
|
||||
|
||||
matched.sort(key=lambda e: e.created_at)
|
||||
if limit is not None:
|
||||
matched = matched[:limit]
|
||||
return matched
|
||||
|
||||
def temporal_neighbors(
|
||||
self,
|
||||
entry_id: str,
|
||||
window_days: int = 7,
|
||||
) -> list[ArchiveEntry]:
|
||||
"""Return entries created within N days of a given entry.
|
||||
|
||||
Args:
|
||||
entry_id: The reference entry's ID.
|
||||
window_days: Number of days before and after the reference entry's
|
||||
created_at to search (default 7).
|
||||
|
||||
Returns:
|
||||
List of ArchiveEntry sorted by time distance from the reference,
|
||||
closest first. Excludes the reference entry itself.
|
||||
|
||||
Raises:
|
||||
KeyError: If entry_id does not exist.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
ref = self._entries.get(entry_id)
|
||||
if ref is None:
|
||||
raise KeyError(entry_id)
|
||||
|
||||
ref_dt = self._parse_datetime(ref.created_at, end_of_day=False)
|
||||
if ref_dt is None:
|
||||
return []
|
||||
|
||||
window = timedelta(days=window_days)
|
||||
start_dt = ref_dt - window
|
||||
end_dt = ref_dt + window
|
||||
|
||||
neighbors = []
|
||||
for entry in self._entries.values():
|
||||
if entry.id == entry_id:
|
||||
continue
|
||||
entry_dt = self._parse_datetime(entry.created_at, end_of_day=False)
|
||||
if entry_dt is None:
|
||||
continue
|
||||
if start_dt <= entry_dt <= end_dt:
|
||||
distance = abs((entry_dt - ref_dt).total_seconds())
|
||||
neighbors.append((distance, entry))
|
||||
|
||||
neighbors.sort(key=lambda x: x[0])
|
||||
return [e for _, e in neighbors]
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(value: str, end_of_day: bool = False) -> Optional[datetime]:
|
||||
"""Parse an ISO datetime or date-only string to a timezone-aware datetime.
|
||||
|
||||
Args:
|
||||
value: ISO datetime string, or date-only string (YYYY-MM-DD).
|
||||
end_of_day: If True and value is date-only, set time to 23:59:59 UTC.
|
||||
|
||||
Returns:
|
||||
Timezone-aware datetime (UTC), or None if parsing fails.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
# Date-only strings (no T separator) get special end_of_day handling
|
||||
if "T" not in value and " " not in value:
|
||||
try:
|
||||
from datetime import date
|
||||
d = date.fromisoformat(value)
|
||||
if end_of_day:
|
||||
return datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc)
|
||||
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
# Full datetime
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@@ -183,43 +183,38 @@ def cmd_retag(args):
|
||||
|
||||
def cmd_timeline(args):
|
||||
archive = MnemosyneArchive()
|
||||
results = archive.by_date_range(args.start, args.end, limit=args.limit)
|
||||
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(f"No entries between {args.start} and {args.end}.")
|
||||
print("No entries found in that date range.")
|
||||
return
|
||||
print(f"Timeline: {args.start} → {args.end} ({len(results)} entries)")
|
||||
print()
|
||||
for entry in results:
|
||||
print(f" [{entry.created_at[:10]}] {entry.title}")
|
||||
print(f" ID: {entry.id[:8]} | Source: {entry.source} | Topics: {', '.join(entry.topics)}")
|
||||
print()
|
||||
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()
|
||||
# Resolve prefix to full ID
|
||||
matches = [e for e in archive._entries.values() if e.id.startswith(args.entry_id)]
|
||||
if not matches:
|
||||
print(f"No entry matching '{args.entry_id}'.")
|
||||
return
|
||||
if len(matches) > 1:
|
||||
print(f"Ambiguous — {len(matches)} entries match '{args.entry_id}'. Use a longer prefix.")
|
||||
return
|
||||
entry = matches[0]
|
||||
results = archive.temporal_neighbors(entry.id, window_days=args.days)
|
||||
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(f"No entries within {args.days} days of [{entry.id[:8]}] {entry.title}.")
|
||||
print("No temporal neighbors found.")
|
||||
return
|
||||
print(f"Neighbors of [{entry.id[:8]}] {entry.title} (±{args.days} days):")
|
||||
print()
|
||||
for neighbor in results:
|
||||
print(f" [{neighbor.created_at[:10]}] {neighbor.title}")
|
||||
print(f" ID: {neighbor.id[:8]} | Source: {neighbor.source} | Topics: {', '.join(neighbor.topics)}")
|
||||
print()
|
||||
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="Mnemosyne — Living Holographic Archive")
|
||||
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("stats", help="Show archive statistics")
|
||||
@@ -271,14 +266,13 @@ 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 a date range")
|
||||
tl.add_argument("start", help="Start date (YYYY-MM-DD or ISO datetime)")
|
||||
tl.add_argument("end", help="End date (YYYY-MM-DD or ISO datetime)")
|
||||
tl.add_argument("-n", "--limit", type=int, default=50, help="Max entries to show")
|
||||
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 reference entry")
|
||||
nb.add_argument("entry_id", help="Reference entry ID (or prefix)")
|
||||
nb.add_argument("-d", "--days", type=int, default=7, help="Window in days (default 7)")
|
||||
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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
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
|
||||
@@ -666,3 +667,189 @@ def test_update_entry_no_change_no_crash():
|
||||
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)
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Tests for Mnemosyne temporal query methods.
|
||||
|
||||
Tests: by_date_range, temporal_neighbors, _parse_datetime.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archive():
|
||||
"""Create a fresh archive in a temp directory."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
a = MnemosyneArchive(archive_path=path)
|
||||
yield a
|
||||
|
||||
|
||||
class TestParseDatetime:
|
||||
"""Test _parse_datetime static method."""
|
||||
|
||||
def test_iso_datetime(self):
|
||||
dt = MnemosyneArchive._parse_datetime("2026-03-15T10:30:00+00:00")
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
assert dt.month == 3
|
||||
assert dt.hour == 10
|
||||
|
||||
def test_date_only_start(self):
|
||||
dt = MnemosyneArchive._parse_datetime("2026-03-15", end_of_day=False)
|
||||
assert dt is not None
|
||||
assert dt.hour == 0
|
||||
assert dt.minute == 0
|
||||
|
||||
def test_date_only_end(self):
|
||||
dt = MnemosyneArchive._parse_datetime("2026-03-15", end_of_day=True)
|
||||
assert dt is not None
|
||||
assert dt.hour == 23
|
||||
assert dt.minute == 59
|
||||
assert dt.second == 59
|
||||
|
||||
def test_naive_datetime_gets_utc(self):
|
||||
dt = MnemosyneArchive._parse_datetime("2026-03-15T10:30:00")
|
||||
assert dt is not None
|
||||
assert dt.tzinfo is not None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert MnemosyneArchive._parse_datetime("") is None
|
||||
|
||||
def test_invalid_string(self):
|
||||
assert MnemosyneArchive._parse_datetime("not-a-date") is None
|
||||
|
||||
|
||||
class TestByDateRange:
|
||||
"""Test by_date_range() method."""
|
||||
|
||||
def _add_dated_entry(self, archive, title, created_at):
|
||||
"""Add an entry with a specific created_at timestamp."""
|
||||
entry = ArchiveEntry(title=title, content=f"Content for {title}")
|
||||
entry.created_at = created_at
|
||||
archive._entries[entry.id] = entry
|
||||
archive._save()
|
||||
return entry
|
||||
|
||||
def test_empty_archive(self, archive):
|
||||
results = archive.by_date_range("2026-01-01", "2026-12-31")
|
||||
assert results == []
|
||||
|
||||
def test_single_entry_in_range(self, archive):
|
||||
self._add_dated_entry(archive, "March entry", "2026-03-15T10:00:00+00:00")
|
||||
results = archive.by_date_range("2026-03-01", "2026-03-31")
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "March entry"
|
||||
|
||||
def test_single_entry_out_of_range(self, archive):
|
||||
self._add_dated_entry(archive, "January entry", "2026-01-10T10:00:00+00:00")
|
||||
results = archive.by_date_range("2026-03-01", "2026-03-31")
|
||||
assert results == []
|
||||
|
||||
def test_boundary_inclusive(self, archive):
|
||||
self._add_dated_entry(archive, "Start", "2026-03-01T00:00:00+00:00")
|
||||
self._add_dated_entry(archive, "End", "2026-03-31T23:59:59+00:00")
|
||||
results = archive.by_date_range("2026-03-01", "2026-03-31")
|
||||
assert len(results) == 2
|
||||
|
||||
def test_sorted_by_date(self, archive):
|
||||
self._add_dated_entry(archive, "Late", "2026-03-20T10:00:00+00:00")
|
||||
self._add_dated_entry(archive, "Early", "2026-03-05T10:00:00+00:00")
|
||||
self._add_dated_entry(archive, "Mid", "2026-03-12T10:00:00+00:00")
|
||||
results = archive.by_date_range("2026-03-01", "2026-03-31")
|
||||
assert [r.title for r in results] == ["Early", "Mid", "Late"]
|
||||
|
||||
def test_limit(self, archive):
|
||||
for i in range(10):
|
||||
self._add_dated_entry(archive, f"Entry {i}", f"2026-03-{i+1:02d}T10:00:00+00:00")
|
||||
results = archive.by_date_range("2026-03-01", "2026-03-31", limit=3)
|
||||
assert len(results) == 3
|
||||
|
||||
def test_date_only_strings(self, archive):
|
||||
self._add_dated_entry(archive, "Mid March", "2026-03-15T10:00:00+00:00")
|
||||
results = archive.by_date_range("2026-03-01", "2026-03-31")
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
class TestTemporalNeighbors:
|
||||
"""Test temporal_neighbors() method."""
|
||||
|
||||
def _add_dated_entry(self, archive, title, created_at):
|
||||
entry = ArchiveEntry(title=title, content=f"Content for {title}")
|
||||
entry.created_at = created_at
|
||||
archive._entries[entry.id] = entry
|
||||
archive._save()
|
||||
return entry
|
||||
|
||||
def test_no_neighbors(self, archive):
|
||||
ref = self._add_dated_entry(archive, "Reference", "2026-03-15T10:00:00+00:00")
|
||||
self._add_dated_entry(archive, "Far away", "2026-06-01T10:00:00+00:00")
|
||||
results = archive.temporal_neighbors(ref.id, window_days=7)
|
||||
assert results == []
|
||||
|
||||
def test_closest_first(self, archive):
|
||||
ref = self._add_dated_entry(archive, "Reference", "2026-03-15T10:00:00+00:00")
|
||||
far = self._add_dated_entry(archive, "6 days away", "2026-03-21T10:00:00+00:00")
|
||||
close = self._add_dated_entry(archive, "1 day away", "2026-03-16T10:00:00+00:00")
|
||||
results = archive.temporal_neighbors(ref.id, window_days=7)
|
||||
assert len(results) == 2
|
||||
assert results[0].title == "1 day away"
|
||||
assert results[1].title == "6 days away"
|
||||
|
||||
def test_excludes_reference(self, archive):
|
||||
ref = self._add_dated_entry(archive, "Reference", "2026-03-15T10:00:00+00:00")
|
||||
self._add_dated_entry(archive, "Nearby", "2026-03-16T10:00:00+00:00")
|
||||
results = archive.temporal_neighbors(ref.id, window_days=7)
|
||||
ids = [r.id for r in results]
|
||||
assert ref.id not in ids
|
||||
|
||||
def test_default_window(self, archive):
|
||||
ref = self._add_dated_entry(archive, "Reference", "2026-03-15T10:00:00+00:00")
|
||||
self._add_dated_entry(archive, "7 days before", "2026-03-08T10:00:00+00:00")
|
||||
self._add_dated_entry(archive, "8 days before", "2026-03-07T10:00:00+00:00")
|
||||
results = archive.temporal_neighbors(ref.id) # default 7 days
|
||||
titles = [r.title for r in results]
|
||||
assert "7 days before" in titles
|
||||
assert "8 days before" not in titles
|
||||
|
||||
def test_custom_window(self, archive):
|
||||
ref = self._add_dated_entry(archive, "Reference", "2026-03-15T10:00:00+00:00")
|
||||
self._add_dated_entry(archive, "14 days away", "2026-03-29T10:00:00+00:00")
|
||||
results = archive.temporal_neighbors(ref.id, window_days=7)
|
||||
assert len(results) == 0
|
||||
results = archive.temporal_neighbors(ref.id, window_days=15)
|
||||
assert len(results) == 1
|
||||
|
||||
def test_bad_entry_id(self, archive):
|
||||
with pytest.raises(KeyError):
|
||||
archive.temporal_neighbors("nonexistent-id")
|
||||
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