feature/0.12.0 #154
3 changed files with 314 additions and 22 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
294
packaging/mother/geodesic-dome-mcp
Executable file
294
packaging/mother/geodesic-dome-mcp
Executable 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()
|
||||||
Loading…
Add table
Reference in a new issue