feature/0.12.0 #154

Merged
clawdie merged 2 commits from feature/0.12.0 into main 2026-06-23 13:40:49 +02:00
3 changed files with 314 additions and 22 deletions

View file

@ -76,8 +76,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Auto-spawn one Pi agent if configured (live "Operator Image" OOTB flow). // Auto-spawn one Pi agent if configured (live "Operator Image" OOTB flow).
// Runs after the socket server is up so the spawn registers on the board; // Runs after the socket server is up so the spawn registers on the board;
// no-op unless COLIBRI_AUTOSPAWN_PI is set and a DeepSeek key is present. // no-op unless COLIBRI_AUTOSPAWN is set and a DeepSeek key is present.
socket::autospawn_pi_if_configured(&state).await; socket::autospawn_agent_if_configured(&state).await;
// Start the daemon background loop (heartbeat, session rotation, scheduler) // Start the daemon background loop (heartbeat, session rotation, scheduler)
let loop_state = state.clone(); let loop_state = state.clone();

View file

@ -400,47 +400,45 @@ async fn cmd_list_sessions(state: &SharedState) -> ColibriResponse {
})) }))
} }
/// Auto-spawn a single Pi agent at daemon startup when configured. /// Auto-spawn a single agent at daemon startup when configured.
/// ///
/// Enabled with `COLIBRI_AUTOSPAWN_PI` (`YES`/`1`/`true`/`on`). Requires a /// Enabled with `COLIBRI_AUTOSPAWN` (`YES`/`1`/`true`/`on`). Requires a
/// `DEEPSEEK_API_KEY` in the daemon environment (sourced from `provider.env`) — /// `DEEPSEEK_API_KEY` in the daemon environment (sourced from `provider.env`) —
/// the host-spawned Pi inherits it. Idempotent: skips when a Pi subprocess is /// the host-spawned agent inherits it. Idempotent: skips when an agent subprocess
/// already running, so a daemon restart (e.g. after the operator enters creds /// is already running, so a daemon restart does not stack duplicates.
/// and `service colibri_daemon restart` runs) does not stack duplicates.
/// ///
/// The live "Operator Image" is single-agent, so the Pi runs on the host (no /// The live "Operator Image" is single-agent, so the agent runs on the host (no
/// jail). Binary and argv are env-tunable so the exact Pi invocation can be /// jail). Binary and argv are env-tunable without a rebuild:
/// adjusted on the image without a rebuild: /// - `COLIBRI_AUTOSPAWN_BINARY` (default `zot`)
/// - `COLIBRI_PI_BINARY` (default `pi`) /// - `COLIBRI_AUTOSPAWN_ARGS` (default `--mode json`)
/// - `COLIBRI_AUTOSPAWN_PI_ARGS` (default `--mode json`) pub async fn autospawn_agent_if_configured(state: &SharedState) {
pub async fn autospawn_pi_if_configured(state: &SharedState) { if !env_truthy("COLIBRI_AUTOSPAWN") {
if !env_truthy("COLIBRI_AUTOSPAWN_PI") {
return; return;
} }
if state.config.deepseek_api_key.is_none() { if state.config.deepseek_api_key.is_none() {
info!( info!(
"autospawn-pi: DEEPSEEK_API_KEY not set; skipping (operator can add it via Join Hive)" "autospawn: DEEPSEEK_API_KEY not set; skipping (operator can add it via Join Hive)"
); );
return; return;
} }
let pi_binary = std::env::var("COLIBRI_PI_BINARY") let agent_binary = std::env::var("COLIBRI_AUTOSPAWN_BINARY")
.ok() .ok()
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "pi".to_string()); .unwrap_or_else(|| "zot".to_string());
// One Pi is enough. Match by binary basename so a restart does not duplicate. // One agent is enough. Match by binary basename so a restart does not duplicate.
let pi_name = basename(&pi_binary); let agent_name = basename(&agent_binary);
let already = state let already = state
.agents .agents
.iter() .iter()
.any(|e| basename(&e.value().config.binary) == pi_name); .any(|e| basename(&e.value().config.binary) == agent_name);
if already { if already {
info!(pi = %pi_name, "autospawn-pi: a Pi agent is already running; skipping"); info!(agent = %agent_name, "autospawn: an agent is already running; skipping");
return; return;
} }
let args: Vec<String> = std::env::var("COLIBRI_AUTOSPAWN_PI_ARGS") let args: Vec<String> = std::env::var("COLIBRI_AUTOSPAWN_ARGS")
.ok() .ok()
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "--mode json".to_string()) .unwrap_or_else(|| "--mode json".to_string())

View file

