From aea2fbe60e1f01c5cfb734abcccf81c314fa0b69 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Tue, 2 Jun 2026 08:31:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20`clawdie`=20=E2=80=94=20simplifie?= =?UTF-8?q?d=20operator=20agent=20in=20one=20small=20binary=20(Sam=20&=20C?= =?UTF-8?q?laude)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `clawdie` crate: the operator-friendly face of Colibri. One small Rust binary that reuses the proven control-plane core (glasspane supervision + Herdr Unix-socket API + coordination loop — the "split brain") and puts a DeepSeek-backed Telegram bot in front of it. That is the entire out-of-the-box surface. Deliberately lifted vs. the full control plane: cost modes, quota accounting, context budgets, multi-provider fallback (OpenRouter/Anthropic), per-user limits. One DeepSeek key serves both the chat lane and the daemon routing. - crates/clawdie: main (core + bridge wiring), telegram (long-poll bridge), deepseek (minimal one-key chat), build.rs (bakes CLAWDIE_TG_TOKEN + CLAWDIE_DEEPSEEK_KEY build flags; runtime env overrides). - packaging/freebsd/clawdie.in: rc.d service, daemon(8)-supervised, restart on crash, dedicated clawdie user — starts as a service like Clawdie-AI. - release profile strips symbols (binary ~7.6 MB stripped). - docs/CLAWDIE-AGENT-WIKI.md (mindmap), docs/CLAWDIE-BUILD.md (build + ISO + next-build XFCE USB fixes), README workspace table. Build/clippy/fmt green; headless start smoke-tested (socket + sessions bind). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 14 +++ Cargo.toml | 9 +- README.md | 23 ++--- crates/clawdie/Cargo.toml | 22 +++++ crates/clawdie/build.rs | 29 ++++++ crates/clawdie/src/deepseek.rs | 114 ++++++++++++++++++++++ crates/clawdie/src/main.rs | 170 +++++++++++++++++++++++++++++++++ crates/clawdie/src/telegram.rs | 133 ++++++++++++++++++++++++++ docs/CLAWDIE-AGENT-WIKI.md | 98 +++++++++++++++++++ docs/CLAWDIE-BUILD.md | 100 +++++++++++++++++++ packaging/freebsd/clawdie.in | 86 +++++++++++++++++ 11 files changed, 785 insertions(+), 13 deletions(-) create mode 100644 crates/clawdie/Cargo.toml create mode 100644 crates/clawdie/build.rs create mode 100644 crates/clawdie/src/deepseek.rs create mode 100644 crates/clawdie/src/main.rs create mode 100644 crates/clawdie/src/telegram.rs create mode 100644 docs/CLAWDIE-AGENT-WIKI.md create mode 100644 docs/CLAWDIE-BUILD.md create mode 100644 packaging/freebsd/clawdie.in diff --git a/Cargo.lock b/Cargo.lock index 2c32456..94ba27c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,20 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clawdie" +version = "0.0.1" +dependencies = [ + "colibri-daemon", + "colibri-glasspane", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "colibri" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index cdd1b4b..b95f932 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store"] +members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/clawdie"] [package] name = "colibri" @@ -26,4 +26,9 @@ colibri-deepseek = { path = "crates/colibri-deepseek" } dotenvy = "0.15" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } serde = { version = "1", features = ["derive"] } -serde_json = "1" \ No newline at end of file +serde_json = "1" + +# Lean release artifacts (notably the `clawdie` operator binary): strip symbols +# so the staged ISO binaries stay small without a manual strip step. +[profile.release] +strip = true \ No newline at end of file diff --git a/README.md b/README.md index 1450b83..3f93989 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,19 @@ Next ISO integration plan: `docs/ISO-INTEGRATION-PLAN.md`. ISO acceptance runbook: `docs/ISO-ACCEPTANCE-RUNBOOK.md`. Clawdie Studio/Zed proposal: `docs/CLAWDIE-STUDIO-PROPOSAL.md`. -## Workspace — 8 crates +## Workspace — 9 crates -| Crate | Role | -|-------|------| -| `colibri-contracts` | JSON schema contracts (golden tests) | -| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering | -| `colibri-runtime` | Host status ingestion, runtime inventory | -| `colibri-glasspane` | Agent 5-state machine (Pi events → state) | -| `colibri-daemon` | Always-on Unix socket server, session lifecycle | -| `colibri-client` | Typed Unix-socket client + operator CLI | -| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) | -| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) | +| Crate | Role | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `clawdie` | Simplified operator agent: glasspane + herdr + DeepSeek/Telegram in one small binary (build-flag configured). See `docs/CLAWDIE-AGENT-WIKI.md`. | +| `colibri-contracts` | JSON schema contracts (golden tests) | +| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering | +| `colibri-runtime` | Host status ingestion, runtime inventory | +| `colibri-glasspane` | Agent 5-state machine (Pi events → state) | +| `colibri-daemon` | Always-on Unix socket server, session lifecycle | +| `colibri-client` | Typed Unix-socket client + operator CLI | +| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) | +| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) | ## Build diff --git a/crates/clawdie/Cargo.toml b/crates/clawdie/Cargo.toml new file mode 100644 index 0000000..fc00a1a --- /dev/null +++ b/crates/clawdie/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "clawdie" +version = "0.0.1" +edition = "2021" +license = "AGPL-3.0-only" +description = "Clawdie — the simplified, operator-friendly Colibri agent (glasspane + herdr + DeepSeek/Telegram) as one small binary" + +[[bin]] +name = "clawdie" +path = "src/main.rs" + +[dependencies] +# Reuse the proven control-plane core: glasspane (supervision radar), +# the Herdr Unix-socket API, the coordination loop, and session lifecycle. +colibri-daemon = { path = "../colibri-daemon" } +colibri-glasspane = { path = "../colibri-glasspane" } +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/clawdie/build.rs b/crates/clawdie/build.rs new file mode 100644 index 0000000..63eb2a3 --- /dev/null +++ b/crates/clawdie/build.rs @@ -0,0 +1,29 @@ +//! Build-time configuration for the `clawdie` binary. +//! +//! The whole product surface is two credentials, and we want them bakeable at +//! compile time so an operator ISO ships ready-to-run with no config files: +//! +//! CLAWDIE_TG_TOKEN — Telegram bot token (the chat front door) +//! CLAWDIE_DEEPSEEK_KEY — DeepSeek API key (the one key for everything) +//! +//! Pass them as build flags, e.g.: +//! +//! CLAWDIE_TG_TOKEN=123:abc CLAWDIE_DEEPSEEK_KEY=sk-... cargo build --release +//! +//! Whatever is present at build time is re-exported into the compiled binary +//! via `cargo:rustc-env`, where `option_env!` can read it. Runtime environment +//! variables always win over baked-in values (see `src/main.rs`), so the same +//! binary works for both "baked" ISO builds and "bring your own key" operators. + +fn main() { + // Re-run if the build-flag inputs change. + println!("cargo:rerun-if-env-changed=CLAWDIE_TG_TOKEN"); + println!("cargo:rerun-if-env-changed=CLAWDIE_DEEPSEEK_KEY"); + + // Bake whatever was provided (empty string when unset — main.rs treats + // empty as absent). We never print the secret values to the build log. + let tg = std::env::var("CLAWDIE_TG_TOKEN").unwrap_or_default(); + let ds = std::env::var("CLAWDIE_DEEPSEEK_KEY").unwrap_or_default(); + println!("cargo:rustc-env=CLAWDIE_BUILTIN_TG_TOKEN={tg}"); + println!("cargo:rustc-env=CLAWDIE_BUILTIN_DEEPSEEK_KEY={ds}"); +} diff --git a/crates/clawdie/src/deepseek.rs b/crates/clawdie/src/deepseek.rs new file mode 100644 index 0000000..8d9e219 --- /dev/null +++ b/crates/clawdie/src/deepseek.rs @@ -0,0 +1,114 @@ +//! Minimal DeepSeek chat lane for the Clawdie agent. +//! +//! Deliberately tiny: one model, one key, one request shape, no cost modes, no +//! quota accounting, no provider fallback. That heavier discipline lives in +//! `colibri-deepseek` / `colibri-daemon::cost` for the full control plane; the +//! simplified Clawdie agent intentionally does **not** carry it. + +use serde::{Deserialize, Serialize}; + +/// DeepSeek's OpenAI-compatible chat-completions endpoint. +pub const DEFAULT_ENDPOINT: &str = "https://api.deepseek.com/chat/completions"; +/// Default chat model. Override at runtime with `DEEPSEEK_MODEL`. +pub const DEFAULT_MODEL: &str = "deepseek-chat"; + +/// One DeepSeek chat lane, configured once at startup. +#[derive(Clone)] +pub struct DeepSeek { + client: reqwest::Client, + endpoint: String, + model: String, + api_key: String, + system_prompt: String, +} + +#[derive(Serialize)] +struct ChatMessage<'a> { + role: &'a str, + content: &'a str, +} + +#[derive(Serialize)] +struct ChatRequest<'a> { + model: &'a str, + messages: Vec>, + stream: bool, +} + +#[derive(Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Deserialize)] +struct ResponseMessage { + #[serde(default)] + content: String, +} + +#[derive(Deserialize)] +struct ChatResponse { + #[serde(default)] + choices: Vec, +} + +impl DeepSeek { + /// Build a lane. `api_key` must be non-empty (callers gate on this). + pub fn new(api_key: String) -> Result { + let endpoint = + std::env::var("DEEPSEEK_ENDPOINT").unwrap_or_else(|_| DEFAULT_ENDPOINT.to_string()); + let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + let system_prompt = std::env::var("CLAWDIE_SYSTEM_PROMPT").unwrap_or_else(|_| { + "You are Clawdie, a friendly, concise operator assistant running on a \ + FreeBSD live system. Answer directly and helpfully." + .to_string() + }); + let client = reqwest::Client::builder() + .user_agent(concat!("clawdie/", env!("CARGO_PKG_VERSION"))) + .build()?; + Ok(Self { + client, + endpoint, + model, + api_key, + system_prompt, + }) + } + + /// Send one user message, return the assistant's reply text. + pub async fn reply( + &self, + user_text: &str, + ) -> Result> { + let body = ChatRequest { + model: &self.model, + messages: vec![ + ChatMessage { + role: "system", + content: &self.system_prompt, + }, + ChatMessage { + role: "user", + content: user_text, + }, + ], + stream: false, + }; + let resp = self + .client + .post(&self.endpoint) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await? + .error_for_status()?; + let parsed: ChatResponse = resp.json().await?; + let text = parsed + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .unwrap_or_default(); + Ok(text) + } +} diff --git a/crates/clawdie/src/main.rs b/crates/clawdie/src/main.rs new file mode 100644 index 0000000..2d764a1 --- /dev/null +++ b/crates/clawdie/src/main.rs @@ -0,0 +1,170 @@ +//! clawdie — the simplified, operator-friendly Colibri agent. +//! +//! One small binary, two credentials, zero ceremony. It bundles the proven +//! control-plane core (glasspane supervision radar + the Herdr Unix-socket API + +//! the coordination loop — the "split brain") and puts a DeepSeek-backed +//! Telegram bot in front of it. That is the entire out-of-the-box surface. +//! +//! What was deliberately **lifted** vs. the full Colibri control plane: +//! - no cost modes / quota accounting / context-budget enforcement +//! - no multi-provider fallback (OpenRouter / Anthropic) — one DeepSeek key +//! - no per-user limits, no admin gating +//! +//! Credentials come from build flags (see `build.rs`) and are overridable at +//! runtime by environment variables, so the same binary serves both a baked +//! ISO and a bring-your-own-key operator. + +mod deepseek; +mod telegram; + +use std::sync::Arc; + +use colibri_daemon::{daemon, session, socket, DaemonConfig, DaemonState, SharedState}; +use tracing::{info, warn}; + +/// Resolve a credential: runtime env wins, then the build-flag baked value, +/// then nothing. Empty strings count as "not set". +fn resolve(env_names: &[&str], baked: Option<&str>) -> Option { + for name in env_names { + if let Ok(v) = std::env::var(name) { + if !v.trim().is_empty() { + return Some(v); + } + } + } + baked + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + info!("clawdie {} starting", env!("CARGO_PKG_VERSION")); + + // ── Resolve the two and only credentials ──────────────────────────────── + let tg_token = resolve( + &["CLAWDIE_TG_TOKEN", "TELEGRAM_BOT_TOKEN"], + option_env!("CLAWDIE_BUILTIN_TG_TOKEN"), + ); + let deepseek_key = resolve( + &["CLAWDIE_DEEPSEEK_KEY", "DEEPSEEK_API_KEY"], + option_env!("CLAWDIE_BUILTIN_DEEPSEEK_KEY"), + ); + + // One key for everything: make the daemon's own provider routing see it too. + if let Some(key) = &deepseek_key { + std::env::set_var("DEEPSEEK_API_KEY", key); + } + + info!( + telegram = tg_token.is_some(), + deepseek = deepseek_key.is_some(), + "credentials resolved (build flags + runtime env)" + ); + + // ── Bring up the control-plane core (glasspane + Herdr socket + loop) ──── + let config = DaemonConfig::from_env(); + info!( + host = %config.host, + data_dir = %config.data_dir.display(), + socket_path = %config.socket_path.display(), + "control plane configured" + ); + + let state: SharedState = Arc::new(DaemonState::new(config.clone())); + + tokio::fs::create_dir_all(&config.data_dir).await?; + tokio::fs::create_dir_all(config.data_dir.join("sessions")).await?; + + // Reload any persisted sessions so supervision picks up where it left off. + if let Ok(mut entries) = tokio::fs::read_dir(config.data_dir.join("sessions")).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + let session_id = stem.to_string(); + let cfg = Arc::new(config.clone()); + match session::Session::load(session_id.clone(), cfg) { + Ok(sess) => { + state.sessions.insert(session_id, sess); + } + Err(e) => warn!(error = %e, "failed to load session file"), + } + } + } + } + } + + // Herdr Unix-socket API. + let socket_state = state.clone(); + let socket_shutdown = state.shutdown_rx.resubscribe(); + let socket_handle = tokio::spawn(async move { + socket::serve(socket_state, socket_shutdown).await; + }); + + // Coordination / heartbeat / glasspane loop. + let loop_state = state.clone(); + let loop_shutdown = state.shutdown_rx.resubscribe(); + let loop_handle = tokio::spawn(async move { + daemon::run_loop( + loop_state, + daemon::DaemonLoopConfig::default(), + loop_shutdown, + ) + .await; + }); + + // ── Telegram front door (only if we have both a token and a key) ───────── + let telegram_handle = match (tg_token, &deepseek_key) { + (Some(token), Some(key)) => match deepseek::DeepSeek::new(key.clone()) { + Ok(ds) => { + let tg_shutdown = state.shutdown_rx.resubscribe(); + Some(tokio::spawn(async move { + telegram::run(token, ds, tg_shutdown).await; + })) + } + Err(e) => { + warn!(error = %e, "could not start DeepSeek lane; Telegram bridge disabled"); + None + } + }, + (Some(_), None) => { + warn!("Telegram token present but no DeepSeek key — bridge disabled"); + None + } + (None, _) => { + info!("no Telegram token — running headless (control plane only)"); + None + } + }; + + // Graceful shutdown on Ctrl-C / SIGTERM. + let shutdown_state = state.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + info!("interrupt received, shutting down"); + let _ = shutdown_state.shutdown_tx.send(()); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + for entry in shutdown_state.agents.iter() { + let _ = entry.value().kill().await; + } + }); + + let (socket_result, loop_result) = tokio::join!(socket_handle, loop_handle); + socket_result?; + loop_result?; + if let Some(h) = telegram_handle { + let _ = h.await; + } + + info!("clawdie shut down cleanly"); + Ok(()) +} diff --git a/crates/clawdie/src/telegram.rs b/crates/clawdie/src/telegram.rs new file mode 100644 index 0000000..67c4101 --- /dev/null +++ b/crates/clawdie/src/telegram.rs @@ -0,0 +1,133 @@ +//! Minimal Telegram bridge: long-poll `getUpdates`, hand each text message to +//! the DeepSeek lane, and `sendMessage` the reply back. +//! +//! No webhooks, no command framework, no per-user quota — the simplest thing +//! that gives an operator a working chat bot out of the box. Shuts down cleanly +//! when the daemon broadcasts shutdown. + +use serde::Deserialize; +use tokio::sync::broadcast; +use tracing::{info, warn}; + +use crate::deepseek::DeepSeek; + +const API_BASE: &str = "https://api.telegram.org"; + +#[derive(Deserialize)] +struct UpdatesResponse { + #[serde(default)] + ok: bool, + #[serde(default)] + result: Vec, +} + +#[derive(Deserialize)] +struct Update { + update_id: i64, + #[serde(default)] + message: Option, +} + +#[derive(Deserialize)] +struct Message { + #[serde(default)] + text: Option, + chat: Chat, +} + +#[derive(Deserialize)] +struct Chat { + id: i64, +} + +/// Run the bridge until `shutdown` fires. Network errors are logged and retried +/// rather than fatal — the bot should survive flaky links on a live USB. +pub async fn run(token: String, deepseek: DeepSeek, mut shutdown: broadcast::Receiver<()>) { + let client = match reqwest::Client::builder() + .user_agent(concat!("clawdie/", env!("CARGO_PKG_VERSION"))) + .build() + { + Ok(c) => c, + Err(e) => { + warn!(error = %e, "telegram: failed to build HTTP client; bridge disabled"); + return; + } + }; + let base = format!("{API_BASE}/bot{token}"); + let mut offset: i64 = 0; + info!("telegram bridge online (long-poll)"); + + loop { + tokio::select! { + _ = shutdown.recv() => { + info!("telegram bridge shutting down"); + return; + } + updates = poll_once(&client, &base, offset) => { + match updates { + Ok(list) => { + for upd in list { + offset = offset.max(upd.update_id + 1); + if let Some(msg) = upd.message { + if let Some(text) = msg.text { + handle_message(&client, &base, &deepseek, msg.chat.id, &text) + .await; + } + } + } + } + Err(e) => { + warn!(error = %e, "telegram: getUpdates failed; retrying"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + } + } + } + } +} + +async fn poll_once( + client: &reqwest::Client, + base: &str, + offset: i64, +) -> Result, Box> { + // 25s long-poll; keep below the client's default timeout. + let resp: UpdatesResponse = client + .get(format!("{base}/getUpdates")) + .query(&[("timeout", "25"), ("offset", &offset.to_string())]) + .timeout(std::time::Duration::from_secs(35)) + .send() + .await? + .error_for_status()? + .json() + .await?; + if !resp.ok { + return Err("telegram getUpdates returned ok=false".into()); + } + Ok(resp.result) +} + +async fn handle_message( + client: &reqwest::Client, + base: &str, + deepseek: &DeepSeek, + chat_id: i64, + text: &str, +) { + let reply = match deepseek.reply(text).await { + Ok(r) if !r.trim().is_empty() => r, + Ok(_) => "(no reply)".to_string(), + Err(e) => { + warn!(error = %e, "telegram: deepseek reply failed"); + "Sorry — I couldn't reach the model just now.".to_string() + } + }; + if let Err(e) = client + .post(format!("{base}/sendMessage")) + .json(&serde_json::json!({ "chat_id": chat_id, "text": reply })) + .send() + .await + { + warn!(error = %e, "telegram: sendMessage failed"); + } +} diff --git a/docs/CLAWDIE-AGENT-WIKI.md b/docs/CLAWDIE-AGENT-WIKI.md new file mode 100644 index 0000000..022bded --- /dev/null +++ b/docs/CLAWDIE-AGENT-WIKI.md @@ -0,0 +1,98 @@ +# Clawdie Agent — wiki / mindmap + +The **Clawdie agent** is the simplified, operator-friendly face of Colibri: one +small Rust binary (`clawdie`) that ships with exactly two things wired up — a +Telegram bot and a DeepSeek lane — sitting on top of the proven control-plane +core. Everything heavier (cost modes, quotas, multi-provider fallback) is +deliberately **lifted**. + +This is a mindmap-style wiki page (the format from the Herdr/Colibri capability +graph work — see [`HERDR-VS-COLIBRI-GRAPH.md`](./HERDR-VS-COLIBRI-GRAPH.md) and +[`COLIBRI-GLASSPANE-DESIGN.md`](./COLIBRI-GLASSPANE-DESIGN.md)). + +## Mindmap + +```mermaid +mindmap + root((clawdie
one binary)) + Split brain + Glasspane radar + 5-state machine + idle/working/blocked/done/error + derived from Pi JSON events + Coordination + task board + agent registry + session JSONL + Front door + Telegram bot + long-poll getUpdates + sendMessage reply + DeepSeek lane + one model + one key + no fallback + Build flags + CLAWDIE_TG_TOKEN + CLAWDIE_DEEPSEEK_KEY + baked at compile time + runtime env overrides + Lifted (NOT shipped) + cost modes + quota accounting + context budgets + OpenRouter / Anthropic + per-user limits + Runs as a service + rc.d clawdie + daemon(8) supervised + restart on crash + like Clawdie-AI +``` + +## What "split brain" means here + +Two halves of the same daemon, both kept: + +| Half | Crate / module | Job | +| ---------------- | ----------------------------------- | ----------------------------------------------------- | +| **Glasspane** | `colibri-glasspane` + daemon socket | The "radar": agent state, panes, supervision snapshot | +| **Coordination** | `colibri-store` + daemon loop | Task board, agent registry, session lifecycle | + +The Clawdie agent reuses these as-is over the Herdr Unix-socket API. It adds the +Telegram + DeepSeek front door and **removes** the cost/quota machinery from its +own runtime path. + +## Surface area (the whole product) + +```mermaid +graph LR + tg["Telegram
(CLAWDIE_TG_TOKEN)"] -->|text| clawdie + clawdie -->|chat| ds["DeepSeek
(CLAWDIE_DEEPSEEK_KEY)"] + ds -->|reply| clawdie + clawdie --> gp["glasspane radar"] + clawdie --> co["coordination board"] + clawdie -. Herdr socket .- ui["operator tools (colibri CLI / TUI)"] +``` + +## Files + +| Path | Role | +| -------------------------------- | ------------------------------------------------------- | +| `crates/clawdie/src/main.rs` | Entrypoint: resolve creds, start core + Telegram bridge | +| `crates/clawdie/src/telegram.rs` | Long-poll bridge | +| `crates/clawdie/src/deepseek.rs` | Minimal one-key chat lane (no cost/quota) | +| `crates/clawdie/build.rs` | Bakes the two credentials from build flags | +| `packaging/freebsd/clawdie.in` | rc.d service (daemon(8)-supervised, like Clawdie-AI) | + +## Credential resolution order + +1. Runtime env (`CLAWDIE_TG_TOKEN` / `TELEGRAM_BOT_TOKEN`, + `CLAWDIE_DEEPSEEK_KEY` / `DEEPSEEK_API_KEY`) +2. Build-flag baked value (`option_env!` from `build.rs`) +3. Absent → that half stays off (no token = headless control plane only) + +One DeepSeek key serves both the Telegram chat lane and the daemon's own +provider routing. + +See [`CLAWDIE-BUILD.md`](./CLAWDIE-BUILD.md) for the build + ISO instructions. diff --git a/docs/CLAWDIE-BUILD.md b/docs/CLAWDIE-BUILD.md new file mode 100644 index 0000000..6da4a18 --- /dev/null +++ b/docs/CLAWDIE-BUILD.md @@ -0,0 +1,100 @@ +# Clawdie agent — build & ISO instructions + +The `clawdie` binary is the simplified Colibri agent (see +[`CLAWDIE-AGENT-WIKI.md`](./CLAWDIE-AGENT-WIKI.md)). It is configured almost +entirely by **build flags**, so a baked ISO ships ready to run. + +## 1. Build with baked credentials + +Pass the two credentials as environment variables at build time. `build.rs` +bakes whatever is present into the binary; runtime env still overrides. + +```sh +cd ~/colibri +CLAWDIE_TG_TOKEN="123456:telegram-bot-token" \ +CLAWDIE_DEEPSEEK_KEY="sk-deepseek-key" \ + cargo build --release -p clawdie + +ls -lh target/release/clawdie # release profile strips symbols automatically +target/release/clawdie --version 2>/dev/null || true +``` + +Build with **no** flags for a "bring your own key" binary — it reads +`CLAWDIE_TG_TOKEN` / `TELEGRAM_BOT_TOKEN` and `CLAWDIE_DEEPSEEK_KEY` / +`DEEPSEEK_API_KEY` from the runtime environment (or the rc.d env file) instead. + +> Build credentials are secrets. Pass them inline on the build command (so they +> are not persisted), never commit them, and prefer the runtime env file for +> rotation. They are never printed to the build log. + +## 2. What the binary does out of the box + +- Starts the control-plane core: glasspane supervision + Herdr Unix socket + + coordination loop. +- If a Telegram token **and** a DeepSeek key are present, runs the Telegram + bridge (long-poll → DeepSeek → reply). +- No token → headless (control plane only). Token but no key → bridge disabled + with a warning. + +No cost modes, quotas, context budgets, or provider fallback — those are lifted +from this agent on purpose. + +## 3. Run as a service (like Clawdie-AI) + +On FreeBSD, install the rc.d script and enable it: + +```sh +pw groupadd clawdie +pw useradd clawdie -g clawdie -d /var/db/clawdie -s /usr/sbin/nologin +install -m 0555 packaging/freebsd/clawdie.in /usr/local/etc/rc.d/clawdie +install -m 0555 target/release/clawdie /usr/local/bin/clawdie +sysrc clawdie_enable=YES +service clawdie start +service clawdie status +``` + +Per-host credential override (optional; binary already has baked defaults): + +```sh +install -d -m 0750 /usr/local/etc/clawdie +cat > /usr/local/etc/clawdie/clawdie.env <<'EOF' +CLAWDIE_TG_TOKEN=123456:telegram-bot-token +CLAWDIE_DEEPSEEK_KEY=sk-deepseek-key +EOF +chmod 0600 /usr/local/etc/clawdie/clawdie.env +service clawdie restart +``` + +## 4. ISO integration + +The ISO build stages the prebuilt FreeBSD `clawdie` release binary + rc.d (it +never compiles Rust while the image is mounted), the same model as the Colibri +daemon staging. In `clawdie-iso`: + +- `build.cfg`: `FEATURE_CLAWDIE`, `CLAWDIE_ENABLE`, and the build-flag creds. +- `scripts/stage-clawdie-iso.sh`: installs binary + rc.d + rc.conf sample. + +Build the binary on the FreeBSD/OSA host (not Debian/Linux), then run the ISO +preflight. **Do not `cargo clean` the colibri checkout** until the ISO build has +consumed `target/release/clawdie`. + +## 5. Next ISO build — XFCE operator-USB fixes to carry forward + +The next operator-USB image must retain the XFCE/live-GUI fixes already proven +on real hardware (see `clawdie-iso/PLAN-OPERATOR-USB-NEXT.md` and +`doc/AMD-ASUS-XFCE-LIVE-USB-FINDINGS.md`). Load-bearing items: + +- **SDDM over LightDM** — LightDM was the silent blocker for live GUI boot on + Intel and AMD. SDDM is part of the operator-USB contract; do **not** revert. +- **`clawdie-live-gpu`** — pre-SDDM rc.d service that does a conservative KMS + pick (Intel iGPU works out of the box; AMD/NVIDIA picked conservatively) + before the display manager starts. +- **Hardened USB-root power policy** (commit `b9290ab`): `powerdxx`, C3 C-state + default, `hw.usb.no_suspend=1`, and a boot-time `power_profile` — keeps + USB-root laptops from stalling/suspending. +- **Base runtime fixes already landed**: resolver bootstrap, internal-audio + preference, touchpad XInput guards, loader branding, hardware-report capture, + public probe-URL reporting, `hw-probe` upload dependency. + +Final XFCE/session/input/audio behavior is only proven on **real hardware** — +bhyve/nested VMs/static image inspection are build gates, not final proof. diff --git a/packaging/freebsd/clawdie.in b/packaging/freebsd/clawdie.in new file mode 100644 index 0000000..375fc3c --- /dev/null +++ b/packaging/freebsd/clawdie.in @@ -0,0 +1,86 @@ +#!/bin/sh +# +# clawdie — FreeBSD rc.d service for the simplified Colibri agent. +# +# Operator-friendly by design: enable it and start it. The two credentials +# (Telegram bot token + DeepSeek key) are normally baked into the binary at +# build time (see crates/clawdie/build.rs), so a baked ISO needs no config. +# To override per-host, drop them in the env file (default +# /usr/local/etc/clawdie/clawdie.env) or set them in rc.conf below. +# +# clawdie runs in the FOREGROUND (no self-daemonize, no pidfile), so rc.d runs +# it under daemon(8), which backgrounds it, writes the supervisor pidfile, +# restarts on crash, drops privileges to the clawdie user, and redirects +# stdout/stderr (tracing) to a logfile. +# +# Install: +# pw groupadd clawdie +# pw useradd clawdie -g clawdie -d /var/db/clawdie -s /usr/sbin/nologin +# cp packaging/freebsd/clawdie.in /usr/local/etc/rc.d/clawdie +# chmod 555 /usr/local/etc/rc.d/clawdie +# sysrc clawdie_enable=YES +# service clawdie start +# +# Requires the clawdie binary at /usr/local/bin/clawdie. + +# PROVIDE: clawdie +# REQUIRE: LOGIN NETWORKING cleanvar +# KEYWORD: shutdown + +. /etc/rc.subr + +name="clawdie" +rcvar="clawdie_enable" + +load_rc_config $name + +: ${clawdie_enable:="NO"} +: ${clawdie_user:="clawdie"} +: ${clawdie_group:="clawdie"} +: ${clawdie_program:="/usr/local/bin/clawdie"} +: ${clawdie_data_dir:="/var/db/clawdie"} +: ${clawdie_run_dir:="/var/run/clawdie"} +: ${clawdie_socket:="${clawdie_run_dir}/clawdie.sock"} +: ${clawdie_logfile:="/var/log/clawdie/clawdie.log"} +: ${clawdie_host:="$(/bin/hostname)"} +: ${clawdie_env_file:="/usr/local/etc/clawdie/clawdie.env"} + +pidfile="${clawdie_run_dir}/clawdie.pid" + +# Supervise via daemon(8): -P supervisor pidfile, -r restart on exit, -t title, +# -u drop privileges, -o append stdout/stderr to the logfile. +command="/usr/sbin/daemon" +command_args="-P ${pidfile} -r -t ${name} -u ${clawdie_user} \ + -o ${clawdie_logfile} ${clawdie_program}" + +# rc.subr matches the pidfile process against ${procname}; that is daemon(8), +# the supervised parent — not clawdie itself. +procname="/usr/sbin/daemon" + +start_precmd="clawdie_prestart" + +clawdie_prestart() +{ + # /var/run is tmpfs on FreeBSD (wiped each boot) — recreate every start. + install -d -o "${clawdie_user}" -g "${clawdie_group}" -m 0750 "${clawdie_run_dir}" + install -d -o "${clawdie_user}" -g "${clawdie_group}" -m 0750 "${clawdie_data_dir}" + install -d -o "${clawdie_user}" -g "${clawdie_group}" -m 0750 \ + "$(/usr/bin/dirname "${clawdie_logfile}")" + + # Control-plane config passed to the child via the environment. + export COLIBRI_DAEMON_DATA_DIR="${clawdie_data_dir}" + export COLIBRI_DAEMON_SOCKET="${clawdie_socket}" + export COLIBRI_HOST="${clawdie_host}" + + # Optional per-host credential overrides (binary already has baked defaults). + # File format: simple KEY=VALUE lines, e.g. + # CLAWDIE_TG_TOKEN=123456:abc + # CLAWDIE_DEEPSEEK_KEY=sk-... + if [ -r "${clawdie_env_file}" ]; then + set -a + . "${clawdie_env_file}" + set +a + fi +} + +run_rc_command "$1" -- 2.45.3