world viz: agents grow with their brain (log-curve dot size)
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 35s
ci / rust (push) Successful in 2m38s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m40s

Fresh agents start at "size of their letters" — a small dot in
the live viz — and visibly bloom out as their .brain file fills.
Turns "which of these agents is heavily loaded" into a glance
instead of a menu dive.

Taxonomy
- New agent.memory event: { agentId, bytes?, count? }. STATEFUL, so
  a late subscriber sees the last value replayed and pawns arrive
  pre-sized. count is included in the schema for a future combo
  metric but not emitted yet — bytes carries the visual today.

Backend (routes/world.rs SSE loop)
- Per-agent, per-tick std::fs::metadata() on
  brain_dir()/claw_<uuid>.h5. Just the inode stat — no HDF5 open,
  no memory count, sub-ms per agent. Emit agent.memory { bytes }
  only when the value has changed (or on first sight).
- Tracks last_bytes: HashMap<String, u64> in the SSE-stream scope
  alongside the existing status HashMap.
- Missing file (agent never provisioned a brain) reads as 0 bytes
  and yields scale = 1.0 downstream — pawn stays small.

Engine
- GPawn gains memoryScale (visible) + memoryScaleTarget (chased).
  Base is 1.0; ensurePawn initializes both.
- memoryScaleFromBytes(bytes): 1 + log10(1 + bytes/1MB) * 0.6, cap
  MAX_MEMORY_SCALE = 3.5. So 10MB ~ 1.6x, 100MB ~ 2.2x, 1GB ~ 2.8x.
  Log curve keeps a heavy brain readable without a lite one being
  invisible.
- onMemory(e) sets the target. stepPawns eases the visible scale
  toward it at ~3/sec — a big incoming snapshot doesn't pop the
  sphere; it swells in like it's inhaling.

Renderer (WorldCanvas)
- Live subscription registers agent.memory alongside the existing
  status/touch/reasoning listeners.
- Pawn sphere scale = 5 * p.memoryScale (was hardcoded 8). Halo
  scales in proportion (max(24, 4.25 * s)) so a memory-heavy agent
  reads as a bigger presence, not a small dot with a huge halo.
- AABB bounds for the frame-camera math updated to use s instead
  of 8 so the camera actually frames a big agent when it's the
  outlier.

