Merge pull request 'feature/0.12.0' (#154) from feature/0.12.0 into main
Reviewed-on: #154
This commit is contained in:
commit
3aeef3e5ff
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).
|
||||
// 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.
|
||||
socket::autospawn_pi_if_configured(&state).await;
|
||||
// no-op unless COLIBRI_AUTOSPAWN is set and a DeepSeek key is present.
|
||||
socket::autospawn_agent_if_configured(&state).await;
|
||||
|
||||
// Start the daemon background loop (heartbeat, session rotation, scheduler)
|
||||
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`) —
|
||||
/// the host-spawned Pi inherits it. Idempotent: skips when a Pi subprocess is
|
||||
/// already running, so a daemon restart (e.g. after the operator enters creds
|
||||
/// and `service colibri_daemon restart` runs) does not stack duplicates.
|
||||
/// the host-spawned agent inherits it. Idempotent: skips when an agent subprocess
|
||||
/// is already running, so a daemon restart does not stack duplicates.
|
||||
///
|
||||
/// The live "Operator Image" is single-agent, so the Pi runs on the host (no
|
||||
/// jail). Binary and argv are env-tunable so the exact Pi invocation can be
|
||||
/// adjusted on the image without a rebuild:
|
||||
/// - `COLIBRI_PI_BINARY` (default `pi`)
|
||||
/// - `COLIBRI_AUTOSPAWN_PI_ARGS` (default `--mode json`)
|
||||
pub async fn autospawn_pi_if_configured(state: &SharedState) {
|
||||
if !env_truthy("COLIBRI_AUTOSPAWN_PI") {
|
||||
/// The live "Operator Image" is single-agent, so the agent runs on the host (no
|
||||
/// jail). Binary and argv are env-tunable without a rebuild:
|
||||
/// - `COLIBRI_AUTOSPAWN_BINARY` (default `zot`)
|
||||
/// - `COLIBRI_AUTOSPAWN_ARGS` (default `--mode json`)
|
||||
pub async fn autospawn_agent_if_configured(state: &SharedState) {
|
||||
if !env_truthy("COLIBRI_AUTOSPAWN") {
|
||||
return;
|
||||
}
|
||||
if state.config.deepseek_api_key.is_none() {
|
||||
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;
|
||||
}
|
||||
|
||||
let pi_binary = std::env::var("COLIBRI_PI_BINARY")
|
||||
let agent_binary = std::env::var("COLIBRI_AUTOSPAWN_BINARY")
|
||||
.ok()
|
||||
.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.
|
||||
let pi_name = basename(&pi_binary);
|
||||
// One agent is enough. Match by binary basename so a restart does not duplicate.
|
||||
let agent_name = basename(&agent_binary);
|
||||
let already = state
|
||||
.agents
|
||||
.iter()
|
||||
.any(|e| basename(&e.value().config.binary) == pi_name);
|
||||
.any(|e| basename(&e.value().config.binary) == agent_name);
|
||||
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;
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::var("COLIBRI_AUTOSPAWN_PI_ARGS")
|
||||
let args: Vec<String> = std::env::var("COLIBRI_AUTOSPAWN_ARGS")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.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