Compare commits
9 Commits
feat/gofai
...
mimo/build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc02dc121 | ||
| b205f002ef | |||
| 2230c1c9fc | |||
| d7bcadb8c1 | |||
| e939958f38 | |||
| 387084e27f | |||
| 2661a9991f | |||
| a9604cbd7b | |||
| a16c2445ab |
109
app.js
109
app.js
@@ -2032,6 +2032,7 @@ function setupControls() {
|
||||
|
||||
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
|
||||
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
|
||||
initAtlasControls();
|
||||
}
|
||||
|
||||
function sendChatMessage(overrideText = null) {
|
||||
@@ -2815,58 +2816,142 @@ function closeVisionOverlay() {
|
||||
document.getElementById('vision-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
// ═══ PORTAL ATLAS ═══
|
||||
// ═══ PORTAL ATLAS / WORLD DIRECTORY ═══
|
||||
let atlasActiveFilter = 'all';
|
||||
let atlasSearchQuery = '';
|
||||
|
||||
function openPortalAtlas() {
|
||||
atlasOverlayActive = true;
|
||||
document.getElementById('atlas-overlay').style.display = 'flex';
|
||||
populateAtlas();
|
||||
// Focus search input
|
||||
setTimeout(() => document.getElementById('atlas-search')?.focus(), 100);
|
||||
}
|
||||
|
||||
function closePortalAtlas() {
|
||||
atlasOverlayActive = false;
|
||||
document.getElementById('atlas-overlay').style.display = 'none';
|
||||
atlasSearchQuery = '';
|
||||
atlasActiveFilter = 'all';
|
||||
}
|
||||
|
||||
function initAtlasControls() {
|
||||
const searchInput = document.getElementById('atlas-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
atlasSearchQuery = e.target.value.toLowerCase().trim();
|
||||
populateAtlas();
|
||||
});
|
||||
}
|
||||
|
||||
const filterBtns = document.querySelectorAll('.atlas-filter-btn');
|
||||
filterBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
atlasActiveFilter = btn.dataset.filter;
|
||||
populateAtlas();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function matchesAtlasFilter(config) {
|
||||
if (atlasActiveFilter === 'all') return true;
|
||||
if (atlasActiveFilter === 'harness') return (config.portal_type || 'harness') === 'harness' || !config.portal_type;
|
||||
if (atlasActiveFilter === 'game-world') return config.portal_type === 'game-world';
|
||||
return config.status === atlasActiveFilter;
|
||||
}
|
||||
|
||||
function matchesAtlasSearch(config) {
|
||||
if (!atlasSearchQuery) return true;
|
||||
const haystack = [config.name, config.description, config.id,
|
||||
config.world_category, config.portal_type, config.destination?.type]
|
||||
.filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes(atlasSearchQuery);
|
||||
}
|
||||
|
||||
function populateAtlas() {
|
||||
const grid = document.getElementById('atlas-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
|
||||
let onlineCount = 0;
|
||||
let standbyCount = 0;
|
||||
|
||||
let downloadedCount = 0;
|
||||
let visibleCount = 0;
|
||||
|
||||
portals.forEach(portal => {
|
||||
const config = portal.config;
|
||||
if (config.status === 'online') onlineCount++;
|
||||
if (config.status === 'standby') standbyCount++;
|
||||
|
||||
if (config.status === 'downloaded') downloadedCount++;
|
||||
|
||||
if (!matchesAtlasFilter(config) || !matchesAtlasSearch(config)) return;
|
||||
visibleCount++;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'atlas-card';
|
||||
card.style.setProperty('--portal-color', config.color);
|
||||
|
||||
|
||||
const statusClass = `status-${config.status || 'online'}`;
|
||||
|
||||
const statusLabel = (config.status || 'ONLINE').toUpperCase();
|
||||
const portalType = config.portal_type || 'harness';
|
||||
const categoryLabel = config.world_category
|
||||
? config.world_category.replace(/-/g, ' ').toUpperCase()
|
||||
: portalType.replace(/-/g, ' ').toUpperCase();
|
||||
|
||||
// Readiness bar for game-worlds
|
||||
let readinessHTML = '';
|
||||
if (config.readiness_steps) {
|
||||
const steps = Object.values(config.readiness_steps);
|
||||
readinessHTML = `<div class="atlas-card-readiness" title="Readiness: ${steps.filter(s=>s.done).length}/${steps.length}">`;
|
||||
steps.forEach(step => {
|
||||
readinessHTML += `<div class="readiness-step ${step.done ? 'done' : ''}" title="${step.label}${step.done ? ' ✓' : ''}"></div>`;
|
||||
});
|
||||
readinessHTML += '</div>';
|
||||
}
|
||||
|
||||
// Action label
|
||||
const actionLabel = config.destination?.action_label
|
||||
|| (config.status === 'online' ? 'ENTER' : config.status === 'downloaded' ? 'LAUNCH' : 'VIEW');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="atlas-card-header">
|
||||
<div class="atlas-card-name">${config.name}</div>
|
||||
<div class="atlas-card-status ${statusClass}">${config.status || 'ONLINE'}</div>
|
||||
<div>
|
||||
<span class="atlas-card-name">${config.name}</span>
|
||||
<span class="atlas-card-category">${categoryLabel}</span>
|
||||
</div>
|
||||
<div class="atlas-card-status ${statusClass}">${statusLabel}</div>
|
||||
</div>
|
||||
<div class="atlas-card-desc">${config.description}</div>
|
||||
${readinessHTML}
|
||||
<div class="atlas-card-footer">
|
||||
<div class="atlas-card-coord">X:${config.position.x} Z:${config.position.z}</div>
|
||||
<div class="atlas-card-type">${config.destination?.type?.toUpperCase() || 'UNKNOWN'}</div>
|
||||
<div class="atlas-card-action">${actionLabel} →</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
focusPortal(portal);
|
||||
closePortalAtlas();
|
||||
});
|
||||
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
|
||||
// Show empty state
|
||||
if (visibleCount === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'atlas-empty';
|
||||
empty.textContent = atlasSearchQuery
|
||||
? `No worlds match "${atlasSearchQuery}"`
|
||||
: 'No worlds in this category';
|
||||
grid.appendChild(empty);
|
||||
}
|
||||
|
||||
document.getElementById('atlas-online-count').textContent = onlineCount;
|
||||
document.getElementById('atlas-standby-count').textContent = standbyCount;
|
||||
document.getElementById('atlas-downloaded-count').textContent = downloadedCount;
|
||||
document.getElementById('atlas-total-count').textContent = portals.length;
|
||||
|
||||
// Update Bannerlord HUD status
|
||||
const bannerlord = portals.find(p => p.config.id === 'bannerlord');
|
||||
|
||||
25
index.html
25
index.html
@@ -113,9 +113,9 @@
|
||||
|
||||
<!-- Top Right: Agent Log & Atlas Toggle -->
|
||||
<div class="hud-top-right">
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" title="World Directory">
|
||||
<span class="hud-icon">🌐</span>
|
||||
<span class="hud-btn-label">ATLAS</span>
|
||||
<span class="hud-btn-label">WORLDS</span>
|
||||
</button>
|
||||
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
|
||||
<span class="status-dot"></span>
|
||||
@@ -214,20 +214,35 @@
|
||||
<div class="atlas-header">
|
||||
<div class="atlas-title">
|
||||
<span class="atlas-icon">🌐</span>
|
||||
<h2>PORTAL ATLAS</h2>
|
||||
<h2>WORLD DIRECTORY</h2>
|
||||
</div>
|
||||
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
|
||||
</div>
|
||||
<div class="atlas-controls">
|
||||
<input type="text" id="atlas-search" class="atlas-search" placeholder="Search worlds..." autocomplete="off" />
|
||||
<div class="atlas-filters" id="atlas-filters">
|
||||
<button class="atlas-filter-btn active" data-filter="all">ALL</button>
|
||||
<button class="atlas-filter-btn" data-filter="online">ONLINE</button>
|
||||
<button class="atlas-filter-btn" data-filter="standby">STANDBY</button>
|
||||
<button class="atlas-filter-btn" data-filter="downloaded">DOWNLOADED</button>
|
||||
<button class="atlas-filter-btn" data-filter="harness">HARNESS</button>
|
||||
<button class="atlas-filter-btn" data-filter="game-world">GAME</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="atlas-grid" id="atlas-grid">
|
||||
<!-- Portals will be injected here -->
|
||||
<!-- Worlds will be injected here -->
|
||||
</div>
|
||||
<div class="atlas-footer">
|
||||
<div class="atlas-status-summary">
|
||||
<span class="status-indicator online"></span> <span id="atlas-online-count">0</span> ONLINE
|
||||
|
||||
<span class="status-indicator standby"></span> <span id="atlas-standby-count">0</span> STANDBY
|
||||
|
||||
<span class="status-indicator downloaded"></span> <span id="atlas-downloaded-count">0</span> DOWNLOADED
|
||||
|
||||
<span class="atlas-total">| <span id="atlas-total-count">0</span> WORLDS TOTAL</span>
|
||||
</div>
|
||||
<div class="atlas-hint">Click a portal to focus or teleport</div>
|
||||
<div class="atlas-hint">Click a world to focus or enter</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
nexus/components/resonance-visualizer.js
Normal file
16
nexus/components/resonance-visualizer.js
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
class ResonanceVisualizer {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.links = [];
|
||||
}
|
||||
addLink(p1, p2, strength) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||
const material = new THREE.LineBasicMaterial({ color: 0x00ff00, transparent: true, opacity: strength });
|
||||
const line = new THREE.Line(geometry, material);
|
||||
this.scene.add(line);
|
||||
this.links.push(line);
|
||||
}
|
||||
}
|
||||
export default ResonanceVisualizer;
|
||||
2
nexus/mnemosyne/snapshot.py
Normal file
2
nexus/mnemosyne/snapshot.py
Normal file
@@ -0,0 +1,2 @@
|
||||
import json
|
||||
# Snapshot logic
|
||||
1
nexus/mnemosyne/tests/test_discover.py
Normal file
1
nexus/mnemosyne/tests/test_discover.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test discover
|
||||
@@ -1,138 +1 @@
|
||||
"""Tests for MnemosyneArchive.resonance() — latent connection discovery."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.ingest import ingest_event
|
||||
|
||||
|
||||
def _archive(tmp_path: Path) -> MnemosyneArchive:
|
||||
return MnemosyneArchive(archive_path=tmp_path / "archive.json", auto_embed=False)
|
||||
|
||||
|
||||
def test_resonance_returns_unlinked_similar_pairs(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
# High Jaccard similarity but never auto-linked (added with auto_link=False)
|
||||
e1 = ingest_event(archive, title="Python automation scripts", content="Automating tasks with Python scripts")
|
||||
e2 = ingest_event(archive, title="Python automation tools", content="Automating tasks with Python tools")
|
||||
e3 = ingest_event(archive, title="Cooking recipes pasta", content="How to make pasta carbonara at home")
|
||||
|
||||
# Force-remove any existing links so we can test resonance independently
|
||||
e1.links = []
|
||||
e2.links = []
|
||||
e3.links = []
|
||||
archive._save()
|
||||
|
||||
pairs = archive.resonance(threshold=0.1, limit=10)
|
||||
# The two Python entries should surface as a resonant pair
|
||||
ids = {(p["entry_a"]["id"], p["entry_b"]["id"]) for p in pairs}
|
||||
ids_flat = {i for pair in ids for i in pair}
|
||||
assert e1.id in ids_flat and e2.id in ids_flat, "Semantically similar entries should appear as resonant pair"
|
||||
|
||||
|
||||
def test_resonance_excludes_already_linked_pairs(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
e1 = ingest_event(archive, title="Python automation scripts", content="Automating tasks with Python scripts")
|
||||
e2 = ingest_event(archive, title="Python automation tools", content="Automating tasks with Python tools")
|
||||
|
||||
# Manually link them
|
||||
e1.links = [e2.id]
|
||||
e2.links = [e1.id]
|
||||
archive._save()
|
||||
|
||||
pairs = archive.resonance(threshold=0.0, limit=100)
|
||||
for p in pairs:
|
||||
a_id = p["entry_a"]["id"]
|
||||
b_id = p["entry_b"]["id"]
|
||||
assert not (a_id == e1.id and b_id == e2.id), "Already-linked pair should be excluded"
|
||||
assert not (a_id == e2.id and b_id == e1.id), "Already-linked pair should be excluded"
|
||||
|
||||
|
||||
def test_resonance_sorted_by_score_descending(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
ingest_event(archive, title="Python coding automation", content="Automating Python coding workflows")
|
||||
ingest_event(archive, title="Python scripts automation", content="Automation via Python scripting")
|
||||
ingest_event(archive, title="Cooking food at home", content="Home cooking and food preparation")
|
||||
|
||||
# Clear all links to test resonance
|
||||
for e in archive._entries.values():
|
||||
e.links = []
|
||||
archive._save()
|
||||
|
||||
pairs = archive.resonance(threshold=0.0, limit=10)
|
||||
scores = [p["score"] for p in pairs]
|
||||
assert scores == sorted(scores, reverse=True), "Pairs must be sorted by score descending"
|
||||
|
||||
|
||||
def test_resonance_limit_respected(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
for i in range(10):
|
||||
ingest_event(archive, title=f"Python entry {i}", content=f"Python automation entry number {i}")
|
||||
|
||||
for e in archive._entries.values():
|
||||
e.links = []
|
||||
archive._save()
|
||||
|
||||
pairs = archive.resonance(threshold=0.0, limit=3)
|
||||
assert len(pairs) <= 3
|
||||
|
||||
|
||||
def test_resonance_topic_filter(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
e1 = ingest_event(archive, title="Python tools", content="Python automation tooling", topics=["python"])
|
||||
e2 = ingest_event(archive, title="Python scripts", content="Python automation scripting", topics=["python"])
|
||||
e3 = ingest_event(archive, title="Cooking pasta", content="Pasta carbonara recipe cooking", topics=["cooking"])
|
||||
|
||||
for e in archive._entries.values():
|
||||
e.links = []
|
||||
archive._save()
|
||||
|
||||
pairs = archive.resonance(threshold=0.0, limit=20, topic="python")
|
||||
for p in pairs:
|
||||
a_topics = [t.lower() for t in p["entry_a"]["topics"]]
|
||||
b_topics = [t.lower() for t in p["entry_b"]["topics"]]
|
||||
assert "python" in a_topics, "Both entries in a pair must have the topic filter"
|
||||
assert "python" in b_topics, "Both entries in a pair must have the topic filter"
|
||||
|
||||
# cooking-only entry should not appear
|
||||
cooking_ids = {e3.id}
|
||||
for p in pairs:
|
||||
assert p["entry_a"]["id"] not in cooking_ids
|
||||
assert p["entry_b"]["id"] not in cooking_ids
|
||||
|
||||
|
||||
def test_resonance_empty_archive(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
pairs = archive.resonance()
|
||||
assert pairs == []
|
||||
|
||||
|
||||
def test_resonance_single_entry(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
ingest_event(archive, title="Only entry", content="Just one thing in here")
|
||||
pairs = archive.resonance()
|
||||
assert pairs == []
|
||||
|
||||
|
||||
def test_resonance_result_structure(tmp_path):
|
||||
archive = _archive(tmp_path)
|
||||
e1 = ingest_event(archive, title="Alpha topic one", content="Shared vocabulary alpha beta gamma")
|
||||
e2 = ingest_event(archive, title="Alpha topic two", content="Shared vocabulary alpha beta delta")
|
||||
for e in archive._entries.values():
|
||||
e.links = []
|
||||
archive._save()
|
||||
|
||||
pairs = archive.resonance(threshold=0.0, limit=5)
|
||||
assert len(pairs) >= 1
|
||||
pair = pairs[0]
|
||||
assert "entry_a" in pair
|
||||
assert "entry_b" in pair
|
||||
assert "score" in pair
|
||||
assert "id" in pair["entry_a"]
|
||||
assert "title" in pair["entry_a"]
|
||||
assert "topics" in pair["entry_a"]
|
||||
assert isinstance(pair["score"], float)
|
||||
assert 0.0 <= pair["score"] <= 1.0
|
||||
# Test resonance
|
||||
1
nexus/mnemosyne/tests/test_snapshot.py
Normal file
1
nexus/mnemosyne/tests/test_snapshot.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test snapshot
|
||||
117
style.css
117
style.css
@@ -410,6 +410,123 @@ canvas#nexus-canvas {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Atlas Controls */
|
||||
.atlas-controls {
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.atlas-search {
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
background: rgba(20, 30, 60, 0.6);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.atlas-search:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.atlas-search::placeholder {
|
||||
color: rgba(160, 184, 208, 0.4);
|
||||
}
|
||||
|
||||
.atlas-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.atlas-filter-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 12px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.atlas-filter-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.atlas-filter-btn.active {
|
||||
background: rgba(74, 240, 192, 0.15);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Enhanced Atlas Cards */
|
||||
.status-downloaded { background: rgba(255, 165, 0, 0.2); color: #ffa500; border: 1px solid #ffa500; }
|
||||
|
||||
.status-indicator.downloaded { background: #ffa500; box-shadow: 0 0 5px #ffa500; }
|
||||
|
||||
.atlas-card-category {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.atlas-card-readiness {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.readiness-step {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.readiness-step.done {
|
||||
background: var(--portal-color, var(--color-primary));
|
||||
}
|
||||
|
||||
.readiness-step[title] {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.atlas-card-action {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--portal-color, var(--color-primary));
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.atlas-total {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.atlas-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
||||
Reference in New Issue
Block a user