@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""geodesic-dome-mcp — MCP tool: geodesic dome wireframes + bill of materials.
Class I icosahedron subdivision → structural analysis → Pillow render.
Reports: strut lengths/counts, connector types, triangle areas, material BOM.
Pure Python + numpy + Pillow. No Blender, no GPU, no X11.
"""
import json, math, sys
from collections import defaultdict, Counter
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
OUT_DIR = Path("/tmp/geodesic-dome")
OUT_DIR.mkdir(exist_ok=True)
PHI = (1 + math.sqrt(5)) / 2
ICO_V = np.array([
[-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0],
[0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI],
[PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1],
], dtype=np.float64)
ICO_V = ICO_V / np.linalg.norm(ICO_V, axis=1, keepdims=True)
ICO_F = [
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
]
# Material specs per m²
MATERIALS = {
"glass": {"kg_per_m2": 2.5, "cost_per_m2": 25, "transparent": True},
"polycarbonate": {"kg_per_m2": 1.2, "cost_per_m2": 35, "transparent": True},
"insulated": {"kg_per_m2": 5.0, "cost_per_m2": 50, "transparent": False},
}
def geodesic_dome(frequency, half=False):
"""Build dome: vertices array, edge list, triangle list (each tri = 3 vertex indices)."""
vlist, vmap = [], {}
def add(pt):
key = tuple(round(x, 10) for x in pt)
if key not in vmap:
vmap[key] = len(vlist)
vlist.append(pt)
return vmap[key]
edges = set()
triangles = []
for face in ICO_F:
a, b, c = ICO_V[face[0]], ICO_V[face[1]], ICO_V[face[2]]
grid = {}
for i in range(frequency + 1):
for j in range(frequency + 1 - i):
k = frequency - i - j
pt = (i * a + j * b + k * c) / frequency
pt = pt / np.linalg.norm(pt)
# Half-sphere: skip if all three face vertices are below equator
grid[(i, j)] = add(pt)
for i in range(frequency):
for j in range(frequency - i):
v0, v1, v2 = grid[(i,j)], grid[(i+1,j)], grid[(i,j+1)]
triangles.append((v0, v1, v2))
edges.add(tuple(sorted((v0, v1))))
edges.add(tuple(sorted((v1, v2))))
edges.add(tuple(sorted((v2, v0))))
if i + j + 2 <= frequency:
v0, v1, v2 = grid[(i+1,j)], grid[(i+1,j+1)], grid[(i,j+1)]
triangles.append((v0, v1, v2))
edges.add(tuple(sorted((v0, v1))))
edges.add(tuple(sorted((v1, v2))))
edges.add(tuple(sorted((v2, v0))))
verts = np.array(vlist)
if half:
# Keep only triangles with at least one vertex above equator (z > -0.05)
kept_tris = []
kept_edges = set()
kept_verts = set()
for tri in triangles:
zs = [verts[v][2] for v in tri]
if any(z > -0.05 for z in zs):
kept_tris.append(tri)
for e in [(tri[0],tri[1]), (tri[1],tri[2]), (tri[2],tri[0])]:
kept_edges.add(tuple(sorted(e)))
kept_verts.update(e)
triangles = kept_tris
edges = list(kept_edges)
else:
edges = list(edges)
return verts, edges, triangles
def analyze_structure(verts, edges, triangles, radius_meters=5.0):
"""Structural analysis: struts, connectors, triangles, BOM."""
n_verts = len(verts)
n_edges = len(edges)
n_tris = len(triangles)
# ── Strut lengths ──
chord_lengths = []
for a, b in edges:
chord = np.linalg.norm(verts[a] - verts[b]) * radius_meters
chord_lengths.append(chord)
# Group by 1cm buckets
strut_groups = Counter()
for cl in chord_lengths:
bucket = round(cl, 2)
strut_groups[bucket] += 1
strut_table = []
for length in sorted(strut_groups):
count = strut_groups[length]
strut_table.append({
"length_m": round(length, 2),
"count": count,
"total_m": round(length * count, 2),
})
unique_strut_sizes = len(strut_table)
total_strut_m = round(sum(s["total_m"] for s in strut_table), 2)
# ── Connector types ──
valence = Counter()
for a, b in edges:
valence[a] += 1
valence[b] += 1
connectors = Counter()
for v, deg in valence.items():
connectors[deg] += 1
connector_report = {}
for deg in sorted(connectors):
label = f"{deg}-way"
connector_report[label] = connectors[deg]
# ── Triangle areas ──
tri_areas = []
for v0, v1, v2 in triangles:
a = verts[v0] * radius_meters
b = verts[v1] * radius_meters
c = verts[v2] * radius_meters
# Area = 0.5 * |(b-a) × (c-a)|
area = 0.5 * np.linalg.norm(np.cross(b - a, c - a))
tri_areas.append(area)
# Group by 0.05m² buckets
area_groups = Counter()
for a in tri_areas:
area_groups[round(a, 1)] += 1
tri_table = []
for area in sorted(area_groups):
count = area_groups[area]
tri_table.append({
"area_m2": round(area, 2),
"count": count,
"total_m2": round(area * count, 2),
})
total_area_m2 = round(sum(t["total_m2"] for t in tri_table), 2)
# ── Bill of materials ──
bom = {}
for mat_name, spec in MATERIALS.items():
bom[mat_name] = {
"total_m2": total_area_m2,
"total_kg": round(total_area_m2 * spec["kg_per_m2"], 1),
"total_cost_usd": round(total_area_m2 * spec["cost_per_m2"]),
"transparent": spec["transparent"],
"per_m2_kg": spec["kg_per_m2"],
"per_m2_cost_usd": spec["cost_per_m2"],
}
return {
"dome_radius_m": radius_meters,
"frequency": None, # set by caller
"vertices": n_verts,
"edges": n_edges,
"triangles": n_tris,
"unique_strut_sizes": unique_strut_sizes,
"struts": strut_table,
"total_strut_meters": total_strut_m,
"connectors": connector_report,
"total_connectors": sum(connectors.values()),
"triangle_types": len(tri_table),
"triangles_by_area": tri_table[:10], # top 10, not all
"total_surface_m2": total_area_m2,
"bill_of_materials": bom,
}
def render(verts, edges, size=2048, yaw=0.3, pitch=0.4,
line_color="#00b4d8", bg_color="#0d1117", lw=1):
sy, cy = math.sin(yaw), math.cos(yaw)
sp, cp = math.sin(pitch), math.cos(pitch)
rotated = np.empty_like(verts)
for idx, v in enumerate(verts):
x = v[0] * cy + v[2] * sy
z = -v[0] * sy + v[2] * cy
y = v[1]
yy = y * cp - z * sp
zz = y * sp + z * cp
rotated[idx] = [x, yy, zz]
distance = 4.0
scale = size * 0.42
cx = cy = size / 2
img = Image.new("RGB", (size, size), bg_color)
draw = ImageDraw.Draw(img)
for a, b in edges:
p, q = rotated[a], rotated[b]
if p[2] < -0.2 and q[2] < -0.2:
continue
pz = distance - p[2]
qz = distance - q[2]
if pz < 0.1 or qz < 0.1:
continue
px = cx + p[0] * scale / pz
py = cy - p[1] * scale / pz
qx = cx + q[0] * scale / qz
qy = cy - q[1] * scale / qz
avg_z = (p[2] + q[2]) / 2
depth = 0.2 + 0.8 * max(0, min(1, (avg_z + 1.0) / 2.0))
r = int(int(line_color[1:3], 16) * depth)
g = int(int(line_color[3:5], 16) * depth)
b = int(int(line_color[5:7], 16) * depth)
color = f"#{r:02x}{g:02x}{b:02x}"
draw.line([(px, py), (qx, qy)], fill=color, width=lw)
return img
def generate(params):
freq = int(params.get("frequency", 3))
half = params.get("half", "false").lower() in ("true", "1", "yes")
radius = float(params.get("radius_m", 5.0))
size = int(params.get("size", 2048))
yaw = float(params.get("yaw", 0.3))
pitch = float(params.get("pitch", 0.4))
lc = params.get("line_color", "#00b4d8")
bg = params.get("bg_color", "#0d1117")
lw = int(params.get("line_width", 1))
analysis_only = params.get("analysis_only", "false").lower() in ("true", "1", "yes")
verts, edges, triangles = geodesic_dome(freq, half=half)
analysis = analyze_structure(verts, edges, triangles, radius_meters=radius)
analysis["frequency"] = freq
analysis["half_sphere"] = half
result = {"success": True, "analysis": analysis}
if not analysis_only:
img = render(verts, edges, size=size, yaw=yaw, pitch=pitch,
line_color=lc, bg_color=bg, lw=lw)
shape = "half" if half else "full"
stem = f"geodesic-f{freq}-{shape}-y{yaw:.2f}-p{pitch:.2f}"
path = OUT_DIR / f"{stem}.png"
img.save(path)
result["image_path"] = str(path)
result["size"] = f"{size}x{size}"
result["yaw"] = yaw
result["pitch"] = pitch
return result
def main():
raw = sys.stdin.read().strip()
if not raw:
print(json.dumps({"error": "empty input"})); sys.exit(1)
req = json.loads(raw)
rid = req.get("id", 1)
args = req.get("params", {}).get("arguments", {})
try:
result = generate(args)
resp = {"jsonrpc":"2.0","id":rid,"result":{"content":[{"type":"text","text":json.dumps(result)}]}}
except Exception as e:
resp = {"jsonrpc":"2.0","id":rid,"error":{"code":-1,"message":str(e)}}
print(json.dumps(resp))
if __name__ == "__main__":
main()