Compare commits
3 Commits
| 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 { MemoryPulse } from './nexus/components/memory-pulse.js';
|
||||
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
|
||||
import {
|
||||
createPortalRegistryWatcher,
|
||||
fetchPortalRegistry,
|
||||
} from './portal-registry.mjs';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
@@ -83,6 +87,7 @@ let workshopPanelCanvas = null;
|
||||
let workshopScanMat = null;
|
||||
let workshopPanelRefreshTimer = 0;
|
||||
let lastFocusedPortal = null;
|
||||
let portalRegistryWatcher = null;
|
||||
|
||||
// ═══ VISITOR / OPERATOR MODE ═══
|
||||
let uiMode = 'visitor'; // 'visitor' | 'operator'
|
||||
@@ -731,9 +736,25 @@ async function init() {
|
||||
|
||||
// Load Portals from Registry
|
||||
try {
|
||||
const response = await fetch('./portals.json');
|
||||
const portalData = await response.json();
|
||||
createPortals(portalData);
|
||||
const portalData = await fetchPortalRegistry({
|
||||
registryUrl: './portals.json',
|
||||
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) {
|
||||
console.error('Failed to load portals.json:', e);
|
||||
addChatMessage('error', 'Portal registry offline. Check logs.');
|
||||
@@ -1084,7 +1105,7 @@ function refreshWorkshopPanel() {
|
||||
ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120);
|
||||
|
||||
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.fillStyle = '#a0b8d0';
|
||||
@@ -1531,6 +1552,83 @@ function createVisionPoint(config) {
|
||||
}
|
||||
|
||||
// ═══ 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) {
|
||||
data.forEach(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",
|
||||
"params": {
|
||||
"mode": "creative"
|
||||
}
|
||||
},
|
||||
"action_label": "Enter Workshop"
|
||||
},
|
||||
"agents_present": [
|
||||
"timmy",
|
||||
"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",
|
||||
@@ -157,12 +166,21 @@
|
||||
"type": "harness",
|
||||
"params": {
|
||||
"mode": "read"
|
||||
}
|
||||
},
|
||||
"action_label": "Enter Archive"
|
||||
},
|
||||
"agents_present": [
|
||||
"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",
|
||||
@@ -184,10 +202,19 @@
|
||||
"type": "harness",
|
||||
"params": {
|
||||
"mode": "meditation"
|
||||
}
|
||||
},
|
||||
"action_label": "Enter Chapel"
|
||||
},
|
||||
"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",
|
||||
@@ -209,13 +236,22 @@
|
||||
"type": "harness",
|
||||
"params": {
|
||||
"mode": "social"
|
||||
}
|
||||
},
|
||||
"action_label": "Enter Courtyard"
|
||||
},
|
||||
"agents_present": [
|
||||
"timmy",
|
||||
"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",
|
||||
@@ -237,10 +273,19 @@
|
||||
"type": "harness",
|
||||
"params": {
|
||||
"mode": "transit"
|
||||
}
|
||||
},
|
||||
"action_label": "Open Gate"
|
||||
},
|
||||
"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",
|
||||
|
||||
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