Compare commits
3 Commits
mimo/code/
...
fix/1536
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aca3bef4a | ||
|
|
eccba8e573 | ||
|
|
7baa37279b |
106
app.js
106
app.js
@@ -10,6 +10,10 @@ import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
|||||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||||
import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
||||||
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
|
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
|
||||||
|
import {
|
||||||
|
createPortalRegistryWatcher,
|
||||||
|
fetchPortalRegistry,
|
||||||
|
} from './portal-registry.mjs';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// NEXUS v1.1 — Portal System Update
|
// NEXUS v1.1 — Portal System Update
|
||||||
@@ -83,6 +87,7 @@ let workshopPanelCanvas = null;
|
|||||||
let workshopScanMat = null;
|
let workshopScanMat = null;
|
||||||
let workshopPanelRefreshTimer = 0;
|
let workshopPanelRefreshTimer = 0;
|
||||||
let lastFocusedPortal = null;
|
let lastFocusedPortal = null;
|
||||||
|
let portalRegistryWatcher = null;
|
||||||
|
|
||||||
// ═══ VISITOR / OPERATOR MODE ═══
|
// ═══ VISITOR / OPERATOR MODE ═══
|
||||||
let uiMode = 'visitor'; // 'visitor' | 'operator'
|
let uiMode = 'visitor'; // 'visitor' | 'operator'
|
||||||
@@ -731,9 +736,25 @@ async function init() {
|
|||||||
|
|
||||||
// Load Portals from Registry
|
// Load Portals from Registry
|
||||||
try {
|
try {
|
||||||
const response = await fetch('./portals.json');
|
const portalData = await fetchPortalRegistry({
|
||||||
const portalData = await response.json();
|
registryUrl: './portals.json',
|
||||||
createPortals(portalData);
|
cacheBustParam: '_registry_ts',
|
||||||
|
});
|
||||||
|
applyPortalRegistry(portalData);
|
||||||
|
|
||||||
|
portalRegistryWatcher?.stop?.();
|
||||||
|
portalRegistryWatcher = createPortalRegistryWatcher({
|
||||||
|
loadRegistry: () => fetchPortalRegistry({
|
||||||
|
registryUrl: './portals.json',
|
||||||
|
cacheBustParam: '_registry_ts',
|
||||||
|
}),
|
||||||
|
applyRegistry: (nextRegistry) => applyPortalRegistry(nextRegistry, { announce: true }),
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to hot-reload portals.json:', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await portalRegistryWatcher.prime(portalData);
|
||||||
|
portalRegistryWatcher.start();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load portals.json:', e);
|
console.error('Failed to load portals.json:', e);
|
||||||
addChatMessage('error', 'Portal registry offline. Check logs.');
|
addChatMessage('error', 'Portal registry offline. Check logs.');
|
||||||
@@ -1084,7 +1105,7 @@ function refreshWorkshopPanel() {
|
|||||||
ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120);
|
ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120);
|
||||||
|
|
||||||
ctx.fillStyle = '#7b5cff';
|
ctx.fillStyle = '#7b5cff';
|
||||||
const contextName = activePortal ? activePortal.name.toUpperCase() : 'NEXUS CORE';
|
const contextName = activePortal ? activePortal.config.name.toUpperCase() : 'NEXUS CORE';
|
||||||
ctx.fillText(`CONTEXT: ${contextName}`, 40, 160);
|
ctx.fillText(`CONTEXT: ${contextName}`, 40, 160);
|
||||||
|
|
||||||
ctx.fillStyle = '#a0b8d0';
|
ctx.fillStyle = '#a0b8d0';
|
||||||
@@ -1531,6 +1552,83 @@ function createVisionPoint(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══ PORTAL SYSTEM ═══
|
// ═══ PORTAL SYSTEM ═══
|
||||||
|
function disposeMaterial(material) {
|
||||||
|
if (!material) return;
|
||||||
|
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
material.forEach(disposeMaterial);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
'map',
|
||||||
|
'alphaMap',
|
||||||
|
'aoMap',
|
||||||
|
'bumpMap',
|
||||||
|
'displacementMap',
|
||||||
|
'emissiveMap',
|
||||||
|
'envMap',
|
||||||
|
'lightMap',
|
||||||
|
'metalnessMap',
|
||||||
|
'normalMap',
|
||||||
|
'roughnessMap',
|
||||||
|
'specularMap',
|
||||||
|
].forEach((key) => {
|
||||||
|
material[key]?.dispose?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (material.uniforms) {
|
||||||
|
Object.values(material.uniforms).forEach((uniform) => {
|
||||||
|
uniform?.value?.dispose?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
material.dispose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeObject3D(root) {
|
||||||
|
if (!root?.traverse) return;
|
||||||
|
|
||||||
|
root.traverse((node) => {
|
||||||
|
node.geometry?.dispose?.();
|
||||||
|
disposeMaterial(node.material);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPortals() {
|
||||||
|
portals.forEach((portal) => {
|
||||||
|
scene?.remove?.(portal.group);
|
||||||
|
disposeObject3D(portal.group);
|
||||||
|
portal.group?.clear?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
portals = [];
|
||||||
|
activePortal = null;
|
||||||
|
lastFocusedPortal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPortalRegistry(data, { announce = false } = {}) {
|
||||||
|
const previousActivePortalId = activePortal?.config?.id || null;
|
||||||
|
|
||||||
|
clearPortals();
|
||||||
|
createPortals(data);
|
||||||
|
|
||||||
|
if (previousActivePortalId) {
|
||||||
|
activePortal = portals.find((portal) => portal.config.id === previousActivePortalId) || null;
|
||||||
|
if (!activePortal && portalOverlayActive) {
|
||||||
|
closePortalOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atlasOverlayActive) populateAtlas();
|
||||||
|
checkPortalProximity();
|
||||||
|
refreshWorkshopPanel();
|
||||||
|
|
||||||
|
if (announce) {
|
||||||
|
addChatMessage('system', `Portal registry hot-reloaded (${data.length} portals).`, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createPortals(data) {
|
function createPortals(data) {
|
||||||
data.forEach(config => {
|
data.forEach(config => {
|
||||||
const portal = createPortal(config);
|
const portal = createPortal(config);
|
||||||
|
|||||||
123
portal-registry.mjs
Normal file
123
portal-registry.mjs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
export const DEFAULT_PORTAL_REGISTRY_POLL_MS = 5000;
|
||||||
|
export const DEFAULT_PORTAL_REGISTRY_CACHE_BUST_PARAM = '_registry_ts';
|
||||||
|
|
||||||
|
export function getPortalRegistrySignature(registry) {
|
||||||
|
return JSON.stringify(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPortalRegistryRequestUrl(
|
||||||
|
registryUrl = './portals.json',
|
||||||
|
cacheBustValue = Date.now(),
|
||||||
|
baseHref = typeof window !== 'undefined' && window.location ? window.location.href : 'http://localhost/',
|
||||||
|
cacheBustParam = DEFAULT_PORTAL_REGISTRY_CACHE_BUST_PARAM,
|
||||||
|
) {
|
||||||
|
const url = new URL(registryUrl, baseHref);
|
||||||
|
if (cacheBustValue !== null && cacheBustValue !== undefined) {
|
||||||
|
url.searchParams.set(cacheBustParam, String(cacheBustValue));
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPortalRegistry({
|
||||||
|
fetchImpl = fetch,
|
||||||
|
registryUrl = './portals.json',
|
||||||
|
baseHref = typeof window !== 'undefined' && window.location ? window.location.href : 'http://localhost/',
|
||||||
|
cacheBustValue = Date.now(),
|
||||||
|
cacheBustParam = DEFAULT_PORTAL_REGISTRY_CACHE_BUST_PARAM,
|
||||||
|
} = {}) {
|
||||||
|
const requestUrl = buildPortalRegistryRequestUrl(
|
||||||
|
registryUrl,
|
||||||
|
cacheBustValue,
|
||||||
|
baseHref,
|
||||||
|
cacheBustParam,
|
||||||
|
);
|
||||||
|
const response = await fetchImpl(requestUrl, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load ${registryUrl}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = await response.json();
|
||||||
|
if (!Array.isArray(registry)) {
|
||||||
|
throw new Error(`${registryUrl} must be a JSON array`);
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPortalRegistryWatcher({
|
||||||
|
loadRegistry,
|
||||||
|
applyRegistry,
|
||||||
|
onError = (error) => console.error('Portal registry watch failed:', error),
|
||||||
|
intervalMs = DEFAULT_PORTAL_REGISTRY_POLL_MS,
|
||||||
|
setIntervalImpl = setInterval,
|
||||||
|
clearIntervalImpl = clearInterval,
|
||||||
|
signatureFn = getPortalRegistrySignature,
|
||||||
|
} = {}) {
|
||||||
|
if (typeof loadRegistry !== 'function') {
|
||||||
|
throw new TypeError('loadRegistry must be a function');
|
||||||
|
}
|
||||||
|
if (typeof applyRegistry !== 'function') {
|
||||||
|
throw new TypeError('applyRegistry must be a function');
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervalId = null;
|
||||||
|
let lastSignature = null;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
const nextSignature = signatureFn(registry);
|
||||||
|
if (nextSignature === lastSignature) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
registry,
|
||||||
|
previousSignature: lastSignature,
|
||||||
|
nextSignature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousSignature = lastSignature;
|
||||||
|
lastSignature = nextSignature;
|
||||||
|
applyRegistry(registry, { previousSignature, nextSignature });
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
registry,
|
||||||
|
previousSignature,
|
||||||
|
nextSignature,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
onError(error);
|
||||||
|
return { changed: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async prime(registry) {
|
||||||
|
lastSignature = signatureFn(registry);
|
||||||
|
return lastSignature;
|
||||||
|
},
|
||||||
|
getLastSignature() {
|
||||||
|
return lastSignature;
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
return refresh();
|
||||||
|
},
|
||||||
|
start() {
|
||||||
|
if (intervalId !== null) {
|
||||||
|
return intervalId;
|
||||||
|
}
|
||||||
|
intervalId = setIntervalImpl(() => {
|
||||||
|
void refresh();
|
||||||
|
}, intervalMs);
|
||||||
|
return intervalId;
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (intervalId !== null) {
|
||||||
|
clearIntervalImpl(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
65
portals.json
65
portals.json
@@ -129,13 +129,22 @@
|
|||||||
"type": "harness",
|
"type": "harness",
|
||||||
"params": {
|
"params": {
|
||||||
"mode": "creative"
|
"mode": "creative"
|
||||||
}
|
},
|
||||||
|
"action_label": "Enter Workshop"
|
||||||
},
|
},
|
||||||
"agents_present": [
|
"agents_present": [
|
||||||
"timmy",
|
"timmy",
|
||||||
"kimi"
|
"kimi"
|
||||||
],
|
],
|
||||||
"interaction_ready": true
|
"interaction_ready": true,
|
||||||
|
"portal_type": "harness",
|
||||||
|
"world_category": "creative",
|
||||||
|
"environment": "production",
|
||||||
|
"access_mode": "visitor",
|
||||||
|
"readiness_state": "online",
|
||||||
|
"telemetry_source": "workshop.timmy.foundation",
|
||||||
|
"owner": "Timmy",
|
||||||
|
"blocked_reason": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "archive",
|
"id": "archive",
|
||||||
@@ -157,12 +166,21 @@
|
|||||||
"type": "harness",
|
"type": "harness",
|
||||||
"params": {
|
"params": {
|
||||||
"mode": "read"
|
"mode": "read"
|
||||||
}
|
},
|
||||||
|
"action_label": "Enter Archive"
|
||||||
},
|
},
|
||||||
"agents_present": [
|
"agents_present": [
|
||||||
"claude"
|
"claude"
|
||||||
],
|
],
|
||||||
"interaction_ready": true
|
"interaction_ready": true,
|
||||||
|
"portal_type": "harness",
|
||||||
|
"world_category": "knowledge",
|
||||||
|
"environment": "production",
|
||||||
|
"access_mode": "visitor",
|
||||||
|
"readiness_state": "online",
|
||||||
|
"telemetry_source": "archive.timmy.foundation",
|
||||||
|
"owner": "Timmy",
|
||||||
|
"blocked_reason": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "chapel",
|
"id": "chapel",
|
||||||
@@ -184,10 +202,19 @@
|
|||||||
"type": "harness",
|
"type": "harness",
|
||||||
"params": {
|
"params": {
|
||||||
"mode": "meditation"
|
"mode": "meditation"
|
||||||
}
|
},
|
||||||
|
"action_label": "Enter Chapel"
|
||||||
},
|
},
|
||||||
"agents_present": [],
|
"agents_present": [],
|
||||||
"interaction_ready": true
|
"interaction_ready": true,
|
||||||
|
"portal_type": "harness",
|
||||||
|
"world_category": "reflection",
|
||||||
|
"environment": "production",
|
||||||
|
"access_mode": "visitor",
|
||||||
|
"readiness_state": "online",
|
||||||
|
"telemetry_source": "chapel.timmy.foundation",
|
||||||
|
"owner": "Timmy",
|
||||||
|
"blocked_reason": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "courtyard",
|
"id": "courtyard",
|
||||||
@@ -209,13 +236,22 @@
|
|||||||
"type": "harness",
|
"type": "harness",
|
||||||
"params": {
|
"params": {
|
||||||
"mode": "social"
|
"mode": "social"
|
||||||
}
|
},
|
||||||
|
"action_label": "Enter Courtyard"
|
||||||
},
|
},
|
||||||
"agents_present": [
|
"agents_present": [
|
||||||
"timmy",
|
"timmy",
|
||||||
"perplexity"
|
"perplexity"
|
||||||
],
|
],
|
||||||
"interaction_ready": true
|
"interaction_ready": true,
|
||||||
|
"portal_type": "harness",
|
||||||
|
"world_category": "social",
|
||||||
|
"environment": "production",
|
||||||
|
"access_mode": "visitor",
|
||||||
|
"readiness_state": "online",
|
||||||
|
"telemetry_source": "courtyard.timmy.foundation",
|
||||||
|
"owner": "Timmy",
|
||||||
|
"blocked_reason": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gate",
|
"id": "gate",
|
||||||
@@ -237,10 +273,19 @@
|
|||||||
"type": "harness",
|
"type": "harness",
|
||||||
"params": {
|
"params": {
|
||||||
"mode": "transit"
|
"mode": "transit"
|
||||||
}
|
},
|
||||||
|
"action_label": "Open Gate"
|
||||||
},
|
},
|
||||||
"agents_present": [],
|
"agents_present": [],
|
||||||
"interaction_ready": false
|
"interaction_ready": false,
|
||||||
|
"portal_type": "harness",
|
||||||
|
"world_category": "transit",
|
||||||
|
"environment": "production",
|
||||||
|
"access_mode": "visitor",
|
||||||
|
"readiness_state": "standby",
|
||||||
|
"telemetry_source": "gate.timmy.foundation",
|
||||||
|
"owner": "Timmy",
|
||||||
|
"blocked_reason": "Transit gate staged but not interaction ready."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "playground",
|
"id": "playground",
|
||||||
|
|||||||
71
tests/portal-registry.test.mjs
Normal file
71
tests/portal-registry.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
buildPortalRegistryRequestUrl,
|
||||||
|
createPortalRegistryWatcher,
|
||||||
|
getPortalRegistrySignature,
|
||||||
|
} from '../portal-registry.mjs';
|
||||||
|
|
||||||
|
test('buildPortalRegistryRequestUrl appends a cache-busting query without dropping existing params', () => {
|
||||||
|
const url = buildPortalRegistryRequestUrl('./portals.json?mode=atlas', 42, 'https://nexus.test/world/index.html');
|
||||||
|
assert.equal(
|
||||||
|
url,
|
||||||
|
'https://nexus.test/world/portals.json?mode=atlas&_registry_ts=42'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portal registry watcher only reapplies the world when portals.json actually changes', async () => {
|
||||||
|
const applied = [];
|
||||||
|
const snapshots = [
|
||||||
|
[{ id: 'forge', status: 'online' }],
|
||||||
|
[{ id: 'forge', status: 'online' }, { id: 'archive', status: 'online' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
let tick;
|
||||||
|
const started = [];
|
||||||
|
const cleared = [];
|
||||||
|
|
||||||
|
const watcher = createPortalRegistryWatcher({
|
||||||
|
intervalMs: 2500,
|
||||||
|
loadRegistry: async () => {
|
||||||
|
const next = snapshots.shift();
|
||||||
|
if (!next) throw new Error('no more snapshots');
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
applyRegistry: (registry, meta) => {
|
||||||
|
applied.push({ ids: registry.map((portal) => portal.id), meta });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
setIntervalImpl: (fn, ms) => {
|
||||||
|
tick = fn;
|
||||||
|
started.push(ms);
|
||||||
|
return 99;
|
||||||
|
},
|
||||||
|
clearIntervalImpl: (id) => {
|
||||||
|
cleared.push(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await watcher.prime([{ id: 'forge', status: 'online' }]);
|
||||||
|
assert.equal(
|
||||||
|
watcher.getLastSignature(),
|
||||||
|
getPortalRegistrySignature([{ id: 'forge', status: 'online' }])
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.start();
|
||||||
|
assert.deepEqual(started, [2500]);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
assert.equal(applied.length, 0, 'same registry should not trigger a rebuild');
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
assert.equal(applied.length, 1, 'changed registry should trigger one rebuild');
|
||||||
|
assert.deepEqual(applied[0].ids, ['forge', 'archive']);
|
||||||
|
assert.match(applied[0].meta.previousSignature, /forge/);
|
||||||
|
assert.match(applied[0].meta.nextSignature, /archive/);
|
||||||
|
|
||||||
|
watcher.stop();
|
||||||
|
assert.deepEqual(cleared, [99]);
|
||||||
|
});
|
||||||
14
tests/test_portal_hot_reload_source.py
Normal file
14
tests/test_portal_hot_reload_source.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_JS = Path("app.js")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_wires_portal_registry_hot_reload_loop() -> None:
|
||||||
|
source = APP_JS.read_text()
|
||||||
|
|
||||||
|
assert "createPortalRegistryWatcher" in source
|
||||||
|
assert "fetchPortalRegistry" in source
|
||||||
|
assert "applyPortalRegistry(" in source
|
||||||
|
assert "portalRegistryWatcher.start()" in source
|
||||||
|
assert "_registry_ts" in source
|
||||||
Reference in New Issue
Block a user