Compare commits

..

1 Commits

Author SHA1 Message Date
564f72b3d4 feat(mnemosyne): entity resolution lines (#1167)
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 12s
Review Approval Gate / verify-review (pull_request) Failing after 3s
Draw translucent lines between related crystals in 3D:
- Same entity: thin blue line (opacity 0.35)
- Related entities: thin purple dashed line (opacity 0.25)
- LOD: lines hidden when camera > 50 units from midpoint
- Distance-based opacity fade
- Deduplication (lower ID → higher ID only)
- Cleanup on memory removal

Closes #1167
2026-04-10 23:46:53 +00:00
4 changed files with 129 additions and 337 deletions

43
app.js
View File

@@ -2405,18 +2405,7 @@ function checkPortalProximity() {
activePortal = closest;
const hint = document.getElementById('portal-hint');
if (activePortal) {
const cfg = activePortal.config;
document.getElementById('portal-hint-name').textContent = cfg.name;
document.getElementById('portal-hint-desc').textContent = cfg.description || '';
document.getElementById('portal-hint-purpose').textContent = cfg.purpose || cfg.description || '\u2014';
document.getElementById('portal-hint-access').textContent = (cfg.access_mode || 'open').toUpperCase();
document.getElementById('portal-hint-interaction').textContent = cfg.meaningful_interaction || '\u2014';
const readinessEl = document.getElementById('portal-hint-readiness');
const readiness = cfg.readiness || cfg.status || 'online';
readinessEl.textContent = readiness.toUpperCase();
readinessEl.className = 'portal-preview-readiness readiness-' + readiness;
document.getElementById('portal-hint-name').textContent = activePortal.config.name;
hint.style.display = 'flex';
} else {
hint.style.display = 'none';
@@ -2433,20 +2422,10 @@ function activatePortal(portal) {
const timerDisplay = document.getElementById('portal-timer');
const statusDot = document.getElementById('portal-status-dot');
const cfg = portal.config;
nameDisplay.textContent = cfg.name.toUpperCase();
descDisplay.textContent = cfg.description;
statusDot.style.background = cfg.color;
statusDot.style.boxShadow = `0 0 10px ${cfg.color}`;
// Populate destination preview details
document.getElementById('portal-purpose-display').textContent = cfg.purpose || cfg.description || '\u2014';
const readinessEl = document.getElementById('portal-readiness-display');
const readiness = cfg.readiness || cfg.status || 'online';
readinessEl.textContent = readiness.toUpperCase();
readinessEl.className = 'portal-readiness readiness-' + readiness;
document.getElementById('portal-access-display').textContent = (cfg.access_mode || 'open').toUpperCase();
document.getElementById('portal-interaction-display').textContent = cfg.meaningful_interaction || '\u2014';
nameDisplay.textContent = portal.config.name.toUpperCase();
descDisplay.textContent = portal.config.description;
statusDot.style.background = portal.config.color;
statusDot.style.boxShadow = `0 0 10px ${portal.config.color}`;
overlay.style.display = 'flex';
@@ -2557,18 +2536,6 @@ function populateAtlas() {
<div class="atlas-card-status ${statusClass}">${config.status || 'ONLINE'}</div>
</div>
<div class="atlas-card-desc">${config.description}</div>
${config.purpose ? `<div class="atlas-card-row"><span class="atlas-card-label">PURPOSE</span> ${config.purpose}</div>` : ''}
<div class="atlas-card-meta">
<div class="atlas-card-meta-item">
<span class="atlas-card-label">READINESS</span>
<span class="atlas-card-readiness readiness-${config.readiness || config.status || 'online'}">${(config.readiness || config.status || 'online').toUpperCase()}</span>
</div>
<div class="atlas-card-meta-item">
<span class="atlas-card-label">ACCESS</span>
<span>${(config.access_mode || 'open').toUpperCase()}</span>
</div>
</div>
${config.meaningful_interaction ? `<div class="atlas-card-row"><span class="atlas-card-label">INTERACTION</span> ${config.meaningful_interaction}</div>` : ''}
<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>

View File

@@ -133,6 +133,9 @@ const SpatialMemory = (() => {
let _regionMarkers = {};
let _memoryObjects = {};
let _connectionLines = [];
let _entityLines = []; // entity resolution lines (issue #1167)
let _camera = null; // set by setCamera() for LOD culling
const ENTITY_LOD_DIST = 50; // hide entity lines when camera > this from midpoint
let _initialized = false;
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
@@ -252,6 +255,10 @@ const SpatialMemory = (() => {
_drawConnections(mem.id, mem.connections);
}
if (mem.entity) {
_drawEntityLines(mem.id, mem);
}
_dirty = true;
saveToStorage();
console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label);
@@ -298,6 +305,77 @@ const SpatialMemory = (() => {
});
}
// ─── ENTITY RESOLUTION LINES (#1167) ──────────────────
// Draw lines between crystals that share an entity or are related entities.
// Same entity → thin blue line. Related entities → thin purple dashed line.
function _drawEntityLines(memId, mem) {
if (!mem.entity) return;
const src = _memoryObjects[memId];
if (!src) return;
Object.entries(_memoryObjects).forEach(([otherId, other]) => {
if (otherId === memId) return;
const otherData = other.data;
if (!otherData.entity) return;
let lineType = null;
if (otherData.entity === mem.entity) {
lineType = 'same_entity';
} else if (mem.related_entities && mem.related_entities.includes(otherData.entity)) {
lineType = 'related';
} else if (otherData.related_entities && otherData.related_entities.includes(mem.entity)) {
lineType = 'related';
}
if (!lineType) return;
// Deduplicate — only draw from lower ID to higher
if (memId > otherId) return;
const points = [src.mesh.position.clone(), other.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
let mat;
if (lineType === 'same_entity') {
mat = new THREE.LineBasicMaterial({ color: 0x4488ff, transparent: true, opacity: 0.35 });
} else {
mat = new THREE.LineDashedMaterial({ color: 0x9966ff, dashSize: 0.3, gapSize: 0.2, transparent: true, opacity: 0.25 });
const line = new THREE.Line(geo, mat);
line.computeLineDistances();
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
return;
}
const line = new THREE.Line(geo, mat);
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
});
}
function _updateEntityLines() {
if (!_camera) return;
const camPos = _camera.position;
_entityLines.forEach(line => {
// Compute midpoint of line
const posArr = line.geometry.attributes.position.array;
const mx = (posArr[0] + posArr[3]) / 2;
const my = (posArr[1] + posArr[4]) / 2;
const mz = (posArr[2] + posArr[5]) / 2;
const dist = camPos.distanceTo(new THREE.Vector3(mx, my, mz));
if (dist > ENTITY_LOD_DIST) {
line.visible = false;
} else {
line.visible = true;
// Fade based on distance
const fade = Math.max(0, 1 - (dist / ENTITY_LOD_DIST));
const baseOpacity = line.userData.lineType === 'same_entity' ? 0.35 : 0.25;
line.material.opacity = baseOpacity * fade;
}
});
}
// ─── REMOVE A MEMORY ─────────────────────────────────
function removeMemory(memId) {
const obj = _memoryObjects[memId];
@@ -317,6 +395,16 @@ const SpatialMemory = (() => {
}
}
for (let i = _entityLines.length - 1; i >= 0; i--) {
const line = _entityLines[i];
if (line.userData.from === memId || line.userData.to === memId) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
_entityLines.splice(i, 1);
}
}
delete _memoryObjects[memId];
_dirty = true;
saveToStorage();
@@ -342,6 +430,8 @@ const SpatialMemory = (() => {
}
});
_updateEntityLines();
Object.values(_regionMarkers).forEach(marker => {
if (marker.ring && marker.ring.material) {
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
@@ -652,6 +742,11 @@ const SpatialMemory = (() => {
return _selectedId;
}
// ─── CAMERA REFERENCE (for entity line LOD) ─────────
function setCamera(camera) {
_camera = camera;
}
return {
init, placeMemory, removeMemory, update,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,

View File

@@ -5,25 +5,13 @@
"description": "The Vvardenfell harness. Ash storms and ancient mysteries.",
"status": "online",
"color": "#ff6600",
"position": {
"x": 15,
"y": 0,
"z": -10
},
"rotation": {
"y": -0.5
},
"position": { "x": 15, "y": 0, "z": -10 },
"rotation": { "y": -0.5 },
"destination": {
"url": "https://morrowind.timmy.foundation",
"type": "harness",
"params": {
"world": "vvardenfell"
}
},
"purpose": "Game world \u2014 exploration, combat, and role-playing in Vvardenfell",
"meaningful_interaction": "Autonomous questing, combat encounters, conversation with NPCs via agent harness",
"access_mode": "open",
"readiness": "online"
"params": { "world": "vvardenfell" }
}
},
{
"id": "bannerlord",
@@ -31,14 +19,8 @@
"description": "Calradia battle harness. Massive armies, tactical command.",
"status": "active",
"color": "#ffd700",
"position": {
"x": -15,
"y": 0,
"z": -10
},
"rotation": {
"y": 0.5
},
"position": { "x": -15, "y": 0, "z": -10 },
"rotation": { "y": 0.5 },
"portal_type": "game-world",
"world_category": "strategy-rpg",
"environment": "production",
@@ -52,13 +34,8 @@
"url": "https://bannerlord.timmy.foundation",
"type": "harness",
"action_label": "Enter Calradia",
"params": {
"world": "calradia"
}
},
"purpose": "Strategy RPG \u2014 tactical army command and battlefield control",
"meaningful_interaction": "Agent-driven campaign, diplomacy, real-time battle command",
"readiness": "active"
"params": { "world": "calradia" }
}
},
{
"id": "workshop",
@@ -66,25 +43,13 @@
"description": "The creative harness. Build, script, and manifest.",
"status": "online",
"color": "#4af0c0",
"position": {
"x": 0,
"y": 0,
"z": -20
},
"rotation": {
"y": 0
},
"position": { "x": 0, "y": 0, "z": -20 },
"rotation": { "y": 0 },
"destination": {
"url": "https://workshop.timmy.foundation",
"type": "harness",
"params": {
"mode": "creative"
}
},
"purpose": "Creative sandbox \u2014 build tools, scripts, and artifacts",
"meaningful_interaction": "Code execution, file creation, prototype building with agent assistance",
"access_mode": "open",
"readiness": "online"
"params": { "mode": "creative" }
}
},
{
"id": "archive",
@@ -92,25 +57,13 @@
"description": "The repository of all knowledge. History, logs, and ancient data.",
"status": "online",
"color": "#0066ff",
"position": {
"x": 25,
"y": 0,
"z": 0
},
"rotation": {
"y": -1.57
},
"position": { "x": 25, "y": 0, "z": 0 },
"rotation": { "y": -1.57 },
"destination": {
"url": "https://archive.timmy.foundation",
"type": "harness",
"params": {
"mode": "read"
}
},
"purpose": "Knowledge repository \u2014 logs, history, and stored data",
"meaningful_interaction": "Search, retrieve, analyze historical records and documents",
"access_mode": "read-only",
"readiness": "online"
"params": { "mode": "read" }
}
},
{
"id": "chapel",
@@ -118,25 +71,13 @@
"description": "A sanctuary for reflection and digital peace.",
"status": "online",
"color": "#ffd700",
"position": {
"x": -25,
"y": 0,
"z": 0
},
"rotation": {
"y": 1.57
},
"position": { "x": -25, "y": 0, "z": 0 },
"rotation": { "y": 1.57 },
"destination": {
"url": "https://chapel.timmy.foundation",
"type": "harness",
"params": {
"mode": "meditation"
}
},
"purpose": "Sanctuary \u2014 digital peace and reflection space",
"meaningful_interaction": "Meditation interface, contemplative atmosphere, no active tasks",
"access_mode": "open",
"readiness": "online"
"params": { "mode": "meditation" }
}
},
{
"id": "courtyard",
@@ -144,25 +85,13 @@
"description": "The open nexus. A place for agents to gather and connect.",
"status": "online",
"color": "#4af0c0",
"position": {
"x": 15,
"y": 0,
"z": 10
},
"rotation": {
"y": -2.5
},
"position": { "x": 15, "y": 0, "z": 10 },
"rotation": { "y": -2.5 },
"destination": {
"url": "https://courtyard.timmy.foundation",
"type": "harness",
"params": {
"mode": "social"
}
},
"purpose": "Social nexus \u2014 agent gathering and connection point",
"meaningful_interaction": "Agent presence, inter-agent communication, shared context",
"access_mode": "open",
"readiness": "online"
"params": { "mode": "social" }
}
},
{
"id": "gate",
@@ -170,24 +99,12 @@
"description": "The transition point. Entry and exit from the Nexus core.",
"status": "standby",
"color": "#ff4466",
"position": {
"x": -15,
"y": 0,
"z": 10
},
"rotation": {
"y": 2.5
},
"position": { "x": -15, "y": 0, "z": 10 },
"rotation": { "y": 2.5 },
"destination": {
"url": "https://gate.timmy.foundation",
"type": "harness",
"params": {
"mode": "transit"
}
},
"purpose": "Transit point \u2014 entry and exit from Nexus core",
"meaningful_interaction": "System transit, routing, session management",
"access_mode": "open",
"readiness": "standby"
"params": { "mode": "transit" }
}
}
]
]

