world viz: agents grow with their brain (log-curve dot size)
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:
@@ -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.
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user