feat: add Kimi & Perplexity as visible Workshop agents (#11)
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 1s

- agent-defs.js: add Kimi (Long Context Analysis, cyan) and Perplexity
  (Real-time Research, pink) with world positions at (-10,-10) and (10,-10)
- agents.js: add 3D geometric bodies for both agents — Kimi as an
  octahedron with orbital rings, Perplexity as an icosahedron with
  scanning tori; idle/active/dormant animations driven by agent state;
  restrict Timmy mood derivation to workshop agents only
- hud-labels.js: show specialization and last-task summary in inspect
  popup; export setLabelLastTask() for WS updates
- websocket.js: handle agent_task_summary messages; call setLabelLastTask
  on job_completed events
- world-state.ts: add kimi and perplexity to initial agentStates; restrict
  _deriveTimmy() to workshop agents only
- event-bus.ts: add AgentExternalEvent type for external agent state changes
- events.ts: handle agent:external_state bus events, broadcast agent_state
  and agent_task_summary WS messages

Fixes #11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 22:38:09 -04:00
parent b6569aeedc
commit 9972eb59fe
7 changed files with 182 additions and 13 deletions

View File

@@ -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;

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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,
},
];
/**

View File

@@ -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;
}

View File

@@ -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 = `
<div style="color:${entry.color};font-weight:bold;letter-spacing:2px;font-size:12px;margin-bottom:6px;">
${id.toUpperCase()}
</div>
${spec ? `<div style="color:${entry.color}99;margin-bottom:4px;font-size:10px;letter-spacing:1px;">⬡ ${spec}</div>` : ''}
<div style="color:#aaa;margin-bottom:2px;">state&nbsp;&nbsp;: <span style="color:${entry.color}">${state}</span></div>
<div style="color:#aaa;margin-bottom:2px;">uptime : ${uptime}s</div>
<div style="color:#aaa;">network: <span style="color:#44ff88">connected</span></div>
<div style="color:#aaa;margin-bottom:2px;">network: <span style="color:#44ff88">connected</span></div>
${lastTask ? `<div style="color:#888;font-size:9px;margin-top:4px;border-top:1px solid #333;padding-top:4px;">last: ${lastTask.slice(0, 60)}</div>` : ''}
`;
_inspectEl.style.left = `${screenX}px`;
_inspectEl.style.top = `${screenY}px`;

View File

@@ -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`);