Not yet wired: comm lines between pawns when agents talk to each
other (Commit E next), and the topology-edge overlay that renders
the graph shape dimly at rest. Both build on top of this — bigger
dots make comm beams more visible.
This commit is contained in:
Omar Sobh
2026-07-09 11:52:45 -07:00
parent aed21b654c
commit aa941b72a1
4 changed files with 69 additions and 7 deletions
+15
View File
@@ -287,6 +287,11 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
// Audit-log cursor for edge-initiated inter-agent events (delegation, // Audit-log cursor for edge-initiated inter-agent events (delegation,
// A2A) that bypass the run loop. -1 until seeded on the first pass. // A2A) that bypass the run loop. -1 until seeded on the first pass.
let mut audit_cursor: i64 = -1; let mut audit_cursor: i64 = -1;
// Track the brain-file size we last announced per agent so we only
// emit `agent.memory` when the file has actually grown (or shrunk).
// On first seed we still emit — the World engine needs the initial
// scale for every pawn.
let mut last_bytes: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
loop { loop {
let roster = match cm_db::repo::agents::roster(&pool, ws).await { let roster = match cm_db::repo::agents::roster(&pool, ws).await {
Ok(r) => r, Ok(r) => r,
@@ -317,6 +322,16 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
json!({ "agentId": id, "status": status, "role": a.job_title }), json!({ "agentId": id, "status": status, "role": a.job_title }),
); );
} }
// Cheap brain-file stat — just reads the inode metadata, no
// HDF5 open. Missing file (never provisioned) → treat as 0
// so the pawn stays at its base size. Only emit on change.
let brain_path = crate::routes::claws::brain_dir()
.join(format!("claw_{}.h5", id));
let bytes = std::fs::metadata(&brain_path).ok().map(|m| m.len()).unwrap_or(0);
if last_bytes.get(&id).map(|&b| b != bytes).unwrap_or(true) {
last_bytes.insert(id.clone(), bytes);
yield sse("agent.memory", json!({ "agentId": id, "bytes": bytes }));
}
} }
// Real convergence: each running agent beams toward its active-run node. // Real convergence: each running agent beams toward its active-run node.
+12 -6
View File
@@ -162,6 +162,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
live.on("world.touch", (d) => e.onTouch(d)), live.on("world.touch", (d) => e.onTouch(d)),
live.on("node.activity", (d) => e.onNodeActivity(d)), live.on("node.activity", (d) => e.onNodeActivity(d)),
live.on("agent.reasoning.delta", (d) => e.onReasoning(d.agentId)), live.on("agent.reasoning.delta", (d) => e.onReasoning(d.agentId)),
live.on("agent.memory", (d) => e.onMemory(d)),
]; ];
return () => offs.forEach((off) => off()); return () => offs.forEach((off) => off());
}, [live, mode]); }, [live, mode]);
@@ -706,8 +707,12 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
gourceGroup.add(m); gourceGroup.add(m);
} }
const pa = p.alpha * (showPawns ? 1 : 0.5); const pa = p.alpha * (showPawns ? 1 : 0.5);
// Base "size of their letters" dot is 5; grows with brain-file size
// via engine.memoryScale (log curve, cap 3.5x). Halo scales in
// proportion so a memory-heavy agent reads as a bigger presence.
const s = 5 * p.memoryScale;
m.position.set(p.x, p.y, 2); m.position.set(p.x, p.y, 2);
m.scale.set(8, 8, 8); m.scale.set(s, s, s);
const mat = m.material as THREE.MeshStandardMaterial; const mat = m.material as THREE.MeshStandardMaterial;
mat.color.set(p.color); mat.color.set(p.color);
mat.emissive.set(p.color); mat.emissive.set(p.color);
@@ -718,15 +723,16 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
pawnGlows.set(p.id, g); pawnGlows.set(p.id, g);
} }
g.position.set(p.x, p.y, 1); g.position.set(p.x, p.y, 1);
g.scale.set(34, 34, 1); const halo = Math.max(24, 4.25 * s);
g.scale.set(halo, halo, 1);
const gm = g.material as THREE.SpriteMaterial; const gm = g.material as THREE.SpriteMaterial;
gm.color.set(p.color); gm.color.set(p.color);
gm.opacity = 0.55 * pa; gm.opacity = 0.55 * pa;
if (showPawns && frameN % 2 === 0) spawn(p.x, p.y, 0, p.color, 0, 0, 0, 3.0); if (showPawns && frameN % 2 === 0) spawn(p.x, p.y, 0, p.color, 0, 0, 0, 3.0);
minX = Math.min(minX, p.x - 8); minX = Math.min(minX, p.x - s);
minY = Math.min(minY, p.y - 8); minY = Math.min(minY, p.y - s);
maxX = Math.max(maxX, p.x + 8); maxX = Math.max(maxX, p.x + s);
maxY = Math.max(maxY, p.y + 8); maxY = Math.max(maxY, p.y + s);
} }
for (const [id, m] of pawnMeshes) { for (const [id, m] of pawnMeshes) {
if (!livePawnIds.has(id)) { if (!livePawnIds.has(id)) {
+34
View File
@@ -45,6 +45,25 @@ export interface GPawn {
alpha: number; alpha: number;
retime: number; retime: number;
fireColor?: string; // colour of this agent's most recent activity (type) fireColor?: string; // colour of this agent's most recent activity (type)
/** Visual radius multiplier from cumulative brain size (log curve). 1.0 =
* base "size of their letters" dot; grows toward `MAX_MEMORY_SCALE` as
* `.brain` fills up. Fresh agents stay at 1.0 until the first
* agent.memory event lands. */
memoryScale: number;
/** Target for smooth interpolation — set by onMemory, chased by step. */
memoryScaleTarget: number;
}
const MAX_MEMORY_SCALE = 3.5;
/** Map brain-file size in bytes to a visual radius multiplier via a log
* curve so a 10MB brain ~ 1.6x, 100MB ~ 2.2x, 1GB ~ 2.8x, ≥3GB caps at
* `MAX_MEMORY_SCALE`. Empty brain (0 bytes) stays at 1.0 — that's the
* "size of the letters" starting size the world engine draws today. */
export function memoryScaleFromBytes(bytes: number): number {
if (!Number.isFinite(bytes) || bytes <= 0) return 1;
const scale = 1 + Math.log10(1 + bytes / 1_000_000) * 0.6;
return Math.min(MAX_MEMORY_SCALE, scale);
} }
export interface Beam { export interface Beam {
@@ -167,6 +186,8 @@ export class WorldEngine {
idle: 0, idle: 0,
alpha: 1, alpha: 1,
retime: 1 + Math.random() * 2, retime: 1 + Math.random() * 2,
memoryScale: 1,
memoryScaleTarget: 1,
}; };
this.pawns.set(id, p); this.pawns.set(id, p);
} }
@@ -217,6 +238,15 @@ export class WorldEngine {
p.fireColor = "#c98af0"; // think = purple p.fireColor = "#c98af0"; // think = purple
p.idle = 0; p.idle = 0;
} }
/** Backend just observed the agent's `.brain` file at a new size. Set the
* target scale; step() eases the visible radius toward it so growth
* feels alive rather than snapping. */
onMemory(e: TaxonomyEvents["agent.memory"]) {
const p = this.ensurePawn(e.agentId);
if (typeof e.bytes === "number") {
p.memoryScaleTarget = memoryScaleFromBytes(e.bytes);
}
}
onNodeActivity(e: TaxonomyEvents["node.activity"]) { onNodeActivity(e: TaxonomyEvents["node.activity"]) {
const node = this.ensureNode(e.nodeId, e.kind === "event" ? "event" : "service", e.label ?? e.nodeId, ROOT, 1); const node = this.ensureNode(e.nodeId, e.kind === "event" ? "event" : "service", e.label ?? e.nodeId, ROOT, 1);
if (e.label) node.label = e.label; if (e.label) node.label = e.label;
@@ -349,6 +379,10 @@ export class WorldEngine {
p.idle += dt; p.idle += dt;
const dim = p.status === "idle" || p.status === "offline"; const dim = p.status === "idle" || p.status === "offline";
p.alpha = dim ? Math.max(0.25, p.alpha - dt * 0.3) : Math.min(1, p.alpha + dt * 2); p.alpha = dim ? Math.max(0.25, p.alpha - dt * 0.3) : Math.min(1, p.alpha + dt * 2);
// Ease memoryScale toward its target so a fresh backend snapshot
// doesn't pop the sphere. 3x/sec convergence looks organic.
const ms = p.memoryScale;
p.memoryScale = ms + (p.memoryScaleTarget - ms) * Math.min(1, dt * 3);
} }
} }
} }
+8 -1
View File
@@ -18,6 +18,11 @@ export interface TaxonomyEvents {
}; };
/** Observe → REASONING STREAM (append). */ /** Observe → REASONING STREAM (append). */
"agent.reasoning.delta": { agentId: string; text: string; channel?: "think" | "say" | "tool" }; "agent.reasoning.delta": { agentId: string; text: string; channel?: "think" | "say" | "tool" };
/** World → pawn scales with brain-file size (log curve). Sent by the
* server periodically for each agent whose `.brain` file has changed.
* Fresh agents start small ("size of their letters"); loaded agents
* visibly bloom out. */
"agent.memory": { agentId: string; bytes?: number; count?: number };
/** Observe → the live computer-screen tile. */ /** Observe → the live computer-screen tile. */
"agent.computer.frame": { "agent.computer.frame": {
agentId: string; agentId: string;
@@ -81,6 +86,7 @@ export const TAXONOMY_TYPES: TaxonomyType[] = [
"agent.status", "agent.status",
"agent.task.update", "agent.task.update",
"agent.reasoning.delta", "agent.reasoning.delta",
"agent.memory",
"agent.computer.frame", "agent.computer.frame",
"agent.tool.call", "agent.tool.call",
"door.request", "door.request",
@@ -102,6 +108,7 @@ export const TAXONOMY_TYPES: TaxonomyType[] = [
export const STATEFUL_TYPES = new Set<TaxonomyType>([ export const STATEFUL_TYPES = new Set<TaxonomyType>([
"agent.status", "agent.status",
"agent.task.update", "agent.task.update",
"agent.memory",
"node.activity", "node.activity",
"telemetry", "telemetry",
"topology.update", "topology.update",
@@ -110,7 +117,7 @@ export const STATEFUL_TYPES = new Set<TaxonomyType>([
/** The replay key per stateful event (one retained value per agent/node/routine). */ /** The replay key per stateful event (one retained value per agent/node/routine). */
export function stateKey<T extends TaxonomyType>(type: T, d: TaxonomyPayload<T>): string { export function stateKey<T extends TaxonomyType>(type: T, d: TaxonomyPayload<T>): string {
if (type === "agent.status" || type === "agent.task.update") if (type === "agent.status" || type === "agent.task.update" || type === "agent.memory")
return `${type}:${(d as TaxonomyEvents["agent.status"]).agentId}`; return `${type}:${(d as TaxonomyEvents["agent.status"]).agentId}`;
if (type === "node.activity") return `${type}:${(d as TaxonomyEvents["node.activity"]).nodeId}`; if (type === "node.activity") return `${type}:${(d as TaxonomyEvents["node.activity"]).nodeId}`;
if (type === "routine.update") return `${type}:${(d as TaxonomyEvents["routine.update"]).routineId}`; if (type === "routine.update") return `${type}:${(d as TaxonomyEvents["routine.update"]).routineId}`;