diff --git a/artifacts/api-server/src/lib/event-bus.ts b/artifacts/api-server/src/lib/event-bus.ts index f6af56f..8f9ffe8 100644 --- a/artifacts/api-server/src/lib/event-bus.ts +++ b/artifacts/api-server/src/lib/event-bus.ts @@ -21,7 +21,11 @@ export type CostEvent = export type CommentaryEvent = | { type: "agent_commentary"; agentId: string; jobId: string; text: string }; -export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent; +// External agent state changes (e.g. Kimi, Perplexity picking up or completing tasks) +export type AgentExternalEvent = + | { type: "agent:external_state"; agentId: string; state: string; taskSummary?: string }; + +export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent | AgentExternalEvent; class EventBus extends EventEmitter { emit(event: "bus", data: BusEvent): boolean; diff --git a/artifacts/api-server/src/lib/world-state.ts b/artifacts/api-server/src/lib/world-state.ts index 5e869bc..606e5de 100644 --- a/artifacts/api-server/src/lib/world-state.ts +++ b/artifacts/api-server/src/lib/world-state.ts @@ -16,7 +16,7 @@ const DEFAULT_TIMMY: TimmyState = { const _state: WorldState = { timmyState: { ...DEFAULT_TIMMY }, - agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" }, + agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle", kimi: "idle", perplexity: "idle" }, updatedAt: new Date().toISOString(), }; @@ -34,8 +34,10 @@ export function setAgentStateInWorld(agentId: string, agentState: string): void _deriveTimmy(); } +const WORKSHOP_AGENTS = ["alpha", "beta", "gamma", "delta"]; + function _deriveTimmy(): void { - const states = Object.values(_state.agentStates); + const states = WORKSHOP_AGENTS.map(id => _state.agentStates[id] ?? "idle"); if (states.includes("working")) { _state.timmyState.activity = "working"; _state.timmyState.mood = "focused"; diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index f644ded..47841a8 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -269,6 +269,21 @@ function translateEvent(ev: BusEvent): object | null { text: ev.text, }; + // ── External agent state (Kimi, Perplexity) (#11) ───────────────────────── + case "agent:external_state": { + updateAgentWorld(ev.agentId, ev.state); + void logWorldEvent( + `agent:${ev.state}`, + `${ev.agentId} is now ${ev.state}${ev.taskSummary ? `: ${ev.taskSummary.slice(0, 80)}` : ""}`, + ev.agentId, + ); + const msgs: object[] = [{ type: "agent_state", agentId: ev.agentId, state: ev.state }]; + if (ev.taskSummary) { + msgs.push({ type: "agent_task_summary", agentId: ev.agentId, summary: ev.taskSummary }); + } + return msgs; + } + default: return null; } diff --git a/the-matrix/js/agent-defs.js b/the-matrix/js/agent-defs.js index d5572a3..b4c94b8 100644 --- a/the-matrix/js/agent-defs.js +++ b/the-matrix/js/agent-defs.js @@ -5,18 +5,27 @@ * unused (x, z) position. No other file needs to be edited. * * Fields: - * id — unique string key used in WebSocket messages and state maps - * label — display name shown in the 3D HUD and chat panel - * color — hex integer (0xRRGGBB) used for Three.js materials and lights - * role — human-readable role string shown under the label sprite - * direction — cardinal facing direction (for future mesh orientation use) - * x, z — world-space position on the horizontal plane (y is always 0) + * id — unique string key used in WebSocket messages and state maps + * label — display name shown in the 3D HUD and chat panel + * color — hex integer (0xRRGGBB) used for Three.js materials and lights + * role — human-readable role string shown under the label sprite + * specialization — optional capability description shown in agent inspect card + * direction — cardinal facing direction (for future mesh orientation use) + * x, z — world-space position on the horizontal plane (y is always 0) */ export const AGENT_DEFS = [ { id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 }, { id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 }, { id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 }, { id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 }, + { + id: 'kimi', label: 'KIMI', color: 0x00d4ff, role: 'analyst', + specialization: 'Long Context Analysis', direction: 'northwest', x: -10, z: -10, + }, + { + id: 'perplexity', label: 'PERPLEXITY', color: 0xff6b9d, role: 'researcher', + specialization: 'Real-time Research', direction: 'northeast', x: 10, z: -10, + }, ]; /** diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index d5c1721..42e6ca0 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -7,10 +7,13 @@ const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1); const agentStates = Object.fromEntries(AGENT_DEFS.map(d => [d.id, 'idle'])); +// Workshop agents that drive Timmy's mood (excludes external agents Kimi/Perplexity) +const WORKSHOP_AGENT_IDS = ['alpha', 'beta', 'gamma', 'delta']; + function deriveTimmyState() { if (agentStates.gamma === 'working') return 'working'; if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking'; - if (Object.values(agentStates).some(s => s !== 'idle')) return 'active'; + if (WORKSHOP_AGENT_IDS.some(id => agentStates[id] !== 'idle')) return 'active'; return 'idle'; } @@ -97,9 +100,108 @@ function _pickMouthGeo(smileAmount) { // ── Build Timmy ─────────────────────────────────────────────────────────────── +// ── External agent bodies (Kimi, Perplexity) ────────────────────────────────── +const _extBodies = {}; + export function initAgents(sceneRef) { scene = sceneRef; timmy = buildTimmy(scene); + _initKimiBody(scene); + _initPerplexityBody(scene); +} + +function _initKimiBody(sc) { + const group = new THREE.Group(); + group.position.set(-10, 1.2, -10); + + const mat = new THREE.MeshStandardMaterial({ + color: 0x00d4ff, emissive: 0x004466, emissiveIntensity: 0.4, + roughness: 0.15, metalness: 0.4, + }); + const core = new THREE.Mesh(new THREE.OctahedronGeometry(0.38, 0), mat); + group.add(core); + + const ringMat = new THREE.MeshStandardMaterial({ + color: 0x00d4ff, emissive: 0x0088aa, emissiveIntensity: 0.6, + roughness: 0.1, metalness: 0.6, transparent: true, opacity: 0.7, + }); + const ring1 = new THREE.Mesh(new THREE.TorusGeometry(0.60, 0.025, 6, 32), ringMat); + ring1.rotation.x = Math.PI / 3; + group.add(ring1); + + const ring2 = new THREE.Mesh(new THREE.TorusGeometry(0.76, 0.018, 6, 32), ringMat.clone()); + ring2.rotation.x = Math.PI / 2; + ring2.rotation.z = Math.PI / 4; + group.add(ring2); + + const light = new THREE.PointLight(0x00d4ff, 0.5, 8); + group.add(light); + + sc.add(group); + _extBodies.kimi = { group, core, ring1, ring2, light, mat, pulsePhase: Math.random() * Math.PI * 2 }; +} + +function _initPerplexityBody(sc) { + const group = new THREE.Group(); + group.position.set(10, 1.2, -10); + + const mat = new THREE.MeshStandardMaterial({ + color: 0xff6b9d, emissive: 0x660033, emissiveIntensity: 0.4, + roughness: 0.2, metalness: 0.3, + }); + const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.32, 0), mat); + group.add(core); + + const scanMat = new THREE.MeshStandardMaterial({ + color: 0xff6b9d, emissive: 0xaa2255, emissiveIntensity: 0.7, + roughness: 0.1, metalness: 0.5, transparent: true, opacity: 0.65, + }); + const scanRings = [0, Math.PI / 3, -Math.PI / 3].map(angle => { + const r = new THREE.Mesh(new THREE.TorusGeometry(0.55, 0.022, 6, 28), scanMat.clone()); + r.rotation.x = Math.PI / 2 + angle; + r.rotation.z = angle * 0.5; + group.add(r); + return r; + }); + + const light = new THREE.PointLight(0xff6b9d, 0.5, 8); + group.add(light); + + sc.add(group); + _extBodies.perplexity = { group, core, scanRings, light, mat, pulsePhase: Math.random() * Math.PI * 2 }; +} + +function _updateExtBodies(t) { + _updateExtBody('kimi', t); + _updateExtBody('perplexity', t); +} + +function _updateExtBody(id, t) { + const body = _extBodies[id]; + if (!body) return; + const state = agentStates[id] || 'idle'; + const isActive = state === 'working' || state === 'active'; + const isThinking = state === 'thinking'; + + const speedMult = isActive ? 2.5 : isThinking ? 1.5 : 0.6; + const emissI = isActive ? 1.2 : isThinking ? 0.7 : 0.25; + const lightI = isActive ? 1.2 : isThinking ? 0.6 : 0.2; + const bobAmp = isActive ? 0.10 : 0.04; + + body.group.position.y = 1.2 + Math.sin(t * 0.0008 + body.pulsePhase) * bobAmp; + body.mat.emissiveIntensity = emissI; + body.light.intensity = lightI; + + if (id === 'kimi') { + body.core.rotation.y += 0.008 * speedMult; + body.core.rotation.x += 0.003 * speedMult; + body.ring1.rotation.z += 0.012 * speedMult; + body.ring2.rotation.x += 0.007 * speedMult; + } else { + body.core.rotation.y += 0.006 * speedMult; + body.core.rotation.z += 0.009 * speedMult; + body.scanRings.forEach((r, i) => { r.rotation.y += (0.015 + i * 0.008) * speedMult; }); + } } function buildTimmy(sc) { @@ -417,6 +519,7 @@ export function updateAgents(time) { const t = time * 0.001; const dt = _lastFrameTime > 0 ? Math.min((time - _lastFrameTime) * 0.001, 0.05) : 0.016; _lastFrameTime = time; + _updateExtBodies(time); const vs = deriveTimmyState(); const pulse = Math.sin(t * 1.8 + timmy.pulsePhase); @@ -889,5 +992,19 @@ export function disposeAgents() { timmy.bubbleTex?.dispose(); timmy.bubbleMat?.dispose(); timmy = null; + + // Dispose external agent bodies + for (const body of Object.values(_extBodies)) { + body.group.traverse(obj => { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + mats.forEach(m => m.dispose()); + } + }); + if (scene) scene.remove(body.group); + } + for (const k of Object.keys(_extBodies)) delete _extBodies[k]; + scene = null; } diff --git a/the-matrix/js/hud-labels.js b/the-matrix/js/hud-labels.js index 8e1432a..f03c0f1 100644 --- a/the-matrix/js/hud-labels.js +++ b/the-matrix/js/hud-labels.js @@ -12,7 +12,12 @@ */ import * as THREE from 'three'; -import { colorToCss } from './agent-defs.js'; +import { colorToCss, AGENT_DEFS } from './agent-defs.js'; + +// Specialization lookup built once from AGENT_DEFS +const _specializations = Object.fromEntries( + AGENT_DEFS.filter(d => d.specialization).map(d => [d.id, d.specialization]) +); const _proj = new THREE.Vector3(); let _camera = null; @@ -20,6 +25,7 @@ let _labels = []; // { el, worldPos: THREE.Vector3, id } // ── State cache (updated from WS) ──────────────────────────────────────────── const _states = {}; +const _lastTasks = {}; // ── Inspect popup ───────────────────────────────────────────────────────────── let _inspectEl = null; @@ -100,6 +106,10 @@ function _makeLabel(container, id, name, role, color, worldPos) { return { el, worldPos, id, color }; } +export function setLabelLastTask(id, summary) { + _lastTasks[id] = summary; +} + export function setLabelState(id, state) { _states[id] = state; const entry = _labels.find(l => l.id === id); @@ -118,13 +128,17 @@ export function showInspectPopup(id, screenX, screenY) { const state = _states[id] || 'idle'; const uptime = Math.floor(performance.now() / 1000); + const spec = _specializations[id]; + const lastTask = _lastTasks[id]; _inspectEl.innerHTML = `
${id.toUpperCase()}
+ ${spec ? `
⬡ ${spec}
` : ''}
state  : ${state}
uptime : ${uptime}s
-
network: connected
+
network: connected
+ ${lastTask ? `
last: ${lastTask.slice(0, 60)}
` : ''} `; _inspectEl.style.left = `${screenX}px`; _inspectEl.style.top = `${screenY}px`; diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index 36ea5c2..671b2bc 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -3,7 +3,7 @@ import { scene } from './world.js'; // Import the scene import { setAgentState, setSpeechBubble, applyAgentStates, setMood, TIMMY_WORLD_POS } from './agents.js'; import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js'; import { sentiment } from './edge-worker-client.js'; -import { setLabelState } from './hud-labels.js'; +import { setLabelState, setLabelLastTask } from './hud-labels.js'; import { createJobIndicator, dissolveJobIndicator } from './effects.js'; import { getPubkey } from './nostr-identity.js'; @@ -122,11 +122,19 @@ function handleMessage(msg) { break; } + case 'agent_task_summary': { + if (msg.agentId && msg.summary) { + setLabelLastTask(msg.agentId, msg.summary); + } + break; + } + case 'job_completed': { if (jobCount > 0) jobCount--; if (msg.agentId) { setAgentState(msg.agentId, 'idle'); setLabelState(msg.agentId, 'idle'); + setLabelLastTask(msg.agentId, `job ${(msg.jobId || '').slice(0, 8)} completed`); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);