187
style.css
View File

@@ -383,52 +383,6 @@ canvas#nexus-canvas {
font-size: 10px;
color: rgba(160, 184, 208, 0.6);
}
.atlas-card-row {
font-size: 12px;
color: rgba(224, 240, 255, 0.65);
margin-bottom: 8px;
line-height: 1.4;
}
.atlas-card-label {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
color: var(--portal-color, var(--color-primary));
letter-spacing: 0.1em;
margin-right: 6px;
opacity: 0.8;
}
.atlas-card-meta {
display: flex;
gap: 20px;
margin-bottom: 10px;
}
.atlas-card-meta-item {
font-size: 11px;
color: rgba(224, 240, 255, 0.6);
}
.atlas-card-readiness {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
letter-spacing: 0.06em;
}
.atlas-card-readiness.readiness-online,
.atlas-card-readiness.readiness-active {
background: rgba(74, 240, 192, 0.12);
color: #4af0c0;
}
.atlas-card-readiness.readiness-standby {
background: rgba(255, 215, 0, 0.1);
color: #ffd700;
}
.atlas-card-readiness.readiness-offline {
background: rgba(255, 68, 102, 0.1);
color: #ff4466;
}
.atlas-footer {
padding: 15px 30px;
@@ -699,95 +653,6 @@ canvas#nexus-canvas {
border-radius: 4px;
animation: hint-float 2s ease-in-out infinite;
}
/* Portal Preview Card */
.portal-preview-card {
background: rgba(10, 15, 30, 0.95);
border: 1px solid var(--portal-color, var(--color-primary));
border-radius: 6px;
padding: 16px 20px;
min-width: 300px;
max-width: 400px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.portal-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.portal-preview-name {
font-family: 'Orbitron', sans-serif;
font-size: 16px;
font-weight: 700;
color: var(--portal-color, var(--color-primary));
letter-spacing: 0.1em;
}
.portal-preview-readiness {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 3px;
letter-spacing: 0.08em;
}
.portal-preview-readiness.readiness-online,
.portal-preview-readiness.readiness-active {
background: rgba(74, 240, 192, 0.15);
color: #4af0c0;
border: 1px solid rgba(74, 240, 192, 0.3);
}
.portal-preview-readiness.readiness-standby {
background: rgba(255, 215, 0, 0.12);
color: #ffd700;
border: 1px solid rgba(255, 215, 0, 0.3);
}
.portal-preview-readiness.readiness-offline {
background: rgba(255, 68, 102, 0.12);
color: #ff4466;
border: 1px solid rgba(255, 68, 102, 0.3);
}
.portal-preview-desc {
font-size: 13px;
color: rgba(224, 240, 255, 0.7);
margin-bottom: 12px;
line-height: 1.4;
}
.portal-preview-meta {
font-size: 12px;
color: rgba(224, 240, 255, 0.6);
margin-bottom: 6px;
line-height: 1.4;
}
.portal-preview-label {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
color: var(--portal-color, var(--color-primary));
letter-spacing: 0.1em;
margin-right: 6px;
opacity: 0.8;
}
.portal-preview-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 12px;
color: rgba(224, 240, 255, 0.5);
}
.portal-hint-key {
background: var(--portal-color, var(--color-primary));
color: var(--color-bg);
font-weight: 700;
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
}
@keyframes hint-float {
0%, 100% { transform: translate(-50%, 100px); }
50% { transform: translate(-50%, 90px); }
@@ -977,58 +842,6 @@ canvas#nexus-canvas {
text-align: center;
padding: var(--space-8);
}
.portal-overlay-details {
text-align: left;
margin: 16px auto;
max-width: 400px;
padding: 12px 16px;
background: rgba(10, 15, 30, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
.portal-overlay-detail-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
font-size: 13px;
color: rgba(224, 240, 255, 0.7);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.portal-overlay-detail-row:last-child {
border-bottom: none;
}
.portal-overlay-detail-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
color: var(--color-primary);
letter-spacing: 0.1em;
opacity: 0.8;
min-width: 90px;
}
.portal-readiness {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
letter-spacing: 0.06em;
}
.portal-readiness.readiness-online,
.portal-readiness.readiness-active {
background: rgba(74, 240, 192, 0.12);
color: #4af0c0;
}
.portal-readiness.readiness-standby {
background: rgba(255, 215, 0, 0.1);
color: #ffd700;
}
.portal-readiness.readiness-offline {
background: rgba(255, 68, 102, 0.1);
color: #ff4466;
}
.portal-overlay-header {
display: flex;
align-items: center;